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}");