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:
parent
efe80d5a0d
commit
09cb548a32
9 changed files with 1278 additions and 5 deletions
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue