feat(headless): Campaign OP slice OP7 — declared characterOptions with seed-diff sends

Adds an optional, strict `characterOptions` block to the headless bot config
(D8): keys are exactly the lane-B tier-1 (22) + tier-2 (4) bot-declarable
CharacterOptionId enum-member spellings; an unknown/out-of-tier name fails
config load naming the offending key, before it can ever reach the wire.

HeadlessCharacterOptionsSeeder diffs declared-vs-actual once both of ACE's
real preconditions are known true — GameActionLoginComplete sent (the
FirstEnterWorldDone gate SetCharacterOptions 0x01A1 needs) and a real
PlayerDescription has seeded RuntimeCharacterOptionsState
(HasServerSeed) — learned from whichever of two hooks lands second. Every
differing id routes through OP1's shared IRuntimeCharacterCommands seam:
auto-save ids send SetSingleOption (0x0005) immediately; batched ids also
call SetSingleOption (which only dirties the module) followed by exactly
one SaveOptions flush after the whole declared set has been walked.
Idempotent on reconnect by construction — no dedupe latch, the diff simply
finds nothing once the server agrees.

RuntimeLiveEntitySessionController gains a passive onLoginCompleteSent
observation hook (additive only, never changes when/whether it sends) so
the headless host can learn ACE's gate opened from any of its own two
internal send sites; the third site (direct first-entry completion) is
already owned by HeadlessSessionHost itself. All wiring is synchronous
delegate calls on Runtime's one dedicated update thread — no new
async/Task continuation, honoring #368.

Tests: schema (valid parse, unknown/tier-3 name rejected naming the key,
non-bool rejected, empty/absent no-op), the diff engine against a fake
IRuntimeCharacterCommands (nothing-to-send, auto-save-only, batched-with-
flush, mixed ordering, reconnect idempotence), and two wiring integration
tests — one dispatching a real PlayerDescription game event end-to-end to
a captured wire action, one proving the send lands on the same dedicated
thread every Tick runs on. Full solution suite: 12,935 passed / 4 skipped
/ 0 failed (+17 over baseline 12,918/4/0).

No register row: the characterOptions bot-config surface is acdream-
native tooling over retail's own wire mechanisms (both already ported by
OP1), not a retail UI port with a divergence to record.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 02:45:08 +02:00
parent efe80d5a0d
commit 09cb548a32
9 changed files with 1278 additions and 5 deletions

View file

@ -48,6 +48,24 @@ internal sealed class HeadlessSessionDescriptor
[JsonRequired]
public HeadlessCredentialReference Credential { get; init; } = new();
/// <summary>
/// Campaign OP slice OP7 (2026-08-11), D8: optional declared character-
/// option overrides. Keys MUST be exact <c>CharacterOptionId</c> 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)
/// — <see cref="HeadlessConfigurationLoader"/> rejects everything else
/// at load, before any name can reach the wire (ACE throws
/// <c>KeyNotFoundException</c> 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
/// <c>Dictionary&lt;string, TValue&gt;</c> are read verbatim), so the
/// exact PascalCase enum spelling is what the config file must contain.
/// <c>null</c> (the field entirely absent) and an empty object are both
/// legal no-ops.
/// </summary>
public Dictionary<string, bool>? CharacterOptions { get; init; }
}
internal sealed class HeadlessEndpointDescriptor

View file

@ -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;
/// <summary>
/// Campaign OP slice OP7 (2026-08-11), D8: the ONLY <c>characterOptions</c>
/// 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 <see cref="CharacterOptionTable"/>/<see cref="CharacterOptionId"/>
/// enum-member spellings (research doc §5.2's own prose labels differ from
/// the enum in a few spots — e.g. "IgnoreAllTradeRequests" is
/// <see cref="CharacterOptionId.IgnoreTradeRequests"/>,
/// "LetOtherPlayersGiveYouItems" is <see cref="CharacterOptionId.AllowGive"/>
/// — the enum spelling wins, per §7.3's "match CharacterOptionTable's enum-
/// member spellings" instruction). Every other real
/// <see cref="CharacterOptionId"/> member (the presentation-only tier-3 set,
/// §5.2) is deliberately excluded BY CONSTRUCTION — not merely undeclared.
/// </summary>
private static readonly HashSet<CharacterOptionId> 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);
}
/// <summary>
/// D8: an unknown name (misspelled, or a real
/// <see cref="CharacterOptionId"/> outside <see cref="AllowedCharacterOptions"/>,
/// 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 <c>Dictionary&lt;string, bool&gt;</c>
/// deserialization itself (a <see cref="JsonException"/>, consistent with
/// every other type-shape violation this loader lets the deserializer
/// reject directly — <see cref="HeadlessConfigurationException"/> is
/// reserved for semantic validation of already-well-typed values).
/// </summary>
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.");
}
}
}
}

View file

@ -0,0 +1,137 @@
using AcDream.Core.Net.Messages;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
namespace AcDream.Headless.Hosting;
/// <summary>
/// Campaign OP slice OP7 (2026-08-11), D8: the declared-vs-actual character-
/// option diff-and-send engine for a headless bot's optional
/// <c>characterOptions</c> config block
/// (docs/plans/2026-08-10-options-panel-campaign.md §4 OP7).
///
/// <para>
/// Sends through the SAME shared Runtime seam the retail Options panel and
/// every other option-changing entrance uses
/// (<c>IRuntimeCharacterCommands.SetSingleOption</c> /
/// <c>SaveOptions</c> — OP1) so this class owns no wire-format knowledge of
/// its own: an auto-save id's <c>SetSingleOption</c> call sends
/// <c>SetSingleCharacterOption (0x0005)</c> immediately (inside
/// <see cref="RuntimeCharacterOptionsState.TrySetOption"/>); a batched id's
/// call only dirties the module, so this class calls <c>SaveOptions</c>
/// 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).
/// </para>
///
/// <para>
/// <b>Why two note-methods.</b> Sending <c>SetCharacterOptions (0x01A1)</c>
/// (via <c>SaveOptions</c>) before ACE's <c>FirstEnterWorldDone</c> gate
/// opens is a silent no-op server-side (wire research §5.2) — that flag
/// flips from the client's own <c>GameActionLoginComplete</c>, 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 <c>HeadlessSessionHost.CreateEventRoute</c>
/// and <c>RuntimeLiveEntitySessionController</c>'s own two internal send
/// sites). Diffing against live option state before a real
/// <c>PlayerDescription</c> has seeded it would also diff against client-
/// constructor defaults instead of server truth
/// (<see cref="RuntimeCharacterOptionsState.HasServerSeed"/> — 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.
/// </para>
///
/// <para>
/// <b>No "already sent" dedupe.</b> 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
/// (<c>HeadlessSessionHost.CreateEventRoute</c>), so
/// <see cref="_loginCompleteSent"/> never needs to un-latch mid-session.
/// </para>
/// </summary>
internal sealed class HeadlessCharacterOptionsSeeder
{
private readonly IReadOnlyList<KeyValuePair<CharacterOptionId, bool>> _declared;
private readonly GameRuntime _runtime;
private readonly IRuntimeCharacterCommands _commands;
private bool _loginCompleteSent;
internal HeadlessCharacterOptionsSeeder(
IReadOnlyDictionary<CharacterOptionId, bool> 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;
/// <summary>
/// Wired to every production site that can send
/// <c>GameActionLoginComplete</c> for this session — see the type doc.
/// Idempotent to call more than once.
/// </summary>
internal void NoteLoginCompleteSent()
{
_loginCompleteSent = true;
TryDiffAndSend();
}
/// <summary>
/// Wired to <c>LiveCharacterSessionBindings.OnCharacterOptionsChanged</c>
/// — fires after <c>RuntimeCharacterOptionsState.Replace</c> has already
/// committed a fresh <c>PlayerDescription</c>'s option words, on both the
/// first connect and every reconnect's fresh seed.
/// </summary>
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);
}
}

View file

@ -112,6 +112,21 @@ internal sealed class HeadlessSessionHost : IDisposable
private readonly HeadlessSessionDescriptor _descriptor;
private readonly HeadlessCredentialSecret _credential;
private readonly HeadlessDiagnosticWriter _diagnostics;
/// <summary>
/// Campaign OP slice OP7 (2026-08-11), D8: the parsed
/// <c>characterOptions</c> block — empty when the config omitted it.
/// Parsed once at construction; <see cref="HeadlessConfigurationLoader"/>
/// already rejected every unmodelled or out-of-tier name before this
/// host was ever built, so the parse here cannot fail.
/// </summary>
private readonly Dictionary<CharacterOptionId, bool> _declaredCharacterOptions;
/// <summary>
/// Reassigned on every reconnect exactly like <see cref="_worldProjection"/>
/// — a fresh instance per <see cref="CreateEventRoute"/> call gives the
/// seeder's own login-complete latch a clean per-session start without a
/// separate reset method.
/// </summary>
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; }
/// <summary>
/// OP7 test seam (mirrors <see cref="Commands"/>'s own visibility): the
/// current route's declared-<c>characterOptions</c> diff-and-send engine,
/// or <c>null</c> before the first <c>CreateEventRoute</c> 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.
/// </summary>
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;
}
/// <summary>
/// OP7, D8: parses <see cref="HeadlessSessionDescriptor.CharacterOptions"/>
/// into the typed id space the seeder and <see cref="CharacterOptionTable"/>
/// share. <see cref="HeadlessConfigurationLoader"/> already validated
/// every key against the exact bot-declarable name set before this host
/// was constructed, so <c>Enum.Parse</c> here cannot fail.
/// </summary>
private static Dictionary<CharacterOptionId, bool> ParseDeclaredCharacterOptions(
HeadlessSessionDescriptor descriptor)
{
var declared = new Dictionary<CharacterOptionId, bool>();
if (descriptor.CharacterOptions is not { } options)
return declared;
foreach (KeyValuePair<string, bool> pair in options)
{
declared[Enum.Parse<CharacterOptionId>(pair.Key, ignoreCase: false)] =
pair.Value;
}
return declared;
}
private static LiveSessionCharacterSelector MapCharacterSelector(
HeadlessCharacterSelector selector) =>
new(

View file

@ -74,6 +74,22 @@ public sealed class RuntimeLiveEntitySessionController
/// <c>HeadlessSessionWorldProjection.BlipLocalPlayer</c>).
/// </summary>
private readonly RuntimeAcceptedPositionDriveController? _acceptedPositionDrive;
/// <summary>
/// Campaign OP slice OP7 (2026-08-11): passive observation hook — fires
/// AFTER either of this controller's own two internal
/// <c>GameActionLoginComplete</c> send sites (<see cref="OnSpawned"/>'s
/// content-less immediate-admission path; <see cref="TryAdvancePortalCompletion"/>'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
/// <c>RuntimeFirstEntryDriveController</c>'s <c>localPlayerCompleted</c>
/// callback — lives one level up in <c>HeadlessSessionHost</c>, which
/// wires the same observer there directly.
/// </summary>
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<string>? 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} "