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
|
|
@ -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<string, TValue></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
|
||||
|
|
|
|||
|
|
@ -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<string, bool></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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
137
src/AcDream.Headless/Hosting/HeadlessCharacterOptionsSeeder.cs
Normal file
137
src/AcDream.Headless/Hosting/HeadlessCharacterOptionsSeeder.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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