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: