diff --git a/docs/research/2026-08-11-campaign-op-test-script.md b/docs/research/2026-08-11-campaign-op-test-script.md index cd23eb3f..a4c1fb59 100644 --- a/docs/research/2026-08-11-campaign-op-test-script.md +++ b/docs/research/2026-08-11-campaign-op-test-script.md @@ -1,6 +1,6 @@ # Campaign OP connected-gate test script -**Status:** OP3 section only. Later slices (OP4-6, OP8) append their own +**Status:** OP3 and OP7 sections. Later slices (OP4-6, OP8) append their own sections here as they land; the campaign's OP9 closeout gate is this document complete plus every slice code-complete. @@ -112,3 +112,126 @@ for this slice, not a bug. - Configure Keyboard's actual screen — OP8. - Any observable camera change from "Use Mouse Turning Settings" — no acdream consumer exists yet (TS-74). + +--- + +## OP7 — headless `characterOptions` + +Unlike OP3-OP6, this is not a graphical-client gate: no window is launched. +The coordinator runs `acdream-headless run` against local ACE with a config +declaring a small `characterOptions` block, and inspects the ACE-side +persisted `CharacterOptions1`/`CharacterOptions2` (and, for the +`ListenTo*Chat` ids, the actual Turbine room membership) before and after. +Automated coverage (schema rejection, the diff-and-send engine, the wiring +from a real `PlayerDescription` game event through to a captured wire +action, and dedicated-update-thread affinity) already runs in +`tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs`, +`HeadlessCharacterOptionsSeederTests.cs`, and +`HeadlessCharacterOptionsSeederWiringTests.cs` — this script is for the ONE +thing those tests cannot prove: that a REAL ACE server actually accepts and +persists the sends. + +### Recipe + +1. **Confirm the character's starting state.** Before running the bot, + note (or reset) the target character's `IgnoreAllegianceRequests` + (0x01, auto-save, `SetSingleCharacterOption 0x0005`) and + `ListenToTradeChat` (0x24, auto-save, also `0x0005` — additionally + joins/leaves the Turbine trade room) options — e.g. via an existing + graphical login, or by inspecting ACE's stored `CharacterOptions1`/ + `CharacterOptions2` for the character row directly. Pick a THIRD, + batched (non-auto-save) id for the blob path — `SalvageMultiple` + (0x22, tier 2) is a safe choice: it has no observable server-side + effect beyond the stored bit, so a mismatch is purely a persistence + question, not a behavior one. +2. **Author a headless config** with all three declared at values that + DIFFER from the character's current stored state, e.g.: + ```json + { + "version": 1, + "sessions": [{ + "id": "op7-bot", + "endpoint": { "host": "127.0.0.1", "port": 9000 }, + "account": "testaccount", + "character": { "name": "+Acdream" }, + "policy": { "id": "idle" }, + "credential": { "provider": "environment", "reference": "OP7_BOT_PASSWORD" }, + "characterOptions": { + "IgnoreAllegianceRequests": true, + "ListenToTradeChat": true, + "SalvageMultiple": true + } + }] + } + ``` + (Flip each `true` to `false` instead if the character already has that + bit set — the point is a genuine diff in both directions, not + specifically "everything ON".) +3. **Run it**: `acdream-headless run --config op7-bot.json` with + `OP7_BOT_PASSWORD` set in the environment. Let it sit in-world a few + seconds, then stop it (Ctrl+C — the process scheduler's graceful- + shutdown path already sends a real logoff). + +### Expected wire sends on first connect + +- One `SetSingleCharacterOption (0x0005)` for `IgnoreAllegianceRequests` + (id `0x01`) — immediately, no batching. +- One `SetSingleCharacterOption (0x0005)` for `ListenToTradeChat` (id + `0x24`) — immediately, and ACE's handler additionally joins the + Turbine trade-chat room server-side for this session (observable via + ACE's own Turbine-chat membership logging if available). +- One `SetSingleCharacterOption (0x0005)` for `SalvageMultiple` (id + `0x22`) followed by exactly one `SetCharacterOptions (0x01A1)` blob + flush — `SalvageMultiple` is batched (dirties the module; OP7's + diff-and-send calls the explicit `SaveOptions` verb once, after every + declared id has been diffed, never interleaved). +- **No other option bits change.** ACE's stored `CharacterOptions1`/ + `CharacterOptions2` for every UNDECLARED id stay exactly what they + were before the run. +- **No 0x01A1 blob before the client's own `GameActionLoginComplete`.** + If ACE logs a refusal ("SetCharacterOptions received before + FirstEnterWorldDone" or equivalent), that is a real OP7 defect — the + diff-and-send is contracted to run only after both LoginComplete has + been sent AND a real `PlayerDescription` has seeded local state + (`HeadlessCharacterOptionsSeeder`'s own two-precondition latch). + +### Expected silence on reconnect + +4. **Run the SAME config a second time** (a fresh process, or the + process's own reconnect path if it fires) without changing anything + on the ACE side in between. Expect **zero** `0x0005`/`0x01A1` sends + for the three declared ids — the fresh `PlayerDescription` now echoes + back exactly what the first run persisted, so the declared-vs-actual + diff finds nothing (idempotent by construction: a bot that already + has what it wants sends nothing, matching retail's own "re-set to the + current value produces nothing" rule — set-character-options-wire.md + §3.1/§3.5). +5. **Flip one declared value** in the config (e.g. `SalvageMultiple` back + to `false`) and run again. Expect exactly ONE `0x0005` + + `0x01A1` pair for that id only — the other two, still matching, + produce nothing. + +### What to report + +- Any declared id that does NOT persist across a fresh reconnect (ACE + either rejected it silently, or the diff-and-send never actually ran). +- Any UNDECLARED id whose stored value changed — a sign the blob echoed + something it shouldn't have (wire research §5.3's "echo real values, + never zero them" rule, or a stale/incorrect snapshot read). +- Any 0x0005/0x01A1 send observed BEFORE the client's own LoginComplete + action on the wire (a WireMCP capture on loopback `127.0.0.1:9000` + settles this precisely if ACE's own log line is ambiguous). +- Whether `ListenToTradeChat` actually joined the Turbine trade room + server-side (not just the stored bit) — the one declared id in the + sample config with a real behavioral consumer. + +### Explicitly NOT in scope for this gate + +- Every other tier-1/tier-2 option name — the three above exercise both + wire paths (auto-save `0x0005`-only and batched `0x0005`+`0x01A1`); + the remaining 23 names share the same two code paths and are already + covered by the id-ascending completeness assertions in + `CharacterOptionTableTests` (OP1) and the schema tests here (OP7). +- Any presentation-only (tier-3) option — `HeadlessConfigurationLoader` + refuses to load a config that declares one; there is nothing to run. +- The graphical Options panel's own Character tab — OP4. diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index e05cb864..cb157d34 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -48,6 +48,24 @@ internal sealed class HeadlessSessionDescriptor [JsonRequired] public HeadlessCredentialReference Credential { get; init; } = new(); + + /// + /// Campaign OP slice OP7 (2026-08-11), D8: optional declared character- + /// option overrides. Keys MUST be exact CharacterOptionId enum- + /// member spellings drawn from the lane-B tier-1+tier-2 bot-relevant + /// subset (docs/research/2026-08-10-character-options-map.md §5.2/§7.3) + /// — rejects everything else + /// at load, before any name can reach the wire (ACE throws + /// KeyNotFoundException server-side on an unmodelled id — set- + /// character-options-wire.md §5.4.2). Dictionary VALUES bypass the + /// loader's camelCase property-naming policy entirely (only C# property + /// names go through that policy; JSON object keys inside a + /// Dictionary<string, TValue> are read verbatim), so the + /// exact PascalCase enum spelling is what the config file must contain. + /// null (the field entirely absent) and an empty object are both + /// legal no-ops. + /// + public Dictionary? CharacterOptions { get; init; } } internal sealed class HeadlessEndpointDescriptor diff --git a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs index 92b1e978..30243b54 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfigurationLoader.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using AcDream.Core.Net.Messages; namespace AcDream.Headless.Configuration; @@ -7,6 +8,54 @@ internal static class HeadlessConfigurationLoader { private const int CurrentVersion = 1; + /// + /// Campaign OP slice OP7 (2026-08-11), D8: the ONLY characterOptions + /// key names a headless config may declare — the lane-B tier-1 (22, "server- + /// honoured AND plausibly bot-relevant") plus tier-2 (4, "client-side but a + /// bot still wants them") subset from + /// docs/research/2026-08-10-character-options-map.md §5.2, transcribed as + /// the matching / + /// enum-member spellings (research doc §5.2's own prose labels differ from + /// the enum in a few spots — e.g. "IgnoreAllTradeRequests" is + /// , + /// "LetOtherPlayersGiveYouItems" is + /// — the enum spelling wins, per §7.3's "match CharacterOptionTable's enum- + /// member spellings" instruction). Every other real + /// member (the presentation-only tier-3 set, + /// §5.2) is deliberately excluded BY CONSTRUCTION — not merely undeclared. + /// + private static readonly HashSet AllowedCharacterOptions = + [ + // Tier 1 (22). + CharacterOptionId.IgnoreAllegianceRequests, + CharacterOptionId.IgnoreFellowshipRequests, + CharacterOptionId.IgnoreTradeRequests, + CharacterOptionId.AllowGive, + CharacterOptionId.FellowshipShareXP, + CharacterOptionId.AcceptLootPermits, + CharacterOptionId.FellowshipShareLoot, + CharacterOptionId.FellowshipAutoAcceptRequests, + CharacterOptionId.DisplayAllegianceLogonNotifications, + CharacterOptionId.UseChargeAttack, + CharacterOptionId.UseCraftSuccessDialog, + CharacterOptionId.AutoRepeatAttack, + CharacterOptionId.LeadMissileTargets, + CharacterOptionId.UseFastMissiles, + CharacterOptionId.ConfirmVolatileRareUse, + CharacterOptionId.AppearOffline, + CharacterOptionId.ListenToAllegianceChat, + CharacterOptionId.ListenToGeneralChat, + CharacterOptionId.ListenToTradeChat, + CharacterOptionId.ListenToLFGChat, + CharacterOptionId.ListenToRoleplayChat, + CharacterOptionId.ListenToSocietyChat, + // Tier 2 (4). + CharacterOptionId.MainPackPreferred, + CharacterOptionId.ToggleRun, + CharacterOptionId.AutoTarget, + CharacterOptionId.SalvageMultiple, + ]; + private static readonly JsonSerializerOptions Options = new() { AllowTrailingCommas = false, @@ -144,5 +193,36 @@ internal static class HeadlessConfigurationLoader throw new HeadlessConfigurationException( $"Session '{session.Id}' requires a credential reference."); } + + ValidateCharacterOptions(session); + } + + /// + /// D8: an unknown name (misspelled, or a real + /// outside , + /// e.g. a presentation-only tier-3 row) fails load with the offending + /// name in the message — never silently dropped, never sent. A non-bool + /// JSON value fails earlier, during Dictionary<string, bool> + /// deserialization itself (a , consistent with + /// every other type-shape violation this loader lets the deserializer + /// reject directly — is + /// reserved for semantic validation of already-well-typed values). + /// + private static void ValidateCharacterOptions(HeadlessSessionDescriptor session) + { + if (session.CharacterOptions is not { } declared) + return; + + foreach (string name in declared.Keys) + { + if (!Enum.TryParse(name, ignoreCase: false, out CharacterOptionId id) + || !AllowedCharacterOptions.Contains(id)) + { + throw new HeadlessConfigurationException( + $"Session '{session.Id}' characterOptions declares " + + $"'{name}', which is not a bot-declarable character " + + "option name."); + } + } } } diff --git a/src/AcDream.Headless/Hosting/HeadlessCharacterOptionsSeeder.cs b/src/AcDream.Headless/Hosting/HeadlessCharacterOptionsSeeder.cs new file mode 100644 index 00000000..a052ed53 --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessCharacterOptionsSeeder.cs @@ -0,0 +1,137 @@ +using AcDream.Core.Net.Messages; +using AcDream.Runtime; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Headless.Hosting; + +/// +/// Campaign OP slice OP7 (2026-08-11), D8: the declared-vs-actual character- +/// option diff-and-send engine for a headless bot's optional +/// characterOptions config block +/// (docs/plans/2026-08-10-options-panel-campaign.md §4 OP7). +/// +/// +/// Sends through the SAME shared Runtime seam the retail Options panel and +/// every other option-changing entrance uses +/// (IRuntimeCharacterCommands.SetSingleOption / +/// SaveOptions — OP1) so this class owns no wire-format knowledge of +/// its own: an auto-save id's SetSingleOption call sends +/// SetSingleCharacterOption (0x0005) immediately (inside +/// ); a batched id's +/// call only dirties the module, so this class calls SaveOptions +/// itself, exactly once, after every declared id has been diffed — never +/// interleaved (docs/research/2026-08-10-set-character-options-wire.md §3.5's +/// policy table). +/// +/// +/// +/// Why two note-methods. Sending SetCharacterOptions (0x01A1) +/// (via SaveOptions) before ACE's FirstEnterWorldDone gate +/// opens is a silent no-op server-side (wire research §5.2) — that flag +/// flips from the client's own GameActionLoginComplete, which a +/// headless host can send from any of three independent production sites +/// depending on content configuration and destination shape (content-less +/// immediate admission, direct first-entry completion, or portal-space +/// materialization completion — see HeadlessSessionHost.CreateEventRoute +/// and RuntimeLiveEntitySessionController's own two internal send +/// sites). Diffing against live option state before a real +/// PlayerDescription has seeded it would also diff against client- +/// constructor defaults instead of server truth +/// ( — OP1's +/// MUST-FIX M1 latch). Both preconditions can be learned in either order, so +/// this class latches each sticky and re-attempts the diff from whichever +/// hook lands second. +/// +/// +/// +/// No "already sent" dedupe. character-options-map.md §5.3 +/// constraint 4 and set-character-options-wire.md §3.5's own "re-set an +/// option to its current value produces nothing" rule make the diff +/// naturally idempotent — a call that finds every declared id already +/// matching live state (the ordinary reconnect case, or a redundant repeat +/// signal from either hook within one session) sends nothing. A session +/// constructs a fresh instance per connect/reconnect +/// (HeadlessSessionHost.CreateEventRoute), so +/// never needs to un-latch mid-session. +/// +/// +internal sealed class HeadlessCharacterOptionsSeeder +{ + private readonly IReadOnlyList> _declared; + private readonly GameRuntime _runtime; + private readonly IRuntimeCharacterCommands _commands; + private bool _loginCompleteSent; + + internal HeadlessCharacterOptionsSeeder( + IReadOnlyDictionary declared, + GameRuntime runtime, + IRuntimeCharacterCommands commands) + { + ArgumentNullException.ThrowIfNull(declared); + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + _commands = commands ?? throw new ArgumentNullException(nameof(commands)); + // Deterministic id-ascending order: matches CharacterOptionTable's own + // ordering convention and keeps SetSingleOption call order + // reproducible — the contract requires "all option sets first, then + // the single flush", not any particular order among the sets + // themselves, but a stable order still makes captured-wire tests + // deterministic instead of relying on Dictionary's unspecified + // enumeration order. + _declared = [.. declared.OrderBy(static pair => (uint)pair.Key)]; + } + + internal bool HasDeclaredOptions => _declared.Count > 0; + + /// + /// Wired to every production site that can send + /// GameActionLoginComplete for this session — see the type doc. + /// Idempotent to call more than once. + /// + internal void NoteLoginCompleteSent() + { + _loginCompleteSent = true; + TryDiffAndSend(); + } + + /// + /// Wired to LiveCharacterSessionBindings.OnCharacterOptionsChanged + /// — fires after RuntimeCharacterOptionsState.Replace has already + /// committed a fresh PlayerDescription's option words, on both the + /// first connect and every reconnect's fresh seed. + /// + internal void NoteOptionsSeeded() => TryDiffAndSend(); + + private void TryDiffAndSend() + { + if (!_loginCompleteSent || _declared.Count == 0) + return; + + RuntimeCharacterOptionsState options = _runtime.CharacterOwner.Options; + if (!options.HasServerSeed) + return; + + RuntimeGenerationToken generation = _runtime.Generation; + bool needsFlush = false; + foreach ((CharacterOptionId id, bool desired) in _declared) + { + // Unreachable in production: HeadlessConfigurationLoader already + // rejects any declared name CharacterOptionTable cannot resolve. + // Guarded rather than assumed so a future table/schema drift + // fails closed (skips the id) instead of throwing mid-diff. + if (!CharacterOptionTable.TryGet(id, out CharacterOptionTableEntry entry)) + continue; + + uint word = entry.IsOptions1 ? options.Options1 : options.Options2; + bool current = (word & entry.Mask) != 0u; + if (current == desired) + continue; + + _commands.SetSingleOption(generation, (uint)id, desired); + if (!entry.IsAutoSave) + needsFlush = true; + } + + if (needsFlush) + _commands.SaveOptions(generation); + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 31b22a53..d4c53dde 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -112,6 +112,21 @@ internal sealed class HeadlessSessionHost : IDisposable private readonly HeadlessSessionDescriptor _descriptor; private readonly HeadlessCredentialSecret _credential; private readonly HeadlessDiagnosticWriter _diagnostics; + /// + /// Campaign OP slice OP7 (2026-08-11), D8: the parsed + /// characterOptions block — empty when the config omitted it. + /// Parsed once at construction; + /// already rejected every unmodelled or out-of-tier name before this + /// host was ever built, so the parse here cannot fail. + /// + private readonly Dictionary _declaredCharacterOptions; + /// + /// Reassigned on every reconnect exactly like + /// — a fresh instance per call gives the + /// seeder's own login-complete latch a clean per-session start without a + /// separate reset method. + /// + private HeadlessCharacterOptionsSeeder? _optionsSeeder; private readonly TimeSpan _reconnectQuiescence; private readonly TimeProvider _timeProvider; private readonly HeadlessGenerationResetHost _resetHost = new(); @@ -189,6 +204,7 @@ internal sealed class HeadlessSessionHost : IDisposable ?? throw new ArgumentNullException(nameof(credential)); _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _declaredCharacterOptions = ParseDeclaredCharacterOptions(descriptor); _placementSinkOverride = placementSinkOverride; _timeProvider = timeProvider ?? TimeProvider.System; _reconnectQuiescence = reconnectQuiescence @@ -309,6 +325,16 @@ internal sealed class HeadlessSessionHost : IDisposable internal GameRuntime Runtime { get; } internal DirectGameRuntimeCommandAdapter Commands { get; } + /// + /// OP7 test seam (mirrors 's own visibility): the + /// current route's declared-characterOptions diff-and-send engine, + /// or null before the first CreateEventRoute call. Lets a + /// focused test drive one half of the seeder's two-precondition latch + /// directly (e.g. simulate "LoginComplete already sent") without + /// reconstructing the first-entry-drive/portal-completion machinery that + /// production code uses to reach the same state. + /// + internal HeadlessCharacterOptionsSeeder? OptionsSeeder => _optionsSeeder; internal string SessionId => _descriptor.Id; internal string ActiveCharacterName { get; private set; } = string.Empty; @@ -604,6 +630,13 @@ internal sealed class HeadlessSessionHost : IDisposable // its ack-firing accessor must therefore read the CURRENT session // through this field, never one captured at first construction. _currentSession = session; + // OP7: a fresh seeder per route — see the field's own doc comment + // for why this (rather than a reset method) is the right per- + // reconnect lifetime. + _optionsSeeder = new HeadlessCharacterOptionsSeeder( + _declaredCharacterOptions, + Runtime, + Commands.Character); // R9 review note (2026-08-03): a content-less host (_contentLease is // null — a validated-legal headless configuration, see // RuntimeLiveEntitySessionController.OnSpawned's own R3 comment) @@ -694,7 +727,10 @@ internal sealed class HeadlessSessionHost : IDisposable message, Runtime.Generation.Value), worldProjection, - _acceptedPositionDrive); + _acceptedPositionDrive, + // OP7: the first two of three production LoginComplete send + // sites — see RuntimeLiveEntitySessionController's own doc. + onLoginCompleteSent: () => _optionsSeeder?.NoteLoginCompleteSent()); _entities = entities; var route = new LiveSessionEventRouter( session, @@ -734,7 +770,13 @@ internal sealed class HeadlessSessionHost : IDisposable // (HeadlessSessionWorldProjection.CreateController), same as // the pre-P1 OnSkillsUpdated: null pattern — no live // controller to reactively re-apply to mid-session here. - OnMovementStatsUpdated: null), + OnMovementStatsUpdated: null, + // OP7: fires after RuntimeCharacterOptionsState.Replace has + // already committed a fresh PlayerDescription's option words + // — the seeder's other precondition alongside + // NoteLoginCompleteSent (see its own type doc). + OnCharacterOptionsChanged: (_, _) => + _optionsSeeder?.NoteOptionsSeeded()), new LiveSocialSessionBindings( Runtime.CommunicationOwner.Chat, Runtime.CommunicationOwner.TurbineChat, @@ -747,12 +789,42 @@ internal sealed class HeadlessSessionHost : IDisposable _placementSinkOverride ?? new HeadlessRuntimePlacementProjectionSink(Runtime), _firstEntryDrive, - _ => session.SendGameAction(GameActionLoginComplete.Build()), + _ => + { + session.SendGameAction(GameActionLoginComplete.Build()); + // OP7: the THIRD production LoginComplete send site (direct, + // non-portal first-entry completion) — see + // HeadlessCharacterOptionsSeeder's type doc and + // RuntimeLiveEntitySessionController's onLoginCompleteSent + // doc for the other two. + _optionsSeeder?.NoteLoginCompleteSent(); + }, _acceptedPositionDrive); _eventRoute = eventRoute; return eventRoute; } + /// + /// OP7, D8: parses + /// into the typed id space the seeder and + /// share. already validated + /// every key against the exact bot-declarable name set before this host + /// was constructed, so Enum.Parse here cannot fail. + /// + private static Dictionary ParseDeclaredCharacterOptions( + HeadlessSessionDescriptor descriptor) + { + var declared = new Dictionary(); + if (descriptor.CharacterOptions is not { } options) + return declared; + foreach (KeyValuePair pair in options) + { + declared[Enum.Parse(pair.Key, ignoreCase: false)] = + pair.Value; + } + return declared; + } + private static LiveSessionCharacterSelector MapCharacterSelector( HeadlessCharacterSelector selector) => new( diff --git a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs index 6bba4622..30806477 100644 --- a/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs +++ b/src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs @@ -74,6 +74,22 @@ public sealed class RuntimeLiveEntitySessionController /// HeadlessSessionWorldProjection.BlipLocalPlayer). /// private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive; + /// + /// Campaign OP slice OP7 (2026-08-11): passive observation hook — fires + /// AFTER either of this controller's own two internal + /// GameActionLoginComplete send sites ('s + /// content-less immediate-admission path; 's + /// portal-space materialization completion). Never changes when or + /// whether LoginComplete is sent — purely additive, so a headless host + /// can learn "ACE's FirstEnterWorldDone gate is now open" (set- + /// character-options-wire.md §5.2) without duplicating this controller's + /// own dual-path completion logic. The THIRD production send site — direct + /// (non-portal) first-entry completion via + /// RuntimeFirstEntryDriveController's localPlayerCompleted + /// callback — lives one level up in HeadlessSessionHost, which + /// wires the same observer there directly. + /// + private readonly Action? _onLoginCompleteSent; private bool _initialLoginCompleteSent; // A6 (architecture review): D5's ResolveAndCommitChildAttachment ran on // every accepted spawn and every ParentEvent, allocating three @@ -88,13 +104,15 @@ public sealed class RuntimeLiveEntitySessionController WorldSession session, Action? log = null, IRuntimeDirectWorldProjection? worldProjection = null, - RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null) + RuntimeAcceptedPositionDriveController? acceptedPositionDrive = null, + Action? onLoginCompleteSent = null) { _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _session = session ?? throw new ArgumentNullException(nameof(session)); _log = log ?? (_ => { }); _worldProjection = worldProjection; _acceptedPositionDrive = acceptedPositionDrive; + _onLoginCompleteSent = onLoginCompleteSent; _isChildGuidKnown = guid => Entities.Entities.TryGetSnapshot(guid, out _); _resolveParentInstance = guid => Entities.Entities.TryGetSnapshot(guid, out WorldSession.EntitySpawn spawn) @@ -180,6 +198,7 @@ public sealed class RuntimeLiveEntitySessionController // truthful terminal admission edge. _initialLoginCompleteSent = true; _session.SendGameAction(GameActionLoginComplete.Build()); + _onLoginCompleteSent?.Invoke(); } } } @@ -856,6 +875,7 @@ public sealed class RuntimeLiveEntitySessionController RuntimeWorldHostAcknowledgementStage.TerminalProjected); _session.SendGameAction(GameActionLoginComplete.Build()); + _onLoginCompleteSent?.Invoke(); transit.EndTeleport(); _log( $"headless: portal complete generation={generation} " diff --git a/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederTests.cs b/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederTests.cs new file mode 100644 index 00000000..c0a1f4f5 --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederTests.cs @@ -0,0 +1,289 @@ +using AcDream.Core.Net.Messages; +using AcDream.Headless.Hosting; +using AcDream.Runtime; +using AcDream.Runtime.Gameplay; + +namespace AcDream.Headless.Tests; + +/// +/// Campaign OP slice OP7 (2026-08-11), D8: the declared-vs-actual diff engine +/// () unit-tested against a fake +/// so these tests never need a live +/// session, a wire, or a physics/collision fixture — only a bare +/// for its CharacterOwner.Options state and +/// Generation token. +/// stands in for "a PlayerDescription just landed" exactly like +/// LiveSessionEventRouter's own onCharacterOptions wiring does. +/// +public sealed class HeadlessCharacterOptionsSeederTests +{ + // AutoRepeatAttack (0x00, Options1 0x00000002) is auto-save — sends + // SetSingleOption (0x0005) immediately, per CharacterOptionTable. + // IgnoreTradeRequests (0x03, Options1 0x00020000) is batched — dirties + // the module; only SaveOptions flushes it. Both are exercised by name + // throughout this file's tests. + private const uint AutoSaveMask = 0x00000002u; + + [Fact] + public void DeclaredEqualsActualSendsNothing() + { + using GameRuntime runtime = NewRuntime(); + runtime.CharacterOwner.Options.Replace(0u, 0u); + var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); + var seeder = new HeadlessCharacterOptionsSeeder( + new Dictionary + { + [CharacterOptionId.AutoRepeatAttack] = false, + }, + runtime, + commands); + + seeder.NoteLoginCompleteSent(); + seeder.NoteOptionsSeeded(); + + Assert.Empty(commands.CallOrder); + } + + [Fact] + public void AutoSaveDifferenceSendsExactlyOneSetSingleOption() + { + using GameRuntime runtime = NewRuntime(); + runtime.CharacterOwner.Options.Replace(0u, 0u); + var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); + var seeder = new HeadlessCharacterOptionsSeeder( + new Dictionary + { + [CharacterOptionId.AutoRepeatAttack] = true, + }, + runtime, + commands); + + seeder.NoteLoginCompleteSent(); + seeder.NoteOptionsSeeded(); + + var call = Assert.Single(commands.SingleOptionCalls); + Assert.Equal((uint)CharacterOptionId.AutoRepeatAttack, call.OptionId); + Assert.True(call.Value); + Assert.Equal(0, commands.SaveOptionsCallCount); + Assert.Equal(["set:0"], commands.CallOrder); + } + + [Fact] + public void BatchedDifferenceSendsSetSingleOptionThenExactlyOneSaveOptionsAtTheEnd() + { + using GameRuntime runtime = NewRuntime(); + runtime.CharacterOwner.Options.Replace(0u, 0u); + var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); + var seeder = new HeadlessCharacterOptionsSeeder( + new Dictionary + { + [CharacterOptionId.IgnoreTradeRequests] = true, + }, + runtime, + commands); + + seeder.NoteLoginCompleteSent(); + seeder.NoteOptionsSeeded(); + + var call = Assert.Single(commands.SingleOptionCalls); + Assert.Equal((uint)CharacterOptionId.IgnoreTradeRequests, call.OptionId); + Assert.True(call.Value); + Assert.Equal(1, commands.SaveOptionsCallCount); + Assert.Equal(["set:3", "save"], commands.CallOrder); + } + + [Fact] + public void MixedDeclarationOrdersAllSetsBeforeTheSingleFlush() + { + using GameRuntime runtime = NewRuntime(); + runtime.CharacterOwner.Options.Replace(0u, 0u); + var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); + var seeder = new HeadlessCharacterOptionsSeeder( + new Dictionary + { + // Declared out of id order on purpose — the seeder sorts + // id-ascending internally, so the batched id (0x03) is + // expected AFTER the auto-save id (0x00) regardless of + // declaration order, with the flush always last. + [CharacterOptionId.IgnoreTradeRequests] = true, + [CharacterOptionId.AutoRepeatAttack] = true, + }, + runtime, + commands); + + seeder.NoteLoginCompleteSent(); + seeder.NoteOptionsSeeded(); + + Assert.Equal(2, commands.SingleOptionCalls.Count); + Assert.Equal(1, commands.SaveOptionsCallCount); + Assert.Equal(["set:0", "set:3", "save"], commands.CallOrder); + } + + [Fact] + public void NeitherPreconditionAloneSendsAnything() + { + using GameRuntime runtime = NewRuntime(); + var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); + var seeder = new HeadlessCharacterOptionsSeeder( + new Dictionary + { + [CharacterOptionId.AutoRepeatAttack] = true, + }, + runtime, + commands); + + // Login-complete alone: HasServerSeed is still false (no Replace + // ever ran), so the diff cannot trust live state yet. + seeder.NoteLoginCompleteSent(); + Assert.Empty(commands.CallOrder); + + // Now the seed lands — both preconditions are satisfied and the + // deferred diff runs from THIS call. + runtime.CharacterOwner.Options.Replace(0u, 0u); + seeder.NoteOptionsSeeded(); + + Assert.Single(commands.SingleOptionCalls); + } + + [Fact] + public void OptionsSeededBeforeLoginCompleteDefersUntilLoginCompleteArrives() + { + using GameRuntime runtime = NewRuntime(); + runtime.CharacterOwner.Options.Replace(0u, 0u); + var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); + var seeder = new HeadlessCharacterOptionsSeeder( + new Dictionary + { + [CharacterOptionId.AutoRepeatAttack] = true, + }, + runtime, + commands); + + seeder.NoteOptionsSeeded(); + Assert.Empty(commands.CallOrder); + + seeder.NoteLoginCompleteSent(); + Assert.Single(commands.SingleOptionCalls); + } + + [Fact] + public void EmptyDeclarationNeverSendsRegardlessOfBothSignals() + { + using GameRuntime runtime = NewRuntime(); + runtime.CharacterOwner.Options.Replace(0xFFFFFFFFu, 0xFFFFFFFFu); + var commands = new FakeCharacterCommands(runtime.CharacterOwner.Options); + var seeder = new HeadlessCharacterOptionsSeeder( + new Dictionary(), + runtime, + commands); + + Assert.False(seeder.HasDeclaredOptions); + seeder.NoteLoginCompleteSent(); + seeder.NoteOptionsSeeded(); + + Assert.Empty(commands.CallOrder); + } + + /// + /// D8 idempotence — the acceptance bar is "by construction", not a + /// special-cased dedupe: a fresh, second seeder instance (mirroring the + /// fresh instance HeadlessSessionHost.CreateEventRoute constructs + /// per reconnect) diffs against the SAME declared dictionary but against + /// live state that already reflects what the first seeder sent — the + /// second diff simply finds nothing to do. + /// + [Fact] + public void ReconnectWithSameDeclarationsAgainstServerThatNowAgreesSendsNothing() + { + using GameRuntime runtime = NewRuntime(); + var declared = new Dictionary + { + [CharacterOptionId.AutoRepeatAttack] = true, + }; + + // First connect: server has the option OFF; the bot's declared + // value differs, so it sends once. + runtime.CharacterOwner.Options.Replace(0u, 0u); + var firstCommands = new FakeCharacterCommands(runtime.CharacterOwner.Options); + var firstSeeder = new HeadlessCharacterOptionsSeeder( + declared, runtime, firstCommands); + firstSeeder.NoteLoginCompleteSent(); + firstSeeder.NoteOptionsSeeded(); + Assert.Single(firstCommands.SingleOptionCalls); + + // Reconnect: a fresh seeder instance (per CreateEventRoute's own + // per-route construction), and the server's fresh PlayerDescription + // now echoes what the bot itself set last time. + runtime.CharacterOwner.Options.ResetSession(); + runtime.CharacterOwner.Options.Replace(AutoSaveMask, 0u); + var secondCommands = new FakeCharacterCommands(runtime.CharacterOwner.Options); + var secondSeeder = new HeadlessCharacterOptionsSeeder( + declared, runtime, secondCommands); + secondSeeder.NoteLoginCompleteSent(); + secondSeeder.NoteOptionsSeeded(); + + Assert.Empty(secondCommands.CallOrder); + } + + private static GameRuntime NewRuntime() + { + var gameplay = new HeadlessGameplayOperations(); + var runtime = new GameRuntime(new GameRuntimeDependencies( + gameplay, + gameplay, + gameplay, + gameplay)); + gameplay.Bind(runtime, catalog: null, () => "account"); + return runtime; + } + + /// + /// Writes the bit into as part of + /// , mirroring the REAL + /// DirectGameRuntimeCommandAdapter.SetSingleOption → + /// RuntimeCharacterOptionsState.TrySetOption contract: the local + /// copy updates synchronously, in the same call, before the method + /// returns. Without this, a fake that only records the call (never + /// mutating live state) would make a SECOND diff pass — e.g. a + /// legitimate redundant NoteLoginCompleteSent/NoteOptionsSeeded + /// pairing — re-find the same already-handled mismatch and re-send it, + /// which is a fidelity gap in the fake, not a real defect: production's + /// idempotence depends on exactly this synchronous local write. + /// + private sealed class FakeCharacterCommands( + RuntimeCharacterOptionsState options) : IRuntimeCharacterCommands + { + internal List<(uint OptionId, bool Value)> SingleOptionCalls { get; } = []; + internal int SaveOptionsCallCount { get; private set; } + internal List CallOrder { get; } = []; + + public RuntimeCommandResult Advance( + RuntimeGenerationToken expectedGeneration, + in RuntimeAdvancementCommand command) => + throw new NotSupportedException( + "HeadlessCharacterOptionsSeeder never calls Advance."); + + public RuntimeCommandResult SetSingleOption( + RuntimeGenerationToken expectedGeneration, + uint optionId, + bool value) + { + SingleOptionCalls.Add((optionId, value)); + CallOrder.Add($"set:{optionId}"); + options.SetOptionBit(optionId, value); + return new RuntimeCommandResult( + RuntimeCommandStatus.Accepted, + expectedGeneration); + } + + public RuntimeCommandResult SaveOptions( + RuntimeGenerationToken expectedGeneration) + { + SaveOptionsCallCount++; + CallOrder.Add("save"); + return new RuntimeCommandResult( + RuntimeCommandStatus.Accepted, + expectedGeneration); + } + } +} diff --git a/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederWiringTests.cs b/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederWiringTests.cs new file mode 100644 index 00000000..7987f069 --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessCharacterOptionsSeederWiringTests.cs @@ -0,0 +1,357 @@ +using System.Buffers.Binary; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Headless.Configuration; +using AcDream.Headless.Credentials; +using AcDream.Headless.Diagnostics; +using AcDream.Headless.Hosting; +using AcDream.Headless.Platform; +using AcDream.Runtime; +using AcDream.Runtime.Session; + +namespace AcDream.Headless.Tests; + +/// +/// Campaign OP slice OP7 (2026-08-11), D8: end-to-end wiring proof that a +/// declared characterOptions block reaches the real wire through +/// 's actual production seams — not just +/// the isolated diff engine ( +/// covers that with a fake command surface). A real +/// PlayerDescription game event is dispatched through +/// WorldSession.GameEvents exactly like a live server connection +/// would deliver it (the same mechanism +/// LiveSessionEventRouterTests.PlayerDescription_ReplacesOptionsBeforeInvokingOnCharacterOptionsChanged +/// uses) — this proves the REAL +/// LiveCharacterSessionBindings.OnCharacterOptionsChanged wiring +/// added in HeadlessSessionHost.CreateEventRoute. The "LoginComplete +/// already sent" half is driven through the +/// test seam directly — the three production sites that actually send +/// GameActionLoginComplete (content-less immediate admission, direct +/// first-entry completion, portal-space materialization completion) are +/// each simple one-line delegate wiring already covered by their OWN +/// existing tests (RuntimeLiveEntitySessionControllerTests); this +/// class's job is to prove what happens once that signal lands, not to +/// re-derive it. +/// +public sealed class HeadlessCharacterOptionsSeederWiringTests +{ + [Fact] + public void DeclaredOptionSendsOnceBothLoginCompleteAndTheRealPlayerDescriptionEventLand() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + Assert.NotNull(host.OptionsSeeder); + + var sent = new List(); + WorldSession session = operations.Sessions[^1]; + session.GameActionCapture = body => sent.Add(body); + + // LoginComplete lands first (a legitimate production ordering — + // see the type doc); no PlayerDescription has arrived yet, so + // HasServerSeed is still false and nothing can send. + host.OptionsSeeder!.NoteLoginCompleteSent(); + Assert.Empty(sent); + + // The real PlayerDescription game event, dispatched through the + // ACTUAL session event route production installed — proves + // OnCharacterOptionsChanged is really wired, not just callable. + // IgnoreAllegianceRequests (0x01, Options1 0x00000004) starts OFF + // on the server; the declared value is ON. + session.GameEvents.Dispatch( + GameEventEnvelope.TryParse( + WrapPlayerDescriptionEnvelope(options1: 0u, options2: 0u))! + .Value); + + byte[] action = Assert.Single(sent); + Assert.Equal( + SocialActions.SetSingleCharacterOptionOpcode, + ActionOpcode(action)); + Assert.Equal( + (uint)CharacterOptionId.IgnoreAllegianceRequests, + BinaryPrimitives.ReadUInt32LittleEndian(action.AsSpan(12, 4))); + Assert.Equal(1u, BinaryPrimitives.ReadUInt32LittleEndian(action.AsSpan(16, 4))); + Assert.True( + host.Runtime.CharacterOwner.Options.HasServerSeed); + } + + [Fact] + public void ReconnectAgainstAServerThatNowAgreesSendsNothing() + { + var operations = new FixtureSessionOperations(); + using var credential = new HeadlessCredentialSecret( + "fixture", + "password"); + using var host = new HeadlessSessionHost( + Descriptor(), + credential, + new HeadlessDiagnosticWriter(TextWriter.Null), + operations); + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Start().Status); + WorldSession firstSession = operations.Sessions[^1]; + var firstSent = new List(); + firstSession.GameActionCapture = body => firstSent.Add(body); + host.OptionsSeeder!.NoteLoginCompleteSent(); + firstSession.GameEvents.Dispatch( + GameEventEnvelope.TryParse( + WrapPlayerDescriptionEnvelope(options1: 0u, options2: 0u))! + .Value); + Assert.Single(firstSent); + + Assert.Equal( + RuntimeSessionStartStatus.Connected, + host.Reconnect().Status); + Assert.NotSame(firstSession, operations.Sessions[^1]); + WorldSession secondSession = operations.Sessions[^1]; + var secondSent = new List(); + secondSession.GameActionCapture = body => secondSent.Add(body); + + host.OptionsSeeder!.NoteLoginCompleteSent(); + // The fresh PlayerDescription now echoes what the bot itself set + // last connection — IgnoreAllegianceRequests's mask, ON. + secondSession.GameEvents.Dispatch( + GameEventEnvelope.TryParse( + WrapPlayerDescriptionEnvelope( + options1: 0x00000004u, + options2: 0u))! + .Value); + + Assert.Empty(secondSent); + } + + /// + /// #368: the diff-and-send must run on Runtime's one dedicated update + /// thread, exactly like every other gameplay-owner mutation — never a + /// new async continuation. Mirrors + /// HeadlessProcessSchedulerTests.ProcessHostRunsStartAndEveryTickOnOneDedicatedUpdateThread's + /// own proof shape (occupy the calling thread across real ticks so a + /// thread-pool migration would be observable), but additionally drives + /// a real PlayerDescription dispatch and a login-complete signal FROM + /// INSIDE ILiveSessionOperations.Tick — the same call frame + /// HeadlessSessionHost.Tick's own Runtime.Session.Tick() + /// reaches — so the captured send's thread id is measured at the exact + /// point production code would run it, not simulated from the test + /// thread. + /// + [Fact] + public async Task DiffAndSendRunsOnTheSameDedicatedUpdateThreadAsEveryTick() + { + var configuration = new HeadlessConfiguration + { + Version = 1, + Sessions = [Descriptor()], + }; + var operations = new SeedTriggeringSessionOperations(); + using var diagnostics = new StringWriter(); + using var host = new HeadlessProcessHost( + configuration, + HeadlessPathSet.Resolve(new HeadlessPathOverrides()), + new System.IO.StringReader("fixture-password" + Environment.NewLine), + diagnostics, + operations); + operations.Host = host.Sessions[0]; + using var cancellation = new CancellationTokenSource(); + + int callerThread = Environment.CurrentManagedThreadId; + Task run = host.RunAsync(cancellation.Token); + var stopwatch = Stopwatch.StartNew(); + while (operations.SentActions.IsEmpty + && stopwatch.Elapsed < TimeSpan.FromSeconds(10)) + { + Thread.Sleep(1); + } + cancellation.Cancel(); + HeadlessExitCode result = await run; + + Assert.Equal(HeadlessExitCode.Success, result); + (byte[] Body, int ThreadId) sent = Assert.Single(operations.SentActions); + Assert.Equal( + SocialActions.SetSingleCharacterOptionOpcode, + ActionOpcode(sent.Body)); + Assert.NotEqual(0, sent.ThreadId); + Assert.NotEqual(callerThread, sent.ThreadId); + Assert.Equal(operations.ConnectThreadId, sent.ThreadId); + } + + private static uint ActionOpcode(byte[] body) => + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, sizeof(uint))); + + // Minimal PlayerDescription (0x0013) body carrying only the + // CharacterOptions1/2 trailer fields — copied from + // LiveSessionEventRouterTests.WrapPlayerDescriptionEnvelope (mirrors + // GameEventWiringTests.WireAll_PlayerDescription_PublishesCharacterOptions's + // fixture layout). + private static byte[] WrapPlayerDescriptionEnvelope( + uint options1, + uint options2) + { + var stream = new MemoryStream(); + using (var writer = new BinaryWriter( + stream, System.Text.Encoding.UTF8, leaveOpen: true)) + { + writer.Write(0u); // property flags + writer.Write(0x52u); // player weenie type + writer.Write(0u); // vector flags + writer.Write(0u); // has health + writer.Write(0x40u); // option flags: CharacterOptions2 + writer.Write(options1); + writer.Write(0u); // legacy hotbar count + writer.Write(0u); // spellbook filters + writer.Write(options2); + writer.Write(0u); // inventory count + writer.Write(0u); // equipped count + } + + byte[] payload = stream.ToArray(); + byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length]; + BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u); + BinaryPrimitives.WriteUInt32LittleEndian( + body.AsSpan(12), (uint)GameEventType.PlayerDescription); + Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length); + return body; + } + + private static HeadlessSessionDescriptor Descriptor() => new() + { + Id = "bot", + Endpoint = new HeadlessEndpointDescriptor + { + Host = "127.0.0.1", + Port = 9000, + }, + Account = "account", + Character = new HeadlessCharacterSelector + { + Name = "headless", + }, + Policy = new HeadlessBotPolicyDescriptor + { + Id = "idle", + }, + Credential = new HeadlessCredentialReference + { + Provider = HeadlessCredentialProviderKind.StandardInput, + Reference = "fixture-password", + }, + CharacterOptions = new Dictionary + { + ["IgnoreAllegianceRequests"] = true, + }, + }; + + private sealed class FixtureSessionOperations : ILiveSessionOperations + { + public List Sessions { get; } = []; + + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) + { + var session = new WorldSession(endpoint); + Sessions.Add(session); + return session; + } + + public void Connect(WorldSession session, string user, string password) + { + } + + public CharacterList.Parsed GetCharacters(WorldSession session) => + new( + 0u, + [new CharacterList.Character(0x50000001u, "Headless", 0u)], + [], + 11, + "account", + true, + true); + + public void EnterWorld(WorldSession session, int activeCharacterIndex) + { + } + + public void Tick(WorldSession session) + { + } + + public void DisposeSession(WorldSession session) => session.Dispose(); + } + + /// + /// Thread-affinity fixture: on its first call it + /// drives login-complete plus a real PlayerDescription dispatch from + /// INSIDE the call — the same frame production's + /// Runtime.Session.Tick() reaches ILiveSessionOperations.Tick + /// from. is wired AFTER construction (the + /// does not exist yet when this fixture + /// is passed into 's constructor). + /// + private sealed class SeedTriggeringSessionOperations : ILiveSessionOperations + { + private int _connectThreadId; + private int _dispatched; + + internal HeadlessSessionHost? Host { get; set; } + internal int ConnectThreadId => Volatile.Read(ref _connectThreadId); + internal ConcurrentQueue<(byte[] Body, int ThreadId)> SentActions { get; } = new(); + + public IPEndPoint ResolveEndpoint(string host, int port) => + new(IPAddress.Loopback, port); + + public WorldSession CreateSession(IPEndPoint endpoint) + { + var session = new WorldSession(endpoint); + session.GameActionCapture = body => SentActions.Enqueue( + (body, Environment.CurrentManagedThreadId)); + return session; + } + + public void Connect(WorldSession session, string user, string password) => + Volatile.Write(ref _connectThreadId, Environment.CurrentManagedThreadId); + + public CharacterList.Parsed GetCharacters(WorldSession session) => + new( + 0u, + [new CharacterList.Character(0x50000001u, "Headless", 0u)], + [], + 11, + "account", + true, + true); + + public void EnterWorld(WorldSession session, int activeCharacterIndex) + { + } + + public void Tick(WorldSession session) + { + if (Interlocked.Exchange(ref _dispatched, 1) != 0) + return; + Host?.OptionsSeeder?.NoteLoginCompleteSent(); + session.GameEvents.Dispatch( + GameEventEnvelope.TryParse( + WrapPlayerDescriptionEnvelope(options1: 0u, options2: 0u))! + .Value); + } + + public void DisposeSession(WorldSession session) => session.Dispose(); + } +} diff --git a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs new file mode 100644 index 00000000..774d19df --- /dev/null +++ b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs @@ -0,0 +1,177 @@ +using System.Text.Json; +using AcDream.Headless.Configuration; + +namespace AcDream.Headless.Tests; + +/// +/// Campaign OP slice OP7 (2026-08-11), D8: schema tests for the optional +/// per-session characterOptions block — +/// docs/plans/2026-08-10-options-panel-campaign.md §4 OP7. Exercises +/// directly (rather than +/// through HeadlessEntryPoint's CLI wrapper) so assertions can pin the +/// exact exception type/message for the semantic "unknown name" rejection, +/// matching the loader's own established split: type-shape violations +/// (missing required field, wrong JSON value kind) fail during +/// deserialization itself with a raw ; semantic +/// violations of an already-well-typed value fail with +/// (see +/// 's own doc comment on +/// ValidateCharacterOptions). +/// +public sealed class HeadlessConfigurationLoaderTests +{ + [Fact] + public void ValidCharacterOptionsBlockParsesExactDeclaredNames() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + ConfigurationWith(Session( + "bot", + "BOT_PASSWORD", + """ + "characterOptions":{ + "IgnoreAllegianceRequests":true, + "ListenToTradeChat":false, + "AutoTarget":true + } + """))); + + HeadlessConfiguration configuration = + HeadlessConfigurationLoader.Load(file.Path); + + Dictionary? declared = + Assert.Single(configuration.Sessions)!.CharacterOptions; + Assert.NotNull(declared); + Assert.Equal(3, declared!.Count); + Assert.True(declared["IgnoreAllegianceRequests"]); + Assert.False(declared["ListenToTradeChat"]); + Assert.True(declared["AutoTarget"]); + } + + [Fact] + public void UnknownOptionNameFailsLoadNamingTheOffendingKey() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + ConfigurationWith(Session( + "bot", + "BOT_PASSWORD", + "\"characterOptions\":{\"NotARealOption\":true}"))); + + HeadlessConfigurationException exception = Assert.Throws< + HeadlessConfigurationException>( + () => HeadlessConfigurationLoader.Load(file.Path)); + + Assert.Contains( + "NotARealOption", + exception.Message, + StringComparison.Ordinal); + } + + [Fact] + public void PresentationOnlyTierThreeOptionNameFailsLoad() + { + // ShowHelm is a REAL CharacterOptionId member (0x2F) but is + // deliberately outside the tier-1+2 bot-declarable subset (research + // doc §5.2 tier 3) — excluded by construction, not merely absent + // from an allow-list that forgot it. + using TemporaryConfiguration file = TemporaryConfiguration.Create( + ConfigurationWith(Session( + "bot", + "BOT_PASSWORD", + "\"characterOptions\":{\"ShowHelm\":true}"))); + + HeadlessConfigurationException exception = Assert.Throws< + HeadlessConfigurationException>( + () => HeadlessConfigurationLoader.Load(file.Path)); + + Assert.Contains( + "ShowHelm", + exception.Message, + StringComparison.Ordinal); + } + + [Fact] + public void NonBoolValueFailsLoad() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + ConfigurationWith(Session( + "bot", + "BOT_PASSWORD", + "\"characterOptions\":{\"IgnoreAllegianceRequests\":\"yes\"}"))); + + Assert.ThrowsAny( + () => HeadlessConfigurationLoader.Load(file.Path)); + } + + [Fact] + public void AbsentCharacterOptionsBlockIsANoOp() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + ConfigurationWith(Session("bot", "BOT_PASSWORD"))); + + HeadlessConfiguration configuration = + HeadlessConfigurationLoader.Load(file.Path); + + Assert.Null(Assert.Single(configuration.Sessions)!.CharacterOptions); + } + + [Fact] + public void EmptyCharacterOptionsBlockIsANoOp() + { + using TemporaryConfiguration file = TemporaryConfiguration.Create( + ConfigurationWith(Session( + "bot", + "BOT_PASSWORD", + "\"characterOptions\":{}"))); + + HeadlessConfiguration configuration = + HeadlessConfigurationLoader.Load(file.Path); + + Dictionary? declared = + Assert.Single(configuration.Sessions)!.CharacterOptions; + Assert.NotNull(declared); + Assert.Empty(declared!); + } + + private static string ConfigurationWith(params string[] sessions) => + $$"""{"version":1,"sessions":[{{string.Join(",", sessions)}}]}"""; + + private static string Session( + string id, + string credentialReference, + string? extraTopLevelField = null) + { + string suffix = extraTopLevelField is null + ? string.Empty + : $",{extraTopLevelField}"; + return $"{{\"id\":\"{id}\",\"endpoint\":{{\"host\":\"127.0.0.1\",\"port\":9000}}," + + "\"account\":\"account\",\"character\":{\"index\":0}," + + "\"policy\":{\"id\":\"idle\"},\"credential\":" + + $"{{\"provider\":\"environment\",\"reference\":\"{credentialReference}\"}}" + + suffix + + "}"; + } + + private sealed class TemporaryConfiguration : IDisposable + { + private TemporaryConfiguration(string path) + { + Path = path; + } + + internal string Path { get; } + + internal static TemporaryConfiguration Create(string json) + { + string path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + $"acdream-headless-op7-{Guid.NewGuid():N}.json"); + File.WriteAllText(path, json); + return new TemporaryConfiguration(path); + } + + public void Dispose() + { + File.Delete(Path); + } + } +}