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
|
|
@ -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} "
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue