fix(headless): FA6 — bot confirmation relay for the allegiance swear gate

The first live two-bot run exposed a real gap: retail always confirms an
incoming allegiance swear to the PATRON (0x0274 Character.ConfirmationRequest,
type 1) before ACE sends 0x0020/0x01C8 to either party
(docs/research/2026-08-11-fa-allegiance-wire.md §3.3) — and unlike
fellowship's FellowshipAutoAcceptRequests (which ACE honors server-side,
never even sending a confirmation), there is no auto-accept character option
for allegiance. HeadlessSessionHost wired OnConfirmationRequest to null, so
a headless bot silently dropped every incoming confirmation and the swear
never completed — both bots timed out waiting for TotalVassals/patron to
seed, confirmed live against ACE (both quarantined cleanly with graceful
per-character logout, proving the self-terminating design and existing
graceful-shutdown path both work correctly; this was an FA6 capability gap,
not an FA1-FA5 wire/state defect).

HeadlessSessionHost now latches the single outstanding confirmation
(matching retail's own one-dialog-at-a-time shape) and exposes
PendingConfirmation/RespondToConfirmation, cleared on every reconnect since
a stale context id would be meaningless post-reconnect. The gate's Leader
policy polls and blind-accepts any pending confirmation on every tick before
its own stage switch — the v1 substitute for a human clicking Accept, safe
because the gate's two sessions are its own known bots.

HeadlessBotPolicyFactory.Create takes two new optional delegate parameters
(default null, so cannot break other policy ids); the Leader gate policy
requires them non-null via a defensive constructor check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 09:47:09 +02:00
parent 2825589035
commit 11641597db
2 changed files with 120 additions and 11 deletions

View file

@ -177,6 +177,25 @@ internal sealed class HeadlessSessionHost : IDisposable
/// route's own disposal (via <c>LiveSessionHost</c>'s route replacement) /// route's own disposal (via <c>LiveSessionHost</c>'s route replacement)
/// is independent of this field.</summary> /// is independent of this field.</summary>
private HeadlessSessionEventRoute? _eventRoute; private HeadlessSessionEventRoute? _eventRoute;
/// <summary>
/// Campaign FA slice FA6: the single outstanding <c>0x0274
/// Character.ConfirmationRequest</c>, or <see langword="null"/> when
/// none is pending. Graphical hosts route this to
/// <c>GameplayConfirmationController</c>
/// (<c>LiveSessionRuntimeFactory.cs:315</c>); a headless bot has no
/// panel, so this single-slot latch (matching retail's own "one open
/// dialog at a time" shape) plus <see cref="RespondToConfirmation"/> is
/// the bot-visible substitute a policy can poll and answer — the
/// allegiance swear flow requires it: retail always confirms an
/// incoming swear to the PATRON before <c>0x0020</c>/<c>0x01C8</c>
/// ship to either party (docs/research/2026-08-11-fa-allegiance-wire.md
/// §3.3), and there is no auto-accept character option for it (unlike
/// fellowship's <c>FellowshipAutoAcceptRequests</c>, which ACE honors
/// SERVER-SIDE without ever sending the client a confirmation at all).
/// Reassigned on every reconnect exactly like <see cref="_worldProjection"/>
/// — a stale pre-reconnect context id would be meaningless post-reconnect.
/// </summary>
private GameEvents.CharacterConfirmationRequest? _pendingConfirmation;
private int _disposeStage; private int _disposeStage;
private long _reconnectDeadline; private long _reconnectDeadline;
private bool _reconnectPending; private bool _reconnectPending;
@ -300,7 +319,11 @@ internal sealed class HeadlessSessionHost : IDisposable
hostLease = runtime.AcquireHostLease( hostLease = runtime.AcquireHostLease(
$"headless:{descriptor.Id}"); $"headless:{descriptor.Id}");
policy = policyOverride policy = policyOverride
?? HeadlessBotPolicyFactory.Create(descriptor.Policy, runtime); ?? HeadlessBotPolicyFactory.Create(
descriptor.Policy,
runtime,
() => _pendingConfirmation,
RespondToConfirmation);
policySubscription = runtime.Subscribe(policy); policySubscription = runtime.Subscribe(policy);
diagnostics.Lifecycle( diagnostics.Lifecycle(
descriptor.Id, descriptor.Id,
@ -350,6 +373,33 @@ internal sealed class HeadlessSessionHost : IDisposable
: throw new InvalidOperationException( : throw new InvalidOperationException(
"The headless session has no pending reconnect."); "The headless session has no pending reconnect.");
/// <summary>Campaign FA slice FA6: see <see cref="_pendingConfirmation"/>.</summary>
internal GameEvents.CharacterConfirmationRequest? PendingConfirmation =>
_pendingConfirmation;
/// <summary>
/// Campaign FA slice FA6: sends <c>0x0275 ConfirmationResponse</c> for
/// the current <see cref="PendingConfirmation"/> and clears the latch.
/// Throws if none is pending — mirrors <c>Require</c>'s fail-loud
/// convention elsewhere in this file rather than silently no-op'ing.
/// A missing <see cref="_currentSession"/> (never connected, or
/// mid-reconnect) is a silent no-op — matches every other
/// <c>_currentSession?.Send*</c> site in this class.
/// </summary>
internal void RespondToConfirmation(bool accepted)
{
if (_pendingConfirmation is not { } request)
{
throw new InvalidOperationException(
"No confirmation request is pending.");
}
_currentSession?.SendConfirmationResponse(
request.Type,
request.ContextId,
accepted);
_pendingConfirmation = null;
}
internal RuntimeSessionStartResult Start() => internal RuntimeSessionStartResult Start() =>
Commands.Session.Start(Runtime.Generation); Commands.Session.Start(Runtime.Generation);
@ -630,6 +680,10 @@ internal sealed class HeadlessSessionHost : IDisposable
// its ack-firing accessor must therefore read the CURRENT session // its ack-firing accessor must therefore read the CURRENT session
// through this field, never one captured at first construction. // through this field, never one captured at first construction.
_currentSession = session; _currentSession = session;
// FA6: a stale pre-reconnect confirmation context id is meaningless
// against the fresh WorldSession above — drop it rather than let a
// policy answer a confirmation that no longer has a live listener.
_pendingConfirmation = null;
// OP7: a fresh seeder per route — see the field's own doc comment // OP7: a fresh seeder per route — see the field's own doc comment
// for why this (rather than a reset method) is the right per- // for why this (rather than a reset method) is the right per-
// reconnect lifetime. // reconnect lifetime.
@ -761,7 +815,7 @@ internal sealed class HeadlessSessionHost : IDisposable
Runtime.CharacterOwner, Runtime.CharacterOwner,
ResolveSkillFormulaBonus: null, ResolveSkillFormulaBonus: null,
OnSkillsUpdated: null, OnSkillsUpdated: null,
OnConfirmationRequest: null, OnConfirmationRequest: request => _pendingConfirmation = request,
OnConfirmationDone: null, OnConfirmationDone: null,
ClientTime: () => ClientTime: () =>
Runtime.Clock.SimulationTimeSeconds, Runtime.Clock.SimulationTimeSeconds,

View file

@ -1,3 +1,4 @@
using AcDream.Core.Net.Messages;
using AcDream.Headless.Configuration; using AcDream.Headless.Configuration;
using AcDream.Runtime; using AcDream.Runtime;
using AcDream.Runtime.Gameplay; using AcDream.Runtime.Gameplay;
@ -15,18 +16,27 @@ internal static class HeadlessBotPolicyFactory
{ {
/// <summary> /// <summary>
/// Campaign FA slice FA6 widened this from <c>Create(string id)</c> to /// Campaign FA slice FA6 widened this from <c>Create(string id)</c> to
/// also carry the constructed <see cref="GameRuntime"/> — the /// also carry the constructed <see cref="GameRuntime"/> plus a small
/// fellowship/allegiance gate policies need /// confirmation-relay pair — the fellowship/allegiance gate policies
/// <see cref="AcDream.Runtime.Gameplay.RuntimeFriendlyTargetQuery"/>, /// need <see cref="AcDream.Runtime.Gameplay.RuntimeFriendlyTargetQuery"/>,
/// which (like its <c>RuntimeHostileTargetQuery</c> sibling) takes the /// which (like its <c>RuntimeHostileTargetQuery</c> sibling) takes the
/// concrete <see cref="GameRuntime"/> rather than the narrower /// concrete <see cref="GameRuntime"/> rather than the narrower
/// <see cref="IGameRuntimeView"/> a policy's own <c>Tick</c> receives /// <see cref="IGameRuntimeView"/> a policy's own <c>Tick</c> receives
/// (<see cref="IRuntimeEntityView"/>'s snapshot carries no name/PWD- /// (<see cref="IRuntimeEntityView"/>'s snapshot carries no name/PWD-
/// bitfield). Every pre-FA6 policy ignores the new parameter. /// bitfield); the LEADER half also needs to answer the server-driven
/// <c>0x0274</c> swear confirmation, which no character option can
/// bypass (unlike fellowship's server-side-filtered
/// <c>FellowshipAutoAcceptRequests</c>) and which headless hosts
/// otherwise drop entirely (<c>HeadlessSessionHost</c>'s
/// <c>OnConfirmationRequest</c> was <see langword="null"/> pre-FA6).
/// Every pre-FA6 policy ignores all three new parameters.
/// </summary> /// </summary>
internal static IHeadlessBotPolicy Create( internal static IHeadlessBotPolicy Create(
HeadlessBotPolicyDescriptor descriptor, HeadlessBotPolicyDescriptor descriptor,
GameRuntime runtime) GameRuntime runtime,
Func<GameEvents.CharacterConfirmationRequest?>?
getPendingConfirmation = null,
Action<bool>? respondToConfirmation = null)
{ {
ArgumentNullException.ThrowIfNull(descriptor); ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(runtime); ArgumentNullException.ThrowIfNull(runtime);
@ -38,7 +48,11 @@ internal static class HeadlessBotPolicyFactory
"portal-route-smoke" => new PortalRouteSmokeHeadlessBotPolicy(), "portal-route-smoke" => new PortalRouteSmokeHeadlessBotPolicy(),
"jump-probe" => new JumpProbeHeadlessBotPolicy(), "jump-probe" => new JumpProbeHeadlessBotPolicy(),
"fellowship-allegiance-gate" => "fellowship-allegiance-gate" =>
CreateFellowshipAllegianceGatePolicy(descriptor, runtime), CreateFellowshipAllegianceGatePolicy(
descriptor,
runtime,
getPendingConfirmation,
respondToConfirmation),
_ => throw new HeadlessConfigurationException( _ => throw new HeadlessConfigurationException(
$"Unknown headless bot policy '{descriptor.Id}'."), $"Unknown headless bot policy '{descriptor.Id}'."),
}; };
@ -46,7 +60,9 @@ internal static class HeadlessBotPolicyFactory
private static IHeadlessBotPolicy CreateFellowshipAllegianceGatePolicy( private static IHeadlessBotPolicy CreateFellowshipAllegianceGatePolicy(
HeadlessBotPolicyDescriptor descriptor, HeadlessBotPolicyDescriptor descriptor,
GameRuntime runtime) GameRuntime runtime,
Func<GameEvents.CharacterConfirmationRequest?>? getPendingConfirmation,
Action<bool>? respondToConfirmation)
{ {
if (descriptor.Role is not { } role) if (descriptor.Role is not { } role)
{ {
@ -54,10 +70,21 @@ internal static class HeadlessBotPolicyFactory
"Policy 'fellowship-allegiance-gate' requires a 'role' " "Policy 'fellowship-allegiance-gate' requires a 'role' "
+ "('leader' or 'recruit')."); + "('leader' or 'recruit').");
} }
if (role == HeadlessBotPolicyRole.Leader
&& (getPendingConfirmation is null || respondToConfirmation is null))
{
throw new HeadlessConfigurationException(
"Policy 'fellowship-allegiance-gate' role 'leader' requires "
+ "a confirmation-relay host (the swear confirmation "
+ "targets the patron).");
}
return role switch return role switch
{ {
HeadlessBotPolicyRole.Leader => HeadlessBotPolicyRole.Leader =>
new FellowshipAllegianceLeaderBotPolicy(runtime), new FellowshipAllegianceLeaderBotPolicy(
runtime,
getPendingConfirmation!,
respondToConfirmation!),
HeadlessBotPolicyRole.Recruit => HeadlessBotPolicyRole.Recruit =>
new FellowshipAllegianceRecruitBotPolicy(runtime), new FellowshipAllegianceRecruitBotPolicy(runtime),
_ => throw new HeadlessConfigurationException( _ => throw new HeadlessConfigurationException(
@ -848,6 +875,9 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
} }
private readonly GameRuntime _runtime; private readonly GameRuntime _runtime;
private readonly Func<GameEvents.CharacterConfirmationRequest?>
_getPendingConfirmation;
private readonly Action<bool> _respondToConfirmation;
private Stage _stage = Stage.WaitForPlayer; private Stage _stage = Stage.WaitForPlayer;
private double _stageDeadline; private double _stageDeadline;
private double _nextProximityAttempt; private double _nextProximityAttempt;
@ -855,9 +885,16 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
private uint _targetGuid; private uint _targetGuid;
private ulong _reconnectFromGeneration; private ulong _reconnectFromGeneration;
internal FellowshipAllegianceLeaderBotPolicy(GameRuntime runtime) internal FellowshipAllegianceLeaderBotPolicy(
GameRuntime runtime,
Func<GameEvents.CharacterConfirmationRequest?> getPendingConfirmation,
Action<bool> respondToConfirmation)
{ {
_runtime = runtime ?? throw new ArgumentNullException(nameof(runtime)); _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_getPendingConfirmation = getPendingConfirmation
?? throw new ArgumentNullException(nameof(getPendingConfirmation));
_respondToConfirmation = respondToConfirmation
?? throw new ArgumentNullException(nameof(respondToConfirmation));
} }
public bool IsComplete => _stage == Stage.Done; public bool IsComplete => _stage == Stage.Done;
@ -867,6 +904,24 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
ArgumentNullException.ThrowIfNull(view); ArgumentNullException.ThrowIfNull(view);
ArgumentNullException.ThrowIfNull(commands); ArgumentNullException.ThrowIfNull(commands);
// FA6: unconditionally accept any pending server-driven confirmation
// BEFORE the stage switch below, on every tick regardless of stage —
// the swear confirmation (type 1, ALLEGIANCE_SWEAR_CONFIRM) can
// arrive at any point once the Recruit bot reaches its own Swear
// stage, which this bot does not control the timing of. A headless
// bot has no panel to show the dialog through, so blind-accepting
// is this gate's v1 substitute for a human clicking "Accept" — safe
// here because the only two sessions in this process are the gate's
// own two known bots.
if (_getPendingConfirmation() is { } pending)
{
Console.WriteLine(
$"[fa6-leader] answering pending confirmation type="
+ $"{pending.Type} context={pending.ContextId} "
+ $"text='{pending.Message}' -> accepted=true");
_respondToConfirmation(true);
}
switch (_stage) switch (_stage)
{ {
case Stage.WaitForPlayer: case Stage.WaitForPlayer: