ModernUO/dev-docs/runuo-migration-docs/11-api-reference.md
Kamron Batman 502c3f6aa4
fix(ai): BaseAI.OnAggressiveAction owns the retaliation policy; familiars refuse cleanly (#2662)
> [!IMPORTANT]
> **Breaking change to API to fix NPC AI issues**

## Symptom

`FamiliarAITests.HiddenCaster_FamiliarRefusesRetaliation` fails on main at `Assert.False(wolf.Warmode)`: the familiar's `Combatant` stays null as intended, but it is left in war stance. In-game the familiar of a hidden caster flips into warmode for a tick when struck, until `FamiliarAI.StandDown()` clears it on the next think.

CI never saw it because the familiar tests are `SkippableFact` (they need client tiledata).

## Root cause

#2644 and #2645 landed a day apart and are individually correct but conflict at one seam.

Since #2645, a familiar (`Commandable == false`) is no longer "told to stand down", so `BaseCreature.AggressiveAction` continues past `AIObject.OnAggressiveAction(aggressor)` to its generic fallback:

```cs
else if (Combatant == null && !BardPacified)
{
    Warmode = true;
    Combatant = aggressor;
}
```

`FamiliarAI.OnAggressiveAction` had already declined (caster hidden), but the hook was `void`, so the creature could not tell. The fallback raised `Warmode` before `BaseFamiliar.Combatant` vetoed the target.

## Fix

This is the follow-up agreed in #2645: the retaliation policy moves into a virtual `BaseAI.OnAggressiveAction(aggressor) -> bool`.

- **`BaseAI.OnAggressiveAction`** now holds the stand-down verdict, the closer-aggressor swap (`PreferCloserAggressor`), and the `IssueOrder(Attack)` vs `Combatant =` decision. Returns `false` when the creature stands down.
- **`BaseCreature.AggressiveAction`** keeps only bookkeeping — `ControlMaster.Aggressors` entry, `StopFlee`, `ForceReacquire`, ethics `FinishShield` — keyed off the return, so an AI that declines is never second-guessed.
- **`FamiliarAI`** overrides the whole policy: no orders to stand down on, never fights for a hidden caster or a hidden aggressor.

Behaviour for every other AI is unchanged. Moving the fallback ahead of `StopFlee`/`ForceReacquire` is order-safe: both are pure field resets, `IssueAttack` does not `Think()`, and `OnCombatantChange` only derives `Warmode`.

Two commits: the first is the minimal `bool` hook that alone fixes the test, the second the policy move.

## Not changed (noted for later)

- `Warmode = true; Combatant = x;` (also in `FamiliarAI.Act`, `IssueAttack`) is redundant when the setter accepts — `BaseCreature.OnCombatantChange` derives `Warmode` — and is the dangling-warmode hazard whenever a `Combatant` setter vetoes.
- Since #2614 a stood-down pet skips the ethics `FinishShield`; RunUO ran it unconditionally. Preserved as-is.

## Tests

`HiddenCaster_FamiliarRefusesRetaliation` fails on main and passes here. Full `UOContent.Tests`: 1126 passed, 0 failed, 2 skipped (spawner fixture capture + benchmark).
2026-09-22 18:48:13 -07:00

208 lines
11 KiB
Markdown

# API Reference — RunUO to ModernUO Mapping
## Quick Lookup
Alphabetical by RunUO API name. Use Ctrl+F / Cmd+F to search.
## Attributes
| RunUO | ModernUO | Notes |
|---|---|---|
| `[Constructable]` | `[Constructible]` | Spelling change |
| `[CommandProperty(AccessLevel)]` | `[SerializedCommandProperty(AccessLevel)]` | On `[SerializableField]` fields |
| `[CommandProperty(AccessLevel)]` | `[CommandProperty(AccessLevel)]` | On `[SerializableProperty]` properties (unchanged) |
| `[CorpseName("name")]` | `public override string CorpseName => "name";` | Attribute → property override |
| `[Constructable]` | `[Constructible]` | Must use `using ModernUO.Serialization;` |
| `[Usage("...")]` | `[Usage("...")]` | Unchanged |
| `[Description("...")]` | `[Description("...")]` | Unchanged |
| N/A | `[Aliases("a1", "a2")]` | New in ModernUO |
| N/A | `[SerializationGenerator(ver, enc)]` | New — replaces manual Serialize/Deserialize |
| N/A | `[SerializableField(idx)]` | New — auto-generates property + serialization |
| N/A | `[SerializableProperty(idx)]` | New — custom property serialization |
| N/A | `[InvalidateProperties]` | New — auto-calls InvalidateProperties on set |
| N/A | `[AfterDeserialization]` | New — post-deserialize hook |
| N/A | `[DeltaDateTime]` | New — relative DateTime storage |
| N/A | `[EncodedInt]` | New — variable-length int encoding |
| N/A | `[InternString]` | New — string interning on load |
| N/A | `[Tidy]` | New — auto-clean null entries in collections |
| N/A | `[CanBeNull]` | New — nullable reference field |
| N/A | `[TypeAlias("old.name")]` | New — backward-compatible type mapping |
## Classes
| RunUO | ModernUO | Notes |
|---|---|---|
| `BinaryFileWriter` | `IGenericWriter` (via GenericPersistence) | No manual file writing |
| `BinaryFileReader` | `IGenericReader` (via GenericPersistence) | No manual file reading |
| `GenericWriter` | `IGenericWriter` | Interface now |
| `GenericReader` | `IGenericReader` | Interface now |
| `Gump` | `DynamicGump` or `StaticGump<T>` | See 04-gumps.md |
| `ObjectPropertyList` (parameter) | `IPropertyList` | Interface for GetProperties |
| `Packet` (base class) | Removed — use static methods | See 05-packets.md |
| `PacketWriter` | `SpanWriter` | Ref struct |
| `PacketReader` | `SpanReader` | Ref struct |
| `Timer` (subclass pattern) | `Timer.StartTimer()` + `TimerExecutionToken` | See 03-timers.md |
| `TextRelay` | Removed — use `info.GetTextEntry(id)` | Returns string directly |
| `RelayInfo` | `RelayInfo` (passed with `in`) | `in RelayInfo` in OnResponse |
## Constructors
| RunUO | ModernUO | Notes |
|---|---|---|
| `MyItem(Serial serial) : base(serial)` | DELETE | Auto-generated |
| `BaseCreature(AI, Fight, 10, 1, 0.2, 0.4)` | `BaseCreature(AI, Fight)` | Extra params have defaults |
## Events (EventSink)
| RunUO | ModernUO | Signature |
|---|---|---|
| `EventSink.Login` | `EventSink.Connected` | `LoginEventArgs` → `Action<Mobile>` |
| `EventSink.Logout` | `EventSink.Disconnected` | `LogoutEventArgs` → `Action<Mobile>` |
| `EventSink.WorldSave` | `EventSink.WorldSave` | `WorldSaveEventArgs` → `Action` |
| `EventSink.WorldLoad` | `EventSink.WorldLoad` | `Action` |
| `EventSink.ServerStarted` | `EventSink.ServerStarted` | `Action` |
| `EventSink.Crashed` | `EventSink.ServerCrashed` | Renamed |
| `EventSink.Speech` | `EventSink.Speech` | `Action<SpeechEventArgs>` |
| `EventSink.Movement` | `EventSink.Movement` | `Action<MovementEventArgs>` |
| `EventSink.AggressiveAction` | `EventSink.AggressiveAction` | `Action<AggressiveActionEventArgs>` |
| `EventSink.AccountLogin` | `EventSink.AccountLogin` | `Action<AccountLoginEventArgs>` |
| `EventSink.SocketConnect` | `EventSink.SocketConnect` | `Action<SocketConnectEventArgs>` |
| `EventSink.Shutdown` | `EventSink.Shutdown` | `Action` |
| `EventSink.BeforeWorldSave` | Removed | Use `WorldSave` |
| `EventSink.PlayerDeath` | `PlayerMobile.PlayerDeathEvent` | CodeGeneratedEvent |
| `EventSink.CreatureDeath` | `BaseCreature.CreatureDeathEvent` | CodeGeneratedEvent |
## Event Delegates
| RunUO | ModernUO |
|---|---|
| `new WorldSaveEventHandler(Save)` | `Save` (direct method reference) |
| `new WorldLoadEventHandler(Load)` | `Load` |
| `new LoginEventHandler(OnLogin)` | `OnConnected` |
| `new LogoutEventHandler(OnLogout)` | `OnDisconnected` |
| `new SpeechEventHandler(OnSpeech)` | `OnSpeech` |
| `new TimerCallback(Method)` | `Method` |
| `new TimerStateCallback(Method)` | `Method` (typed overloads) |
| `new OnPacketReceive(Handler)` | `&Handler` (function pointer) |
## Gump Methods
| RunUO | ModernUO | Notes |
|---|---|---|
| `AddPage(0)` | `builder.AddPage()` | 0 is default |
| `AddBackground(...)` | `builder.AddBackground(...)` | On builder |
| `AddAlphaRegion(...)` | `builder.AddAlphaRegion(...)` | On builder |
| `AddLabel(x, y, hue, text)` | `builder.AddLabel(x, y, hue, text)` | On builder |
| `AddHtml(x, y, w, h, text, bg, scroll)` | `builder.AddHtml(x, y, w, h, text, background: bg, scrollbar: scroll)` | Named params |
| `AddHtmlLocalized(...)` | `builder.AddHtmlLocalized(...)` | On builder |
| `AddButton(x, y, n, p, id, Reply, 0)` | `builder.AddButton(x, y, n, p, id)` | Reply is default |
| `AddTextEntry(...)` | `builder.AddTextEntry(...)` | On builder |
| `AddCheck(x, y, off, on, state, id)` | `builder.AddCheckbox(x, y, off, on, state, id)` | Renamed |
| `Closable = false` | `builder.SetNoClose()` | Property → method |
| `Dragable = false` | `builder.SetNoMove()` | Renamed |
| `Resizable = false` | `builder.SetNoResize()` | Property → method |
| `Disposable = false` | `builder.SetNoDispose()` | Property → method |
| `from.SendGump(new G(...))` | `G.DisplayTo(from, ...)` | Static entry point |
| `from.CloseGump(typeof(G))` | `from.CloseGump<G>()` | Generic |
| `from.HasGump(typeof(G))` | `from.HasGump<G>()` | Generic |
## Gump Response
| RunUO | ModernUO |
|---|---|
| `OnResponse(NetState, RelayInfo)` | `OnResponse(NetState, in RelayInfo)` |
| `info.TextEntries[i].Text` | `info.GetTextEntry(id)` |
| `info.IsSwitched(id)` | `info.IsSwitched(id)` (same) |
| `info.ButtonID` | `info.ButtonID` (same) |
## Methods
| RunUO | ModernUO | Notes |
|---|---|---|
| `DateTime.UtcNow` | `Core.Now` | Server time source |
| `Console.WriteLine(...)` | `logger.Information(...)` | Structured logging |
| `World.FindMobile(serial)` | `reader.ReadEntity<Mobile>()` | In deserialization |
| `World.FindItem(serial)` | `reader.ReadEntity<Item>()` | In deserialization |
| `reader.ReadMobile()` | `reader.ReadEntity<Mobile>()` | Generic method |
| `reader.ReadItem()` | `reader.ReadEntity<Item>()` | Generic method |
| `reader.ReadInt()` | `reader.ReadInt()` | Same |
| `writer.Write((int)value)` | `writer.Write(value)` | No cast needed |
| `writer.WriteEncodedInt(value)` | `writer.WriteEncodedInt(value)` | Same |
| `InvalidateProperties()` | `InvalidateProperties()` | Same, or use `[InvalidateProperties]` |
| `this.MarkDirty()` | `this.MarkDirty()` | NEW — required in custom setters |
| `MoveTo(m, run, range)` | `MoveTo(m, range)` | `run` removed; the Running bit is derived from the step pace (`BaseAI.ShouldRun`) |
| `WalkMobileRange(m, steps, run, min, max)` | `WalkMobileRange(m, steps, min, max)` | Same |
| `PathFollower.Follow(run, range)` | `Follow(range)` | Same |
| `AcquireOnApproach` (bool) | `AcquireOnApproachDelay` (TimeSpan) | Reaction-time gradient; `Zero` = old instant behavior |
| `BaseAI.OnAggressiveAction(m)` (`void`) | `OnAggressiveAction(m)` (`bool`) | The AI owns the retaliation policy; `false` = stood down; swap-only overrides call `PreferCloserAggressor` |
| `m.DamageEntries` (`List<DamageEntry>`) | `m.DamageEntries` (`ref readonly ValueLinkList<DamageEntry>`) | Inline, least→most recent; `foreach` / `.ByDescending()` only, needs `using Server.Collections;`; no indexer, `Add`, `Remove`, `Clear` |
| `m.DamageEntries.Clear()` | `m.ClearDamageEntries()` | |
| `GetLootingRights(List<DamageEntry>, int)` | `GetLootingRights(in ValueLinkList<DamageEntry>, int)` | Callers passing `m.DamageEntries` compile unchanged |
## Networking
| RunUO | ModernUO | Notes |
|---|---|---|
| `ns.Send(new MyPacket(...))` | `ns.SendMyPacket(...)` | Extension method |
| `Packet.Compile()` | Not needed | Stack-allocated |
| `Packet.SetStatic()` | Not needed | Reuse via buffer[0] != 0 guard |
| `PacketHandlers.Register(...)` | `IncomingPackets.Register(...)` | Function pointers |
| `m_Stream.Write(value)` | `writer.Write(value)` | SpanWriter |
| `pvSrc.ReadInt32()` | `reader.ReadInt32()` | SpanReader |
| `pvSrc.ReadString()` | `reader.ReadAsciiSafe()` | Explicit encoding |
| `pvSrc.ReadUnicodeStringSafe()` | `reader.ReadBigUniSafe()` | Explicit name |
## Persistence
| RunUO | ModernUO | Notes |
|---|---|---|
| `EventSink.WorldSave += Save` | `class X : GenericPersistence` | Subclass |
| `EventSink.WorldLoad += Load` | `override Deserialize(IGenericReader)` | Method |
| `new BinaryFileWriter(path, true)` | Handled by framework | Automatic |
| `BinaryFileReader` / `FileStream` | Handled by framework | Automatic |
| `Directory.CreateDirectory(...)` | Handled by framework | Automatic |
## Properties
| RunUO | ModernUO | Notes |
|---|---|---|
| `Name = "text"` (in constructor) | `public override string DefaultName => "text";` | Property override |
| `Name` (custom text) | `DefaultName` (property override) | For fixed names |
| `int Prop { get { return m_X; } }` | `int Prop => _x;` | Expression-bodied |
| `int Prop { get { return m_X; } set { m_X = value; } }` | Auto-generated by `[SerializableField]` | Delete manual property |
## Timer
| RunUO | ModernUO | Notes |
|---|---|---|
| `new InternalTimer().Start()` | `Timer.StartTimer(..., out token)` | Fire-and-forget |
| `Timer.DelayCall(delay, callback)` | `Timer.StartTimer(delay, callback)` | Similar |
| `Timer.DelayCall(delay, stateCallback, state)` | `Timer.DelayCall(delay, callback, state)` | Typed state |
| `timer.Stop()` | `token.Cancel()` | Struct, safe to call multiple times |
| `timer.Running` | `token.Running` | Same concept |
| `TimerPriority.XXX` | Removed | Timer wheel auto-schedules |
| Timer in `Deserialize()` | `[AfterDeserialization]` | Post-load hook |
| `timer != null` check | `token.Running` check | Value type, always valid |
## Threading / Performance
| RunUO | ModernUO | Notes |
|---|---|---|
| `lock (_obj)` | Remove | Single-threaded |
| `volatile` | Remove keyword | Single-threaded |
| `ConcurrentDictionary` | `Dictionary` | Single-threaded |
| `Task.Run(...)` | Use `Timer.StartTimer()` | Game loop only |
| `new Thread(...)` | Forbidden | Game loop only |
| `ArrayPool<T>.Shared` | `STArrayPool<T>.Shared` | No-lock pool |
| `new List<T>()` (hot path) | `PooledRefList<T>.Create()` | Zero-alloc |
| `World.Mobiles.Values` iteration | `map.GetMobilesInRange<T>()` | Spatial query |
| `World.Items.Values` iteration | `map.GetItemsInRange<T>()` | Spatial query |
## Usings
| RunUO | ModernUO | Notes |
|---|---|---|
| (implicit) | `using ModernUO.Serialization;` | For serialization attributes |
| (implicit) | `using Server.Logging;` | For logging |
| (implicit) | `using Server.Gumps;` | For gump extension methods |
| (implicit) | `using Server.Collections;` | For PooledRefList |