diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
index 77a191c2..dddd120f 100644
--- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
+++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
@@ -177,6 +177,25 @@ internal sealed class HeadlessSessionHost : IDisposable
/// route's own disposal (via LiveSessionHost's route replacement)
/// is independent of this field.
private HeadlessSessionEventRoute? _eventRoute;
+ ///
+ /// Campaign FA slice FA6: the single outstanding 0x0274
+ /// Character.ConfirmationRequest, or when
+ /// none is pending. Graphical hosts route this to
+ /// GameplayConfirmationController
+ /// (LiveSessionRuntimeFactory.cs:315); a headless bot has no
+ /// panel, so this single-slot latch (matching retail's own "one open
+ /// dialog at a time" shape) plus 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 0x0020/0x01C8
+ /// 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 FellowshipAutoAcceptRequests, which ACE honors
+ /// SERVER-SIDE without ever sending the client a confirmation at all).
+ /// Reassigned on every reconnect exactly like
+ /// — a stale pre-reconnect context id would be meaningless post-reconnect.
+ ///
+ private GameEvents.CharacterConfirmationRequest? _pendingConfirmation;
private int _disposeStage;
private long _reconnectDeadline;
private bool _reconnectPending;
@@ -300,7 +319,11 @@ internal sealed class HeadlessSessionHost : IDisposable
hostLease = runtime.AcquireHostLease(
$"headless:{descriptor.Id}");
policy = policyOverride
- ?? HeadlessBotPolicyFactory.Create(descriptor.Policy, runtime);
+ ?? HeadlessBotPolicyFactory.Create(
+ descriptor.Policy,
+ runtime,
+ () => _pendingConfirmation,
+ RespondToConfirmation);
policySubscription = runtime.Subscribe(policy);
diagnostics.Lifecycle(
descriptor.Id,
@@ -350,6 +373,33 @@ internal sealed class HeadlessSessionHost : IDisposable
: throw new InvalidOperationException(
"The headless session has no pending reconnect.");
+ /// Campaign FA slice FA6: see .
+ internal GameEvents.CharacterConfirmationRequest? PendingConfirmation =>
+ _pendingConfirmation;
+
+ ///
+ /// Campaign FA slice FA6: sends 0x0275 ConfirmationResponse for
+ /// the current and clears the latch.
+ /// Throws if none is pending — mirrors Require's fail-loud
+ /// convention elsewhere in this file rather than silently no-op'ing.
+ /// A missing (never connected, or
+ /// mid-reconnect) is a silent no-op — matches every other
+ /// _currentSession?.Send* site in this class.
+ ///
+ 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() =>
Commands.Session.Start(Runtime.Generation);
@@ -630,6 +680,10 @@ internal sealed class HeadlessSessionHost : IDisposable
// its ack-firing accessor must therefore read the CURRENT session
// through this field, never one captured at first construction.
_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
// for why this (rather than a reset method) is the right per-
// reconnect lifetime.
@@ -761,7 +815,7 @@ internal sealed class HeadlessSessionHost : IDisposable
Runtime.CharacterOwner,
ResolveSkillFormulaBonus: null,
OnSkillsUpdated: null,
- OnConfirmationRequest: null,
+ OnConfirmationRequest: request => _pendingConfirmation = request,
OnConfirmationDone: null,
ClientTime: () =>
Runtime.Clock.SimulationTimeSeconds,
diff --git a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
index a1d6e87c..76a32217 100644
--- a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
+++ b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs
@@ -1,3 +1,4 @@
+using AcDream.Core.Net.Messages;
using AcDream.Headless.Configuration;
using AcDream.Runtime;
using AcDream.Runtime.Gameplay;
@@ -15,18 +16,27 @@ internal static class HeadlessBotPolicyFactory
{
///
/// Campaign FA slice FA6 widened this from Create(string id) to
- /// also carry the constructed — the
- /// fellowship/allegiance gate policies need
- /// ,
+ /// also carry the constructed plus a small
+ /// confirmation-relay pair — 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.
+ /// bitfield); the LEADER half also needs to answer the server-driven
+ /// 0x0274 swear confirmation, which no character option can
+ /// bypass (unlike fellowship's server-side-filtered
+ /// FellowshipAutoAcceptRequests) and which headless hosts
+ /// otherwise drop entirely (HeadlessSessionHost's
+ /// OnConfirmationRequest was pre-FA6).
+ /// Every pre-FA6 policy ignores all three new parameters.
///
internal static IHeadlessBotPolicy Create(
HeadlessBotPolicyDescriptor descriptor,
- GameRuntime runtime)
+ GameRuntime runtime,
+ Func?
+ getPendingConfirmation = null,
+ Action? respondToConfirmation = null)
{
ArgumentNullException.ThrowIfNull(descriptor);
ArgumentNullException.ThrowIfNull(runtime);
@@ -38,7 +48,11 @@ internal static class HeadlessBotPolicyFactory
"portal-route-smoke" => new PortalRouteSmokeHeadlessBotPolicy(),
"jump-probe" => new JumpProbeHeadlessBotPolicy(),
"fellowship-allegiance-gate" =>
- CreateFellowshipAllegianceGatePolicy(descriptor, runtime),
+ CreateFellowshipAllegianceGatePolicy(
+ descriptor,
+ runtime,
+ getPendingConfirmation,
+ respondToConfirmation),
_ => throw new HeadlessConfigurationException(
$"Unknown headless bot policy '{descriptor.Id}'."),
};
@@ -46,7 +60,9 @@ internal static class HeadlessBotPolicyFactory
private static IHeadlessBotPolicy CreateFellowshipAllegianceGatePolicy(
HeadlessBotPolicyDescriptor descriptor,
- GameRuntime runtime)
+ GameRuntime runtime,
+ Func? getPendingConfirmation,
+ Action? respondToConfirmation)
{
if (descriptor.Role is not { } role)
{
@@ -54,10 +70,21 @@ internal static class HeadlessBotPolicyFactory
"Policy 'fellowship-allegiance-gate' requires a 'role' "
+ "('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
{
HeadlessBotPolicyRole.Leader =>
- new FellowshipAllegianceLeaderBotPolicy(runtime),
+ new FellowshipAllegianceLeaderBotPolicy(
+ runtime,
+ getPendingConfirmation!,
+ respondToConfirmation!),
HeadlessBotPolicyRole.Recruit =>
new FellowshipAllegianceRecruitBotPolicy(runtime),
_ => throw new HeadlessConfigurationException(
@@ -848,6 +875,9 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
}
private readonly GameRuntime _runtime;
+ private readonly Func
+ _getPendingConfirmation;
+ private readonly Action _respondToConfirmation;
private Stage _stage = Stage.WaitForPlayer;
private double _stageDeadline;
private double _nextProximityAttempt;
@@ -855,9 +885,16 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
private uint _targetGuid;
private ulong _reconnectFromGeneration;
- internal FellowshipAllegianceLeaderBotPolicy(GameRuntime runtime)
+ internal FellowshipAllegianceLeaderBotPolicy(
+ GameRuntime runtime,
+ Func getPendingConfirmation,
+ Action respondToConfirmation)
{
_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;
@@ -867,6 +904,24 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
ArgumentNullException.ThrowIfNull(view);
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)
{
case Stage.WaitForPlayer: