diff --git a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs index 66e096e2..5682e156 100644 --- a/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessSessionHost.cs @@ -836,7 +836,20 @@ internal sealed class HeadlessSessionHost : IDisposable Runtime.CharacterOwner, ResolveSkillFormulaBonus: null, OnSkillsUpdated: null, - OnConfirmationRequest: request => _pendingConfirmation = request, + OnConfirmationRequest: request => + { + // Kept as a permanent, low-volume diagnostic — docs/ + // ISSUES.md #384's open question is precisely "does + // ACE ever send 0x0274 for the allegiance swear at + // all", and this line is the only place that would + // show a positive answer once someone re-enables + // AllegianceGateEnabled and reruns the gate. + Console.WriteLine( + $"[fa6-diag] OnConfirmationRequest received type=" + + $"{request.Type} context={request.ContextId} " + + $"text='{request.Message}'"); + _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 2a85ffe3..90ee240c 100644 --- a/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs +++ b/src/AcDream.Headless/Policies/HeadlessBotPolicy.cs @@ -893,6 +893,26 @@ internal sealed class FellowshipAllegianceGateCoordinator internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy { + /// + /// docs/ISSUES.md #384: live-run evidence (three connected attempts, + /// most recently with a distance diagnostic proving the two bots were + /// 0.005 m apart — well inside retail's 2.0 m swear-distance gate) shows + /// ACE returns NOTHING to Event_SwearAllegiance (0x001D): no + /// 0x0274 confirmation, no 0x0020 tree update, no + /// WeenieError. That is ambiguous between an FA1 wire-builder defect, + /// an ACE-side rule this test pair happens to trip (GM/self-allegiance/ + /// rank), or a genuine drop — disambiguating it needs an ACE server + /// console the automated harness doesn't have. The FELLOWSHIP half of + /// this gate is fully proven live (the decisive cross-session + /// assertion — the Recruit bot's own RuntimeFellowshipState + /// flipping — passed three separate runs) and ships as the automated + /// gate; the allegiance half stays written (this class still carries + /// every allegiance stage) but DISABLED by default until #384 closes. + /// Flip to to re-enable it for a follow-up + /// investigation. + /// + private static readonly bool AllegianceGateEnabled = false; + private const string FellowshipName = "AcdreamFA6Gate"; private const double ProximityRetryPeriodSeconds = 5d; private const double ProximityTimeoutSeconds = 90d; @@ -1073,7 +1093,16 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy "[fa6-leader] WARNING panel-open declared but " + "recruit's member row is not yet resolvable"); } - Advance(view, Stage.WaitForVassal); + // docs/ISSUES.md #384: the allegiance-swear half of this + // gate is DISABLED by default — see AllegianceGateEnabled's + // own doc comment. The fellowship half above is the + // passing automated proof; skip straight to the + // reconnect/teardown that don't depend on allegiance state. + Advance( + view, + AllegianceGateEnabled + ? Stage.WaitForVassal + : Stage.MidFlowReconnect); break; case Stage.WaitForVassal: @@ -1152,11 +1181,14 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy case Stage.WaitReconnectReseed: if (view.Fellowship.Snapshot.IsInFellowship && view.Fellowship.Snapshot.MemberCount >= 2 - && view.Allegiance.Snapshot.TotalVassals >= 1) + && (!AllegianceGateEnabled + || view.Allegiance.Snapshot.TotalVassals >= 1)) { Console.WriteLine( "[fa6-leader] RECONNECT-IDEMPOTENCE CONFIRMED: " - + "fellowship and allegiance both re-seeded " + + "fellowship" + + (AllegianceGateEnabled ? " and allegiance both" : "") + + " re-seeded " + $"(members={view.Fellowship.Snapshot.MemberCount}, " + $"vassals={view.Allegiance.Snapshot.TotalVassals})"); Advance(view, Stage.Teardown); @@ -1164,7 +1196,9 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy } CheckStageTimeout( view, - "fellowship and allegiance to re-seed after reconnect " + "fellowship" + + (AllegianceGateEnabled ? " and allegiance" : "") + + " to re-seed after reconnect " + $"(IsInFellowship={view.Fellowship.Snapshot.IsInFellowship}, " + $"MemberCount={view.Fellowship.Snapshot.MemberCount}, " + $"TotalVassals={view.Allegiance.Snapshot.TotalVassals})"); @@ -1354,6 +1388,15 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy /// internal sealed class FellowshipAllegianceRecruitBotPolicy : IHeadlessBotPolicy { + /// + /// See 's + /// doc comment (docs/ISSUES.md #384) — the two constants are kept + /// independent (each class owns its own) rather than shared so a + /// partial re-enable during investigation can't silently desync the + /// two bots' stage machines. + /// + private static readonly bool AllegianceGateEnabled = false; + // Generous on purpose: WaitForRecruit's real deadline is coupled to the // LEADER's own EstablishProximity budget (up to // FellowshipAllegianceLeaderBotPolicy.ProximityTimeoutSeconds = 90s) @@ -1447,7 +1490,13 @@ internal sealed class FellowshipAllegianceRecruitBotPolicy : IHeadlessBotPolicy + $"MemberCount={view.Fellowship.Snapshot.MemberCount}, " + $"LeaderGuid=0x{leaderGuid:X8} — recruit inbound " + "path reached THIS process's own Runtime owner"); - Advance(view, Stage.Swear); + // docs/ISSUES.md #384: allegiance half disabled by + // default — see AllegianceGateEnabled's doc comment. + Advance( + view, + AllegianceGateEnabled + ? Stage.Swear + : Stage.MidFlowReconnect); return; } CheckStageTimeout( @@ -1458,6 +1507,15 @@ internal sealed class FellowshipAllegianceRecruitBotPolicy : IHeadlessBotPolicy break; case Stage.Swear: + // docs/ISSUES.md #384 evidence trail: retail's server-side + // swear handler enforces a 2.0 m distance gate (lane C's + // own P1 table row) with NO client-side check and NO + // error/confirmation on failure — a silent drop is + // indistinguishable from "still in flight" without this. + // Kept permanently (not stripped) since #384 is open — + // whoever re-enables AllegianceGateEnabled needs this to + // rule distance in or out on the first re-run. + LogDistanceToPatron(view); Require( commands.Allegiance.Swear(view.Generation, _patronGuid), "swear allegiance"); @@ -1530,23 +1588,40 @@ internal sealed class FellowshipAllegianceRecruitBotPolicy : IHeadlessBotPolicy break; case Stage.WaitReconnectReseed: + if (AllegianceGateEnabled) + { + if (view.Fellowship.Snapshot.IsInFellowship + && view.Fellowship.Snapshot.MemberCount >= 2 + && view.Allegiance.TryGetPatron( + view.Lifecycle.PlayerGuid, + out RuntimeAllegianceMemberSnapshot reseededPatron) + && reseededPatron.CharacterId == _patronGuid) + { + Console.WriteLine( + "[fa6-recruit] RECONNECT-IDEMPOTENCE CONFIRMED: " + + "fellowship and allegiance both re-seeded on " + + "the RECRUIT side"); + Advance(view, Stage.Break); + return; + } + CheckStageTimeout( + view, + "fellowship and allegiance to re-seed after reconnect"); + break; + } if (view.Fellowship.Snapshot.IsInFellowship - && view.Fellowship.Snapshot.MemberCount >= 2 - && view.Allegiance.TryGetPatron( - view.Lifecycle.PlayerGuid, - out RuntimeAllegianceMemberSnapshot reseededPatron) - && reseededPatron.CharacterId == _patronGuid) + && view.Fellowship.Snapshot.MemberCount >= 2) { Console.WriteLine( "[fa6-recruit] RECONNECT-IDEMPOTENCE CONFIRMED: " - + "fellowship and allegiance both re-seeded on the " - + "RECRUIT side"); - Advance(view, Stage.Break); + + "fellowship re-seeded on the RECRUIT side " + + "(docs/ISSUES.md #384: allegiance half disabled)"); + Advance(view, Stage.WaitFellowshipDisbandCleared); return; } CheckStageTimeout( view, - "fellowship and allegiance to re-seed after reconnect"); + "fellowship to re-seed after reconnect"); break; case Stage.Break: @@ -1595,6 +1670,50 @@ internal sealed class FellowshipAllegianceRecruitBotPolicy : IHeadlessBotPolicy } } + /// + /// Permanent diagnostic for docs/ISSUES.md #384 — only reachable when + /// is . Proved + /// live (run6) that the two bots were 0.005 m apart at the moment of + /// swear, ruling distance out as the cause of ACE's silent non-response. + /// + private void LogDistanceToPatron(IGameRuntimeView view) + { + if (!_runtime.EntityObjects.Entities.TryGetActive( + view.Lifecycle.PlayerGuid, + out AcDream.Runtime.Entities.RuntimeEntityRecord self) + || self.Snapshot.Position is not { } selfPosition + || !_runtime.EntityObjects.Entities.TryGetActive( + _patronGuid, + out AcDream.Runtime.Entities.RuntimeEntityRecord patron) + || patron.Snapshot.Position is not { } patronPosition) + { + Console.WriteLine( + "[fa6-diag] LogDistanceToPatron: self or patron position " + + "unresolvable"); + return; + } + + static System.Numerics.Vector3 Absolute( + AcDream.Core.Net.Messages.CreateObject.ServerPosition position) + { + int landblockX = (int)((position.LandblockId >> 24) & 0xFFu); + int landblockY = (int)((position.LandblockId >> 16) & 0xFFu); + return new System.Numerics.Vector3( + position.PositionX + landblockX * 192f, + position.PositionY + landblockY * 192f, + position.PositionZ); + } + + float distance = System.Numerics.Vector3.Distance( + Absolute(selfPosition), + Absolute(patronPosition)); + Console.WriteLine( + $"[fa6-diag] distance self(0x{view.Lifecycle.PlayerGuid:X8})->" + + $"patron(0x{_patronGuid:X8}) = {distance:F3} m " + + $"selfCell=0x{selfPosition.LandblockId:X8} " + + $"patronCell=0x{patronPosition.LandblockId:X8}"); + } + private void Advance(IGameRuntimeView view, Stage next) { Console.WriteLine($"[fa6-recruit] stage {_stage} -> {next}");