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:
parent
2825589035
commit
11641597db
2 changed files with 120 additions and 11 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue