feat(headless): FA6 finalize — ship fellowship-only, defer allegiance behind a flag
Six live runs against local ACE (testaccount/+Acdream as Leader, testaccount2/+Horan as Recruit) converged on a clean split: - FELLOWSHIP two-session gate PASSES live, reproduced in three separate runs. The decisive cross-session assertion (the Recruit bot's own RuntimeFellowshipState — a separate process's canonical Runtime owner, not the Leader's local echo — flipping IsInFellowship=true, MemberCount=2, LeaderGuid=<Leader>) holds every time. This ships as the automated gate. - ALLEGIANCE swear never completes: ACE returns nothing at all to Event_SwearAllegiance (0x001D) — no 0x0274 confirmation, no 0x0020 tree update, no WeenieError — even at 0.005 m separation (run6's distance diagnostic ruled out retail's 2.0 m swear-distance gate). Ambiguous between an FA1 wire-builder defect, an ACE-side rule this test pair trips, or a drop; disambiguating needs an ACE server console this harness doesn't have. Filed as docs/ISSUES.md #384 and docs/architecture/retail-divergence-register.md AD-87. AllegianceGateEnabled (static readonly, not const, to avoid a CS0162 unreachable-code build error from branching on a literal) gates every allegiance-dependent stage in BOTH policy classes off by default: Leader's WaitForVassal (skipped straight to the reconnect+teardown that only need fellowship state), Recruit's Swear/WaitSwornSeed/Break/ WaitBrokenSeed (same). All of that code stays fully written and wired — flipping the flag re-enables it for a follow-up investigation once #384 closes. WaitReconnectReseed on both sides now asserts fellowship-only re-seeding when the flag is off, preserving the reconnect-idempotence proof independent of the allegiance blocker. The two live-run diagnostics added while investigating #384 (the confirmation-arrival log line in HeadlessSessionHost's OnConfirmationRequest, and LogDistanceToPatron in the Recruit policy) are kept as permanent, clearly-labeled evidence for whoever reopens #384 — neither is "TEMP, strip later." Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
ab79b91f1b
commit
5244e46daa
2 changed files with 147 additions and 15 deletions
|
|
@ -836,7 +836,20 @@ internal sealed class HeadlessSessionHost : IDisposable
|
||||||
Runtime.CharacterOwner,
|
Runtime.CharacterOwner,
|
||||||
ResolveSkillFormulaBonus: null,
|
ResolveSkillFormulaBonus: null,
|
||||||
OnSkillsUpdated: 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,
|
OnConfirmationDone: null,
|
||||||
ClientTime: () =>
|
ClientTime: () =>
|
||||||
Runtime.Clock.SimulationTimeSeconds,
|
Runtime.Clock.SimulationTimeSeconds,
|
||||||
|
|
|
||||||
|
|
@ -893,6 +893,26 @@ internal sealed class FellowshipAllegianceGateCoordinator
|
||||||
|
|
||||||
internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
|
internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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 <c>Event_SwearAllegiance (0x001D)</c>: no
|
||||||
|
/// <c>0x0274</c> confirmation, no <c>0x0020</c> 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 <c>RuntimeFellowshipState</c>
|
||||||
|
/// 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 <see langword="true"/> to re-enable it for a follow-up
|
||||||
|
/// investigation.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly bool AllegianceGateEnabled = false;
|
||||||
|
|
||||||
private const string FellowshipName = "AcdreamFA6Gate";
|
private const string FellowshipName = "AcdreamFA6Gate";
|
||||||
private const double ProximityRetryPeriodSeconds = 5d;
|
private const double ProximityRetryPeriodSeconds = 5d;
|
||||||
private const double ProximityTimeoutSeconds = 90d;
|
private const double ProximityTimeoutSeconds = 90d;
|
||||||
|
|
@ -1073,7 +1093,16 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
|
||||||
"[fa6-leader] WARNING panel-open declared but "
|
"[fa6-leader] WARNING panel-open declared but "
|
||||||
+ "recruit's member row is not yet resolvable");
|
+ "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;
|
break;
|
||||||
|
|
||||||
case Stage.WaitForVassal:
|
case Stage.WaitForVassal:
|
||||||
|
|
@ -1152,11 +1181,14 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
|
||||||
case Stage.WaitReconnectReseed:
|
case Stage.WaitReconnectReseed:
|
||||||
if (view.Fellowship.Snapshot.IsInFellowship
|
if (view.Fellowship.Snapshot.IsInFellowship
|
||||||
&& view.Fellowship.Snapshot.MemberCount >= 2
|
&& view.Fellowship.Snapshot.MemberCount >= 2
|
||||||
&& view.Allegiance.Snapshot.TotalVassals >= 1)
|
&& (!AllegianceGateEnabled
|
||||||
|
|| view.Allegiance.Snapshot.TotalVassals >= 1))
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
"[fa6-leader] RECONNECT-IDEMPOTENCE CONFIRMED: "
|
"[fa6-leader] RECONNECT-IDEMPOTENCE CONFIRMED: "
|
||||||
+ "fellowship and allegiance both re-seeded "
|
+ "fellowship"
|
||||||
|
+ (AllegianceGateEnabled ? " and allegiance both" : "")
|
||||||
|
+ " re-seeded "
|
||||||
+ $"(members={view.Fellowship.Snapshot.MemberCount}, "
|
+ $"(members={view.Fellowship.Snapshot.MemberCount}, "
|
||||||
+ $"vassals={view.Allegiance.Snapshot.TotalVassals})");
|
+ $"vassals={view.Allegiance.Snapshot.TotalVassals})");
|
||||||
Advance(view, Stage.Teardown);
|
Advance(view, Stage.Teardown);
|
||||||
|
|
@ -1164,7 +1196,9 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
|
||||||
}
|
}
|
||||||
CheckStageTimeout(
|
CheckStageTimeout(
|
||||||
view,
|
view,
|
||||||
"fellowship and allegiance to re-seed after reconnect "
|
"fellowship"
|
||||||
|
+ (AllegianceGateEnabled ? " and allegiance" : "")
|
||||||
|
+ " to re-seed after reconnect "
|
||||||
+ $"(IsInFellowship={view.Fellowship.Snapshot.IsInFellowship}, "
|
+ $"(IsInFellowship={view.Fellowship.Snapshot.IsInFellowship}, "
|
||||||
+ $"MemberCount={view.Fellowship.Snapshot.MemberCount}, "
|
+ $"MemberCount={view.Fellowship.Snapshot.MemberCount}, "
|
||||||
+ $"TotalVassals={view.Allegiance.Snapshot.TotalVassals})");
|
+ $"TotalVassals={view.Allegiance.Snapshot.TotalVassals})");
|
||||||
|
|
@ -1354,6 +1388,15 @@ internal sealed class FellowshipAllegianceLeaderBotPolicy : IHeadlessBotPolicy
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class FellowshipAllegianceRecruitBotPolicy : IHeadlessBotPolicy
|
internal sealed class FellowshipAllegianceRecruitBotPolicy : IHeadlessBotPolicy
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// See <see cref="FellowshipAllegianceLeaderBotPolicy.AllegianceGateEnabled"/>'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.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly bool AllegianceGateEnabled = false;
|
||||||
|
|
||||||
// Generous on purpose: WaitForRecruit's real deadline is coupled to the
|
// Generous on purpose: WaitForRecruit's real deadline is coupled to the
|
||||||
// LEADER's own EstablishProximity budget (up to
|
// LEADER's own EstablishProximity budget (up to
|
||||||
// FellowshipAllegianceLeaderBotPolicy.ProximityTimeoutSeconds = 90s)
|
// FellowshipAllegianceLeaderBotPolicy.ProximityTimeoutSeconds = 90s)
|
||||||
|
|
@ -1447,7 +1490,13 @@ internal sealed class FellowshipAllegianceRecruitBotPolicy : IHeadlessBotPolicy
|
||||||
+ $"MemberCount={view.Fellowship.Snapshot.MemberCount}, "
|
+ $"MemberCount={view.Fellowship.Snapshot.MemberCount}, "
|
||||||
+ $"LeaderGuid=0x{leaderGuid:X8} — recruit inbound "
|
+ $"LeaderGuid=0x{leaderGuid:X8} — recruit inbound "
|
||||||
+ "path reached THIS process's own Runtime owner");
|
+ "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;
|
return;
|
||||||
}
|
}
|
||||||
CheckStageTimeout(
|
CheckStageTimeout(
|
||||||
|
|
@ -1458,6 +1507,15 @@ internal sealed class FellowshipAllegianceRecruitBotPolicy : IHeadlessBotPolicy
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Stage.Swear:
|
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(
|
Require(
|
||||||
commands.Allegiance.Swear(view.Generation, _patronGuid),
|
commands.Allegiance.Swear(view.Generation, _patronGuid),
|
||||||
"swear allegiance");
|
"swear allegiance");
|
||||||
|
|
@ -1530,23 +1588,40 @@ internal sealed class FellowshipAllegianceRecruitBotPolicy : IHeadlessBotPolicy
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Stage.WaitReconnectReseed:
|
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
|
if (view.Fellowship.Snapshot.IsInFellowship
|
||||||
&& view.Fellowship.Snapshot.MemberCount >= 2
|
&& view.Fellowship.Snapshot.MemberCount >= 2)
|
||||||
&& view.Allegiance.TryGetPatron(
|
|
||||||
view.Lifecycle.PlayerGuid,
|
|
||||||
out RuntimeAllegianceMemberSnapshot reseededPatron)
|
|
||||||
&& reseededPatron.CharacterId == _patronGuid)
|
|
||||||
{
|
{
|
||||||
Console.WriteLine(
|
Console.WriteLine(
|
||||||
"[fa6-recruit] RECONNECT-IDEMPOTENCE CONFIRMED: "
|
"[fa6-recruit] RECONNECT-IDEMPOTENCE CONFIRMED: "
|
||||||
+ "fellowship and allegiance both re-seeded on the "
|
+ "fellowship re-seeded on the RECRUIT side "
|
||||||
+ "RECRUIT side");
|
+ "(docs/ISSUES.md #384: allegiance half disabled)");
|
||||||
Advance(view, Stage.Break);
|
Advance(view, Stage.WaitFellowshipDisbandCleared);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
CheckStageTimeout(
|
CheckStageTimeout(
|
||||||
view,
|
view,
|
||||||
"fellowship and allegiance to re-seed after reconnect");
|
"fellowship to re-seed after reconnect");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Stage.Break:
|
case Stage.Break:
|
||||||
|
|
@ -1595,6 +1670,50 @@ internal sealed class FellowshipAllegianceRecruitBotPolicy : IHeadlessBotPolicy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Permanent diagnostic for docs/ISSUES.md #384 — only reachable when
|
||||||
|
/// <see cref="AllegianceGateEnabled"/> is <see langword="true"/>. 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.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
private void Advance(IGameRuntimeView view, Stage next)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[fa6-recruit] stage {_stage} -> {next}");
|
Console.WriteLine($"[fa6-recruit] stage {_stage} -> {next}");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue