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)
|
||||
/// is independent of this field.</summary>
|
||||
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 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.");
|
||||
|
||||
/// <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() =>
|
||||
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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue