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:
parent
6b8e29cde6
commit
2825589035
3 changed files with 850 additions and 4 deletions
|
|
@ -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>))]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// <name></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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue