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}");
+ }
+ }
+}