feat(headless): FA6 — role-discriminated policy + the fellowship/allegiance

two-bot gate

Adds the infrastructure docs/plans/2026-08-11-fellowship-allegiance-campaign.md
D8 and docs/research/2026-08-11-fa-acdream-seams.md §6.2 call for:

- HeadlessBotPolicyDescriptor gains an optional typed Role
  (HeadlessBotPolicyRole.Leader/Recruit) so two sessions selecting the SAME
  policy id run different scripts — fellowship leader/allegiance patron vs
  fellowship recruit/allegiance vassal.
- HeadlessBotPolicyFactory.Create widens from Create(string id) to
  Create(HeadlessBotPolicyDescriptor, GameRuntime) — the gate policies need
  RuntimeFriendlyTargetQuery, which (like its RuntimeHostileTargetQuery
  sibling) takes the concrete GameRuntime rather than the narrower
  IGameRuntimeView a policy's own Tick receives (IRuntimeEntityView's
  snapshot carries no name/PWD-bitfield). The single call site
  (HeadlessSessionHost.cs) already has the constructed runtime in scope, so
  no new constructor parameter or cross-session coordinator was needed.
- FellowshipAllegianceLeaderBotPolicy / FellowshipAllegianceRecruitBotPolicy:
  a full stage-machine pair covering proximity (retail's admin @teleallto —
  "teleport everyone online to me" — needs no cross-session name sharing,
  unlike @teleto <name>; D8's proximity requirement is load-bearing, recruit
  fails without it), fellowship create+recruit, the D4 0x00A6 panel-open
  declaration with a vitals-presence assertion, the decisive two-session
  assertions (the RECRUIT bot's own RuntimeFellowshipState/
  RuntimeAllegianceState flipping — not the Leader's local echo), a
  mid-flow reconnect on both bots proving FA2's reset-and-reseed semantics
  over the real wire, and teardown (disband / break) with matching
  decisive-clear assertions.

Every pre-FA6 policy (idle, lifecycle-smoke, observer-movement,
portal-route-smoke, jump-probe) is unaffected; the widened factory
signature is the only touch point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 09:39:55 +02:00
parent 6b8e29cde6
commit 2825589035
3 changed files with 850 additions and 4 deletions

View file

@ -94,6 +94,31 @@ internal sealed class HeadlessBotPolicyDescriptor
{
[JsonRequired]
public string Id { get; init; } = string.Empty;
/// <summary>
/// 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);
/// <see cref="AcDream.Headless.Policies.HeadlessBotPolicyFactory.Create"/>
/// rejects a missing role for a policy id that requires one.
/// </summary>
public HeadlessBotPolicyRole? Role { get; init; }
}
/// <summary>See <see cref="HeadlessBotPolicyDescriptor.Role"/>.</summary>
[JsonConverter(typeof(JsonStringEnumConverter<HeadlessBotPolicyRole>))]
internal enum HeadlessBotPolicyRole
{
/// <summary>Fellowship leader / allegiance patron — creates the
/// fellowship, recruits the Recruit bot, and receives its oath.</summary>
Leader,
/// <summary>Fellowship recruit / allegiance vassal — gets recruited and
/// swears to the Leader bot.</summary>
Recruit,
}
[JsonConverter(typeof(JsonStringEnumConverter<HeadlessCredentialProviderKind>))]

View file

@ -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,

View file

@ -13,17 +13,57 @@ internal interface IHeadlessBotPolicy : IRuntimeEventObserver, IDisposable
internal static class HeadlessBotPolicyFactory
{
internal static IHeadlessBotPolicy Create(string id) =>
id switch
/// <summary>
/// Campaign FA slice FA6 widened this from <c>Create(string id)</c> to
/// also carry the constructed <see cref="GameRuntime"/> — the
/// fellowship/allegiance gate policies need
/// <see cref="AcDream.Runtime.Gameplay.RuntimeFriendlyTargetQuery"/>,
/// which (like its <c>RuntimeHostileTargetQuery</c> sibling) takes the
/// concrete <see cref="GameRuntime"/> rather than the narrower
/// <see cref="IGameRuntimeView"/> a policy's own <c>Tick</c> receives
/// (<see cref="IRuntimeEntityView"/>'s snapshot carries no name/PWD-
/// bitfield). Every pre-FA6 policy ignores the new parameter.
/// </summary>
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
}
}
}
/// <summary>
/// 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
/// <see cref="FellowshipAllegianceRecruitBotPolicy"/> — both selected by
/// the SAME policy id (<c>fellowship-allegiance-gate</c>) and discriminated
/// by <see cref="HeadlessBotPolicyRole"/>.
///
/// <para>
/// <b>Proximity (D8, load-bearing — recruit fails without it).</b> Neither
/// bot's config declares the other's character name (D8's own instruction:
/// discover it live, never hard-code), so this bot cannot <c>@teleto
/// &lt;name&gt;</c> the Recruit bot directly. It uses retail's admin
/// <c>@teleallto</c> 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, <see cref="RuntimeFriendlyTargetQuery"/>
/// resolves its guid from the ordinary entity-streaming radius.
/// </para>
///
/// <para>
/// <b>Mid-flow reconnect (item 7).</b> After establishing both the
/// fellowship and the allegiance oath, this bot reconnects
/// (<c>commands.Session.Reconnect</c>) — a fresh generation resets BOTH
/// <c>RuntimeFellowshipState</c> and <c>RuntimeAllegianceState</c> to empty
/// (FA2 D2) — then re-arms the <c>0x001F</c> allegiance subscription
/// (mirroring retail's <c>RecvNotice_PlayerDescReceived</c> 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.
/// </para>
///
/// <para>
/// Only asserts what THIS bot's own owners observed — proving the OTHER
/// bot's owner actually flipped is
/// <see cref="FellowshipAllegianceRecruitBotPolicy"/>'s job (the decisive
/// two-session assertion per the campaign contract).
/// </para>
/// </summary>
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}");
}
}
}
/// <summary>
/// Campaign FA slice FA6: the fellowship RECRUIT / allegiance VASSAL half
/// of the two-bot connected gate. See
/// <see cref="FellowshipAllegianceLeaderBotPolicy"/>'s class doc for the
/// overall shape (proximity via the Leader bot's <c>@teleallto</c>,
/// establish, mid-flow reconnect, teardown).
///
/// <para>
/// This bot does nothing until the Leader recruits it — it only WATCHES
/// its own <c>RuntimeFellowshipState</c>/<c>RuntimeAllegianceState</c>
/// 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.
/// </para>
/// </summary>
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}");
}
}
}