diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs index 5270258c..37e62a4d 100644 --- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs +++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs @@ -94,6 +94,31 @@ internal sealed class HeadlessBotPolicyDescriptor { [JsonRequired] public string Id { get; init; } = string.Empty; + + /// + /// Campaign FA slice FA6: optional role discriminator for a policy that + /// coordinates two bots run from the SAME process config (e.g. the + /// fellowship/allegiance gate's Leader — fellowship leader AND + /// allegiance patron — vs Recruit — fellowship recruit AND allegiance + /// vassal, docs/research/2026-08-11-fa-acdream-seams.md §6.2). Ignored + /// by every policy id that doesn't need it (all five pre-FA6 policies); + /// + /// rejects a missing role for a policy id that requires one. + /// + public HeadlessBotPolicyRole? Role { get; init; } +} + +/// See . +[JsonConverter(typeof(JsonStringEnumConverter))] +internal enum HeadlessBotPolicyRole +{ + /// Fellowship leader / allegiance patron — creates the + /// fellowship, recruits the Recruit bot, and receives its oath. + Leader, + + /// Fellowship recruit / allegiance vassal — gets recruited and + /// swears to the Leader bot. + Recruit, } [JsonConverter(typeof(JsonStringEnumConverter))] diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 62a58cec..77a191c2 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -300,7 +300,7 @@ internal sealed class HeadlessSessionHost : IDisposable hostLease = runtime.AcquireHostLease( $"headless:{descriptor.Id}"); policy = policyOverride - ?? HeadlessBotPolicyFactory.Create(descriptor.Policy.Id); + ?? HeadlessBotPolicyFactory.Create(descriptor.Policy, runtime); policySubscription = runtime.Subscribe(policy); diagnostics.Lifecycle( descriptor.Id, diff --git a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs index 53483d48..a1d6e87c 100644 --- a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs +++ b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs @@ -13,17 +13,57 @@ internal interface IHeadlessBotPolicy : IRuntimeEventObserver, IDisposable internal static class HeadlessBotPolicyFactory { - internal static IHeadlessBotPolicy Create(string id) => - id switch + /// + /// Campaign FA slice FA6 widened this from Create(string id) to + /// also carry the constructed — the + /// fellowship/allegiance gate policies need + /// , + /// which (like its RuntimeHostileTargetQuery sibling) takes the + /// concrete rather than the narrower + /// a policy's own Tick receives + /// ('s snapshot carries no name/PWD- + /// bitfield). Every pre-FA6 policy ignores the new parameter. + /// + internal static IHeadlessBotPolicy Create( + HeadlessBotPolicyDescriptor descriptor, + GameRuntime runtime) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(runtime); + return descriptor.Id switch { "idle" => new IdleHeadlessBotPolicy(), "lifecycle-smoke" => new LifecycleSmokeHeadlessBotPolicy(), "observer-movement" => new ObserverMovementHeadlessBotPolicy(), "portal-route-smoke" => new PortalRouteSmokeHeadlessBotPolicy(), "jump-probe" => new JumpProbeHeadlessBotPolicy(), + "fellowship-allegiance-gate" => + CreateFellowshipAllegianceGatePolicy(descriptor, runtime), _ => throw new HeadlessConfigurationException( - $"Unknown headless bot policy '{id}'."), + $"Unknown headless bot policy '{descriptor.Id}'."), }; + } + + private static IHeadlessBotPolicy CreateFellowshipAllegianceGatePolicy( + HeadlessBotPolicyDescriptor descriptor, + GameRuntime runtime) + { + if (descriptor.Role is not { } role) + { + throw new HeadlessConfigurationException( + "Policy 'fellowship-allegiance-gate' requires a 'role' " + + "('leader' or 'recruit')."); + } + return role switch + { + HeadlessBotPolicyRole.Leader => + new FellowshipAllegianceLeaderBotPolicy(runtime), + HeadlessBotPolicyRole.Recruit => + new FellowshipAllegianceRecruitBotPolicy(runtime), + _ => throw new HeadlessConfigurationException( + $"Unknown fellowship-allegiance-gate role '{role}'."), + }; + } } internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy @@ -738,3 +778,784 @@ internal sealed class JumpProbeHeadlessBotPolicy : IHeadlessBotPolicy } } } + +/// +/// Campaign FA slice FA6: the fellowship LEADER / allegiance PATRON half of +/// the two-bot connected gate +/// (docs/plans/2026-08-11-fellowship-allegiance-campaign.md D8; +/// docs/research/2026-08-11-fa-acdream-seams.md §6.2). Paired with +/// — both selected by +/// the SAME policy id (fellowship-allegiance-gate) and discriminated +/// by . +/// +/// +/// Proximity (D8, load-bearing — recruit fails without it). Neither +/// bot's config declares the other's character name (D8's own instruction: +/// discover it live, never hard-code), so this bot cannot @teleto +/// <name> the Recruit bot directly. It uses retail's admin +/// @teleallto instead (no target = "teleport everyone online to +/// me") — a single idempotent GM command needing no cross-session name +/// sharing at all; safe to retry blindly before the Recruit bot has even +/// connected (a lone player teleporting to themselves is a no-op). Once the +/// Recruit bot is colocated, +/// resolves its guid from the ordinary entity-streaming radius. +/// +/// +/// +/// Mid-flow reconnect (item 7). After establishing both the +/// fellowship and the allegiance oath, this bot reconnects +/// (commands.Session.Reconnect) — a fresh generation resets BOTH +/// RuntimeFellowshipState and RuntimeAllegianceState to empty +/// (FA2 D2) — then re-arms the 0x001F allegiance subscription +/// (mirroring retail's RecvNotice_PlayerDescReceived arming point, +/// since a headless bot has no panel to supply the third arming point) and +/// asserts BOTH owners re-seed correctly from the server's own post-login +/// pushes, over the real wire, with a real second account watching. +/// +/// +/// +/// Only asserts what THIS bot's own owners observed — proving the OTHER +/// bot's owner actually flipped is +/// 's job (the decisive +/// two-session assertion per the campaign contract). +/// +/// +internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy +{ + private const string FellowshipName = "AcdreamFA6Gate"; + private const double ProximityRetryPeriodSeconds = 5d; + private const double ProximityTimeoutSeconds = 90d; + private const double StageTimeoutSeconds = 60d; + + private enum Stage + { + WaitForPlayer, + ArmAllegianceSubscription, + EstablishProximity, + CreateFellowship, + WaitInFellowship, + Recruit, + WaitRecruited, + DeclarePanelOpen, + WaitForVassal, + MidFlowReconnect, + WaitReconnectPlayer, + RearmAllegianceSubscription, + WaitReconnectReseed, + Teardown, + WaitTeardown, + Done, + } + + private readonly GameRuntime _runtime; + private Stage _stage = Stage.WaitForPlayer; + private double _stageDeadline; + private double _nextProximityAttempt; + private double _proximityDeadline; + private uint _targetGuid; + private ulong _reconnectFromGeneration; + + internal FellowshipAllegianceLeaderBotPolicy(GameRuntime runtime) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + } + + public bool IsComplete => _stage == Stage.Done; + + public void Tick(IGameRuntimeView view, IGameRuntimeCommands commands) + { + ArgumentNullException.ThrowIfNull(view); + ArgumentNullException.ThrowIfNull(commands); + + switch (_stage) + { + case Stage.WaitForPlayer: + if (!HasLocalPlayer(view)) + return; + Console.WriteLine("[fa6-leader] local player present"); + Advance(view, Stage.ArmAllegianceSubscription); + break; + + case Stage.ArmAllegianceSubscription: + Require( + commands.Allegiance.SetUpdateSubscription( + view.Generation, + true), + "arm allegiance subscription (0x001F on)"); + Console.WriteLine("[fa6-leader] allegiance subscription armed"); + _nextProximityAttempt = view.Clock.SimulationTimeSeconds; + _proximityDeadline = + view.Clock.SimulationTimeSeconds + ProximityTimeoutSeconds; + Advance(view, Stage.EstablishProximity); + break; + + case Stage.EstablishProximity: + TickEstablishProximity(view, commands); + break; + + case Stage.CreateFellowship: + if (view.Fellowship.Snapshot.IsInFellowship) + { + Console.WriteLine( + "[fa6-leader] already in a fellowship — skipping create"); + Advance(view, Stage.Recruit); + return; + } + Require( + commands.Fellowship.Create( + view.Generation, + FellowshipName, + shareXp: true), + "create fellowship"); + Console.WriteLine( + $"[fa6-leader] sent FellowshipCreate '{FellowshipName}'"); + Advance(view, Stage.WaitInFellowship); + break; + + case Stage.WaitInFellowship: + WaitUntil( + view, + view.Fellowship.Snapshot.IsInFellowship, + Stage.Recruit, + "own fellowship snapshot IsInFellowship"); + break; + + case Stage.Recruit: + if (view.Fellowship.Snapshot.MemberCount >= 2) + { + Console.WriteLine( + "[fa6-leader] fellowship already has 2 members — skipping recruit"); + Advance(view, Stage.DeclarePanelOpen); + return; + } + Require( + commands.Fellowship.Recruit(view.Generation, _targetGuid), + "recruit"); + Console.WriteLine( + $"[fa6-leader] sent FellowshipRecruit target=0x{_targetGuid:X8}"); + Advance(view, Stage.WaitRecruited); + break; + + case Stage.WaitRecruited: + WaitUntil( + view, + view.Fellowship.Snapshot.MemberCount >= 2, + Stage.DeclarePanelOpen, + "own fellowship MemberCount reaching 2 (proves the " + + "recruit round-trip completed)"); + break; + + case Stage.DeclarePanelOpen: + Require( + commands.Fellowship.SetPanelOpen(view.Generation, true), + "declare fellowship panel open (0x00A6)"); + if (view.Fellowship.TryGetMember( + _targetGuid, + out RuntimeFellowMemberSnapshot member)) + { + Console.WriteLine( + $"[fa6-leader] panel-open declared; recruit vitals " + + $"name='{member.Name}' maxHealth={member.MaxHealth}"); + if (member.MaxHealth == 0u) + { + throw new InvalidOperationException( + "[fa6-leader] recruit's own fellowship member " + + "row carries no vitals (MaxHealth == 0) after " + + "the panel-open declaration."); + } + } + else + { + Console.WriteLine( + "[fa6-leader] WARNING panel-open declared but " + + "recruit's member row is not yet resolvable"); + } + Advance(view, Stage.WaitForVassal); + break; + + case Stage.WaitForVassal: + WaitUntil( + view, + view.Allegiance.Snapshot.TotalVassals >= 1, + Stage.MidFlowReconnect, + "own allegiance TotalVassals reaching 1 (proves the " + + "recruit's swear reached this bot's own tree)"); + if (_stage == Stage.MidFlowReconnect) + { + bool hasTargetAsVassal = false; + foreach (RuntimeAllegianceMemberSnapshot vassal + in view.Allegiance.GetVassals(view.Lifecycle.PlayerGuid)) + { + if (vassal.CharacterId == _targetGuid) + { + hasTargetAsVassal = true; + break; + } + } + if (!hasTargetAsVassal) + { + throw new InvalidOperationException( + $"[fa6-leader] TotalVassals reached 1 but " + + $"GetVassals does not contain target " + + $"0x{_targetGuid:X8}."); + } + Console.WriteLine( + $"[fa6-leader] vassal list contains recruit " + + $"0x{_targetGuid:X8} — decisive allegiance " + + "establish confirmed on the LEADER side"); + } + break; + + case Stage.MidFlowReconnect: + _reconnectFromGeneration = view.Generation.Value; + RuntimeSessionStartResult reconnect = + commands.Session.Reconnect(view.Generation); + if (reconnect.Status + is not (RuntimeSessionStartStatus.Connected + or RuntimeSessionStartStatus.Deferred)) + { + throw new InvalidOperationException( + $"[fa6-leader] mid-flow reconnect rejected with " + + $"{reconnect.Status}."); + } + Console.WriteLine("[fa6-leader] mid-flow reconnect issued"); + Advance(view, Stage.WaitReconnectPlayer); + break; + + case Stage.WaitReconnectPlayer: + if (view.Generation.Value <= _reconnectFromGeneration + || !HasLocalPlayer(view)) + { + CheckStageTimeout(view, "reconnect to materialize a new generation with a local player"); + return; + } + Console.WriteLine( + $"[fa6-leader] reconnected — generation " + + $"{_reconnectFromGeneration} -> {view.Generation.Value}"); + Advance(view, Stage.RearmAllegianceSubscription); + break; + + case Stage.RearmAllegianceSubscription: + Require( + commands.Allegiance.SetUpdateSubscription( + view.Generation, + true), + "re-arm allegiance subscription after reconnect"); + Console.WriteLine( + "[fa6-leader] allegiance subscription re-armed post-reconnect"); + Advance(view, Stage.WaitReconnectReseed); + break; + + case Stage.WaitReconnectReseed: + if (view.Fellowship.Snapshot.IsInFellowship + && view.Fellowship.Snapshot.MemberCount >= 2 + && view.Allegiance.Snapshot.TotalVassals >= 1) + { + Console.WriteLine( + "[fa6-leader] RECONNECT-IDEMPOTENCE CONFIRMED: " + + "fellowship and allegiance both re-seeded " + + $"(members={view.Fellowship.Snapshot.MemberCount}, " + + $"vassals={view.Allegiance.Snapshot.TotalVassals})"); + Advance(view, Stage.Teardown); + return; + } + CheckStageTimeout( + view, + "fellowship and allegiance to re-seed after reconnect " + + $"(IsInFellowship={view.Fellowship.Snapshot.IsInFellowship}, " + + $"MemberCount={view.Fellowship.Snapshot.MemberCount}, " + + $"TotalVassals={view.Allegiance.Snapshot.TotalVassals})"); + break; + + case Stage.Teardown: + Require( + commands.Fellowship.Quit(view.Generation, disband: true), + "disband fellowship"); + Console.WriteLine("[fa6-leader] sent FellowshipQuit disband=true"); + Advance(view, Stage.WaitTeardown); + break; + + case Stage.WaitTeardown: + WaitUntil( + view, + !view.Fellowship.Snapshot.IsInFellowship, + Stage.Done, + "own fellowship snapshot clearing after disband"); + if (_stage == Stage.Done) + { + Console.WriteLine( + "[fa6-leader] TEARDOWN CONFIRMED: fellowship " + + "disbanded; gate complete"); + } + break; + + case Stage.Done: + break; + } + } + + private void TickEstablishProximity( + IGameRuntimeView view, + IGameRuntimeCommands commands) + { + uint? found = RuntimeFriendlyTargetQuery.FindClosestOtherPlayer(_runtime); + if (found is { } guid) + { + _targetGuid = guid; + string? name = RuntimeFriendlyTargetQuery.TryGetName(_runtime, guid); + Console.WriteLine( + $"[fa6-leader] proximity established: recruit guid=" + + $"0x{guid:X8} name='{name}'"); + Advance(view, Stage.CreateFellowship); + return; + } + + if (view.Clock.SimulationTimeSeconds >= _nextProximityAttempt) + { + Require( + commands.Chat.Execute( + view.Generation, + new RuntimeChatCommand(RuntimeChatChannel.Say, "@teleallto")), + "send @teleallto"); + Console.WriteLine( + "[fa6-leader] sent @teleallto (no friendly entity resolved yet)"); + _nextProximityAttempt = + view.Clock.SimulationTimeSeconds + ProximityRetryPeriodSeconds; + } + + if (view.Clock.SimulationTimeSeconds >= _proximityDeadline) + { + throw new InvalidOperationException( + "[fa6-leader] timed out establishing proximity to the " + + "recruit bot — no other player entity ever streamed in. " + + "Either the second account never connected, or " + + "@teleallto was refused."); + } + } + + private void Advance(IGameRuntimeView view, Stage next) + { + Console.WriteLine($"[fa6-leader] stage {_stage} -> {next}"); + _stage = next; + _stageDeadline = view.Clock.SimulationTimeSeconds + StageTimeoutSeconds; + } + + private void WaitUntil( + IGameRuntimeView view, + bool condition, + Stage next, + string description) + { + if (condition) + { + Advance(view, next); + return; + } + CheckStageTimeout(view, description); + } + + private void CheckStageTimeout(IGameRuntimeView view, string description) + { + if (view.Clock.SimulationTimeSeconds >= _stageDeadline) + { + throw new InvalidOperationException( + $"[fa6-leader] timed out in stage {_stage} waiting for: " + + description); + } + } + + public void OnLifecycle(in RuntimeLifecycleDelta delta) + { + } + + public void OnCommand(in RuntimeCommandDelta delta) + { + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + } + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + + public void OnChat(in RuntimeChatDelta delta) + { + } + + public void OnMovement(in RuntimeMovementDelta delta) + { + } + + public void OnPortal(in RuntimePortalDelta delta) + { + } + + public void OnCombat(in RuntimeCombatDelta delta) + { + } + + public void Dispose() + { + } + + private static bool HasLocalPlayer(IGameRuntimeView view) => + view.Lifecycle.State is RuntimeLifecycleState.InWorld + && view.Lifecycle.PlayerGuid != 0u + && view.Entities.TryGet( + view.Lifecycle.PlayerGuid, + out _); + + private static void Require(in RuntimeCommandResult result, string what) + { + if (!result.Accepted) + { + throw new InvalidOperationException( + $"[fa6-leader] command rejected ({what}): {result.Status}"); + } + } +} + +/// +/// Campaign FA slice FA6: the fellowship RECRUIT / allegiance VASSAL half +/// of the two-bot connected gate. See +/// 's class doc for the +/// overall shape (proximity via the Leader bot's @teleallto, +/// establish, mid-flow reconnect, teardown). +/// +/// +/// This bot does nothing until the Leader recruits it — it only WATCHES +/// its own RuntimeFellowshipState/RuntimeAllegianceState +/// flip, which is the campaign's decisive two-session assertion: the +/// Leader's recruit/swear reached ACE, ACE pushed a real inbound message +/// to a SEPARATE client process, and that process's own canonical Runtime +/// owner (not the Leader's local echo) observed it. +/// +/// +internal sealed class FellowshipAllegianceRecruitBotPolicy : IHeadlessBotPolicy +{ + // Generous on purpose: WaitForRecruit's real deadline is coupled to the + // LEADER's own EstablishProximity budget (up to + // FellowshipAllegianceLeaderBotPolicy.ProximityTimeoutSeconds = 90s) + // plus create+recruit overhead — this bot's own stage clock starts + // ticking independently (roughly the same wall-clock moment both + // sessions reach InWorld), so its timeout must comfortably outlast the + // Leader's worst case rather than share its budget. + private const double StageTimeoutSeconds = 150d; + + private enum Stage + { + WaitForPlayer, + ArmAllegianceSubscription, + WaitForRecruit, + Swear, + WaitSwornSeed, + MidFlowReconnect, + WaitReconnectPlayer, + RearmAllegianceSubscription, + WaitReconnectReseed, + Break, + WaitBrokenSeed, + WaitFellowshipDisbandCleared, + Done, + } + + private readonly GameRuntime _runtime; + private Stage _stage = Stage.WaitForPlayer; + private double _stageDeadline; + private uint _patronGuid; + private ulong _reconnectFromGeneration; + + internal FellowshipAllegianceRecruitBotPolicy(GameRuntime runtime) + { + _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); + } + + public bool IsComplete => _stage == Stage.Done; + + public void Tick(IGameRuntimeView view, IGameRuntimeCommands commands) + { + ArgumentNullException.ThrowIfNull(view); + ArgumentNullException.ThrowIfNull(commands); + + switch (_stage) + { + case Stage.WaitForPlayer: + if (!HasLocalPlayer(view)) + return; + Console.WriteLine("[fa6-recruit] local player present"); + Advance(view, Stage.ArmAllegianceSubscription); + break; + + case Stage.ArmAllegianceSubscription: + Require( + commands.Allegiance.SetUpdateSubscription( + view.Generation, + true), + "arm allegiance subscription (0x001F on)"); + Console.WriteLine("[fa6-recruit] allegiance subscription armed"); + Advance(view, Stage.WaitForRecruit); + break; + + case Stage.WaitForRecruit: + if (view.Fellowship.Snapshot.IsInFellowship + && view.Fellowship.Snapshot.MemberCount >= 2) + { + uint leaderGuid = view.Fellowship.Snapshot.LeaderGuid; + if (leaderGuid == 0u || leaderGuid == view.Lifecycle.PlayerGuid) + { + throw new InvalidOperationException( + $"[fa6-recruit] DECISIVE ASSERTION FAILED: own " + + $"fellowship snapshot flipped but LeaderGuid=" + + $"0x{leaderGuid:X8} is not a distinct other " + + "player."); + } + uint? nearbyOther = + RuntimeFriendlyTargetQuery.FindClosestOtherPlayer(_runtime); + if (nearbyOther is { } otherGuid && otherGuid != leaderGuid) + { + throw new InvalidOperationException( + $"[fa6-recruit] DECISIVE ASSERTION FAILED: " + + $"fellowship LeaderGuid=0x{leaderGuid:X8} does " + + $"not match the nearest other player entity " + + $"0x{otherGuid:X8}."); + } + _patronGuid = leaderGuid; + Console.WriteLine( + "[fa6-recruit] DECISIVE ASSERTION PASSED: own " + + "RuntimeFellowshipState flipped IsInFellowship=true, " + + $"MemberCount={view.Fellowship.Snapshot.MemberCount}, " + + $"LeaderGuid=0x{leaderGuid:X8} — recruit inbound " + + "path reached THIS process's own Runtime owner"); + Advance(view, Stage.Swear); + return; + } + CheckStageTimeout( + view, + "own fellowship snapshot to flip IsInFellowship=true with " + + "MemberCount>=2 (the Leader's recruit never landed " + + "on this bot's own Runtime owner)"); + break; + + case Stage.Swear: + Require( + commands.Allegiance.Swear(view.Generation, _patronGuid), + "swear allegiance"); + Console.WriteLine( + $"[fa6-recruit] sent AllegianceSwear patron=0x{_patronGuid:X8}"); + Advance(view, Stage.WaitSwornSeed); + break; + + case Stage.WaitSwornSeed: + if (view.Allegiance.TryGetPatron( + view.Lifecycle.PlayerGuid, + out RuntimeAllegianceMemberSnapshot patron) + && patron.CharacterId == _patronGuid) + { + Console.WriteLine( + "[fa6-recruit] DECISIVE ASSERTION PASSED: own " + + $"RuntimeAllegianceState TryGetPatron == 0x" + + $"{_patronGuid:X8} — swear inbound path reached " + + "THIS process's own Runtime owner"); + Advance(view, Stage.MidFlowReconnect); + return; + } + CheckStageTimeout( + view, + "own allegiance snapshot's patron to become the Leader " + + "bot after swearing"); + break; + + case Stage.MidFlowReconnect: + _reconnectFromGeneration = view.Generation.Value; + RuntimeSessionStartResult reconnect = + commands.Session.Reconnect(view.Generation); + if (reconnect.Status + is not (RuntimeSessionStartStatus.Connected + or RuntimeSessionStartStatus.Deferred)) + { + throw new InvalidOperationException( + $"[fa6-recruit] mid-flow reconnect rejected with " + + $"{reconnect.Status}."); + } + Console.WriteLine("[fa6-recruit] mid-flow reconnect issued"); + Advance(view, Stage.WaitReconnectPlayer); + break; + + case Stage.WaitReconnectPlayer: + if (view.Generation.Value <= _reconnectFromGeneration + || !HasLocalPlayer(view)) + { + CheckStageTimeout( + view, + "reconnect to materialize a new generation with a " + + "local player"); + return; + } + Console.WriteLine( + $"[fa6-recruit] reconnected — generation " + + $"{_reconnectFromGeneration} -> {view.Generation.Value}"); + Advance(view, Stage.RearmAllegianceSubscription); + break; + + case Stage.RearmAllegianceSubscription: + Require( + commands.Allegiance.SetUpdateSubscription( + view.Generation, + true), + "re-arm allegiance subscription after reconnect"); + Console.WriteLine( + "[fa6-recruit] allegiance subscription re-armed post-reconnect"); + Advance(view, Stage.WaitReconnectReseed); + break; + + case Stage.WaitReconnectReseed: + if (view.Fellowship.Snapshot.IsInFellowship + && view.Fellowship.Snapshot.MemberCount >= 2 + && view.Allegiance.TryGetPatron( + view.Lifecycle.PlayerGuid, + out RuntimeAllegianceMemberSnapshot reseededPatron) + && reseededPatron.CharacterId == _patronGuid) + { + Console.WriteLine( + "[fa6-recruit] RECONNECT-IDEMPOTENCE CONFIRMED: " + + "fellowship and allegiance both re-seeded on the " + + "RECRUIT side"); + Advance(view, Stage.Break); + return; + } + CheckStageTimeout( + view, + "fellowship and allegiance to re-seed after reconnect"); + break; + + case Stage.Break: + Require( + commands.Allegiance.Break(view.Generation, _patronGuid), + "break allegiance"); + Console.WriteLine( + $"[fa6-recruit] sent AllegianceBreak target=0x{_patronGuid:X8}"); + Advance(view, Stage.WaitBrokenSeed); + break; + + case Stage.WaitBrokenSeed: + if (!view.Allegiance.TryGetPatron( + view.Lifecycle.PlayerGuid, + out RuntimeAllegianceMemberSnapshot stillPatron) + || stillPatron.CharacterId != _patronGuid) + { + Console.WriteLine( + "[fa6-recruit] TEARDOWN CONFIRMED: own allegiance " + + "snapshot no longer shows the Leader as patron"); + Advance(view, Stage.WaitFellowshipDisbandCleared); + return; + } + CheckStageTimeout( + view, + "own allegiance snapshot's patron to clear after break"); + break; + + case Stage.WaitFellowshipDisbandCleared: + WaitUntil( + view, + !view.Fellowship.Snapshot.IsInFellowship, + Stage.Done, + "own fellowship snapshot clearing after the Leader's " + + "disband"); + if (_stage == Stage.Done) + { + Console.WriteLine( + "[fa6-recruit] TEARDOWN CONFIRMED: own fellowship " + + "snapshot cleared after disband; gate complete"); + } + break; + + case Stage.Done: + break; + } + } + + private void Advance(IGameRuntimeView view, Stage next) + { + Console.WriteLine($"[fa6-recruit] stage {_stage} -> {next}"); + _stage = next; + _stageDeadline = view.Clock.SimulationTimeSeconds + StageTimeoutSeconds; + } + + private void WaitUntil( + IGameRuntimeView view, + bool condition, + Stage next, + string description) + { + if (condition) + { + Advance(view, next); + return; + } + CheckStageTimeout(view, description); + } + + private void CheckStageTimeout(IGameRuntimeView view, string description) + { + if (view.Clock.SimulationTimeSeconds >= _stageDeadline) + { + throw new InvalidOperationException( + $"[fa6-recruit] timed out in stage {_stage} waiting for: " + + description); + } + } + + public void OnLifecycle(in RuntimeLifecycleDelta delta) + { + } + + public void OnCommand(in RuntimeCommandDelta delta) + { + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + } + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + + public void OnChat(in RuntimeChatDelta delta) + { + } + + public void OnMovement(in RuntimeMovementDelta delta) + { + } + + public void OnPortal(in RuntimePortalDelta delta) + { + } + + public void OnCombat(in RuntimeCombatDelta delta) + { + } + + public void Dispose() + { + } + + private static bool HasLocalPlayer(IGameRuntimeView view) => + view.Lifecycle.State is RuntimeLifecycleState.InWorld + && view.Lifecycle.PlayerGuid != 0u + && view.Entities.TryGet( + view.Lifecycle.PlayerGuid, + out _); + + private static void Require(in RuntimeCommandResult result, string what) + { + if (!result.Accepted) + { + throw new InvalidOperationException( + $"[fa6-recruit] command rejected ({what}): {result.Status}"); + } + } +}