Two MUST-FIX findings from the FA2 mechanism/blast reviews (docs/research/2026-08-12-fa2-review-mechanism.md, docs/research/2026-08-12-fa2-review-blast.md): MF-1 (mechanism) -- RuntimeAllegianceState survived a generation reset, contradicting retail (ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0 tail-calls AllegianceProfile::Clear at the same boundary Fellowship already clears at), contradicting the precedent it cited (RuntimeCharacterOptionsState.ResetSession clears-and-relatches, it does not persist), and pinned by a test asserting the wrong behavior. Fixed: RuntimeAllegianceState.ResetSession() clears the profile and drops HasServerSeed; a new RuntimeGenerationResetStage.Allegiance stage runs it on every generation reset, mirroring RuntimeFellowshipState exactly. RuntimeGenerationResetTests' FellowshipClearsAtResetButAllegianceSurvivesReconnect inverted to FellowshipAndAllegianceBothClearAtGenerationReset. MF-2 (mechanism) / blast MF-2 -- 0x027C AllegianceInfoResponse fed the Runtime allegiance owner (self-gated). Retail's own handler for 0x027C (CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent @0x006a7470) unpacks into a stack-local profile destroyed on return; the consumer (Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0) only prints AddTextToScroll lines. Retail's panel is fed exclusively by 0x0020 AllegianceUpdate. The removed seeding also fabricated RuntimeAllegianceSnapshot.Rank (0x027C carries no rank field) on any client whose first allegiance message was a self @allegiance info query. Fixed: dropped ApplyInfoResponseSelf, the onAllegianceInfoResponseSelf delegate hole, and the self-gate; 0x027C is text-only again, matching retail and the pre-FA2 shape. Also covers blast SHOULD-FIX 1 in the same edit to LiveSessionEventRouter.cs: the fellowship/allegiance delegate holes are now passed conditionally on the owner being supplied, so GameEventDispatcher.GetUnhandledCount reads correctly for callers without an owner (bare-ChatLog tests, a future partial host) instead of silently reading 0 for 9 event types whose parse result was discarded. RuntimeAllegianceState.cs and the two owners' Apply* mutators also move their ObjectDisposedException.ThrowIf checks inside the lock they already take (mechanism SHOULD-FIX 2) -- the prior check-then-lock shape let an inbound event on the decode thread race Dispose on the host thread and repopulate state after _disposed = true, permanently falsifying CaptureOwnership().IsConverged at teardown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
219 lines
7.9 KiB
C#
219 lines
7.9 KiB
C#
using AcDream.Core.Net.Messages;
|
|
using AcDream.Runtime.Gameplay;
|
|
|
|
namespace AcDream.Runtime.Tests.Gameplay;
|
|
|
|
/// <summary>
|
|
/// Campaign FA slice FA2 (2026-08-12): lifecycle rules for
|
|
/// <see cref="RuntimeAllegianceState"/> — seeding from the unsolicited
|
|
/// <c>AllegianceUpdate</c> push, the <c>HasServerSeed</c>-style latch, the
|
|
/// monarch/patron/vassal walk, revision monotonicity, and the
|
|
/// generation-reset ownership contract.
|
|
///
|
|
/// <para>
|
|
/// FA2 fix-round correction (2026-08-12,
|
|
/// docs/research/2026-08-12-fa2-review-mechanism.md MF-1/MF-2): this owner
|
|
/// is now a <c>RuntimeGenerationReset</c> stage (see
|
|
/// <see cref="RuntimeGenerationResetTests"/> for the sibling assertion that
|
|
/// BOTH Fellowship and Allegiance clear at reset), and no longer seeds from
|
|
/// <c>0x027C AllegianceInfoResponse</c> — retail's own handler for that
|
|
/// response is print-only over a stack-local profile.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class RuntimeAllegianceStateTests
|
|
{
|
|
private const uint MonarchGuid = 0x50000001u;
|
|
private const uint PatronGuid = 0x50000002u;
|
|
private const uint SelfGuid = 0x50000003u;
|
|
private const uint VassalAGuid = 0x50000004u;
|
|
private const uint VassalBGuid = 0x50000005u;
|
|
|
|
private static ClientCommandResponses.AllegianceMemberRecord Record(
|
|
uint id,
|
|
uint parent,
|
|
string name,
|
|
bool loggedIn = true) =>
|
|
new(id, parent, loggedIn, name);
|
|
|
|
private static ClientCommandResponses.AllegianceUpdate Update(uint rank = 3u) =>
|
|
new(
|
|
rank,
|
|
TotalMembers: 5u,
|
|
TotalVassals: 3u,
|
|
RecordCount: 5,
|
|
AllegianceName: "The Order",
|
|
Monarch: Record(MonarchGuid, 0u, "Monarch"),
|
|
Records:
|
|
[
|
|
Record(PatronGuid, MonarchGuid, "Patron"),
|
|
Record(SelfGuid, PatronGuid, "Self"),
|
|
Record(VassalAGuid, SelfGuid, "VassalA"),
|
|
Record(VassalBGuid, SelfGuid, "VassalB"),
|
|
]);
|
|
|
|
[Fact]
|
|
public void ApplyUpdate_SeedsTheProfileAndArmsHasServerSeed()
|
|
{
|
|
var state = new RuntimeAllegianceState();
|
|
Assert.False(state.HasServerSeed);
|
|
|
|
state.ApplyUpdate(Update(rank: 7u));
|
|
|
|
RuntimeAllegianceSnapshot snapshot = state.View.Snapshot;
|
|
Assert.True(state.HasServerSeed);
|
|
Assert.True(snapshot.HasServerSeed);
|
|
Assert.True(snapshot.HasProfile);
|
|
Assert.Equal(7u, snapshot.Rank);
|
|
Assert.Equal("The Order", snapshot.AllegianceName);
|
|
Assert.Equal(MonarchGuid, snapshot.MonarchGuid);
|
|
Assert.True(snapshot.HasMonarch);
|
|
Assert.Equal(4, snapshot.RecordCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryGetMonarchPatronAndVassals_WalkTheFlatRecordList()
|
|
{
|
|
var state = new RuntimeAllegianceState();
|
|
state.ApplyUpdate(Update());
|
|
|
|
Assert.True(state.View.TryGetMonarch(out RuntimeAllegianceMemberSnapshot monarch));
|
|
Assert.Equal("Monarch", monarch.Name);
|
|
|
|
Assert.True(state.View.TryGetPatron(SelfGuid, out RuntimeAllegianceMemberSnapshot patron));
|
|
Assert.Equal(PatronGuid, patron.CharacterId);
|
|
|
|
// The monarch has no patron.
|
|
Assert.False(state.View.TryGetPatron(MonarchGuid, out _));
|
|
|
|
var vassals = state.View.GetVassals(SelfGuid).ToList();
|
|
Assert.Equal(2, vassals.Count);
|
|
Assert.Contains(vassals, v => v.CharacterId == VassalAGuid);
|
|
Assert.Contains(vassals, v => v.CharacterId == VassalBGuid);
|
|
|
|
Assert.True(state.View.TryGetMember(VassalAGuid, out RuntimeAllegianceMemberSnapshot vassalA));
|
|
Assert.Equal("VassalA", vassalA.Name);
|
|
Assert.False(state.View.TryGetMember(0x99999999u, out _));
|
|
}
|
|
|
|
[Fact]
|
|
public void GetVassals_VisitsSiblingsInReverseWireOrder()
|
|
{
|
|
// Lane C §4.4 point 3: each new record is PREPENDED to its parent's
|
|
// vassal list on assembly — the record parsed LAST under a given
|
|
// parent renders FIRST.
|
|
var state = new RuntimeAllegianceState();
|
|
state.ApplyUpdate(Update());
|
|
|
|
var vassals = state.View.GetVassals(SelfGuid).ToList();
|
|
Assert.Equal(VassalBGuid, vassals[0].CharacterId);
|
|
Assert.Equal(VassalAGuid, vassals[1].CharacterId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Revision_IsMonotonicAcrossEveryEventKind()
|
|
{
|
|
var state = new RuntimeAllegianceState();
|
|
long r0 = state.View.Snapshot.Revision;
|
|
|
|
state.ApplyUpdate(Update());
|
|
long r1 = state.View.Snapshot.Revision;
|
|
Assert.True(r1 > r0);
|
|
|
|
state.ApplyLoginNotification(VassalAGuid, isLoggedIn: true);
|
|
long r2 = state.View.Snapshot.Revision;
|
|
Assert.True(r2 > r1);
|
|
|
|
state.ApplyUpdateDone(weenieError: 0u);
|
|
long r3 = state.View.Snapshot.Revision;
|
|
Assert.True(r3 > r2);
|
|
|
|
state.ApplyUpdateAborted(weenieError: 0u);
|
|
long r4 = state.View.Snapshot.Revision;
|
|
Assert.True(r4 > r3);
|
|
}
|
|
|
|
[Fact]
|
|
public void CaptureOwnership_ConvergesOnlyAfterDispose()
|
|
{
|
|
var state = new RuntimeAllegianceState();
|
|
state.ApplyUpdate(Update());
|
|
|
|
Assert.False(state.CaptureOwnership().IsConverged);
|
|
|
|
state.Dispose();
|
|
|
|
RuntimeAllegianceOwnershipSnapshot retired = state.CaptureOwnership();
|
|
Assert.True(retired.IsConverged);
|
|
Assert.True(retired.IsDisposed);
|
|
Assert.False(retired.HasProfile);
|
|
Assert.Equal(0, retired.RecordCount);
|
|
Assert.False(state.HasServerSeed);
|
|
}
|
|
|
|
[Fact]
|
|
public void MutatingAfterDispose_Throws()
|
|
{
|
|
var state = new RuntimeAllegianceState();
|
|
state.Dispose();
|
|
|
|
Assert.Throws<ObjectDisposedException>(() => state.ApplyUpdate(Update()));
|
|
Assert.Throws<ObjectDisposedException>(
|
|
() => state.ApplyLoginNotification(SelfGuid, true));
|
|
}
|
|
|
|
[Fact]
|
|
public void ResetSession_ClearsProfileAndDropsHasServerSeed_MatchingOnEndCharacterSession()
|
|
{
|
|
// MF-1 (docs/research/2026-08-12-fa2-review-mechanism.md): retail's
|
|
// ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0
|
|
// tail-calls AllegianceProfile::Clear at exactly this boundary —
|
|
// the fix-round replaces the prior (inverted) "survives reconnect"
|
|
// test with this one.
|
|
var state = new RuntimeAllegianceState();
|
|
state.ApplyUpdate(Update(rank: 7u));
|
|
Assert.True(state.HasServerSeed);
|
|
Assert.True(state.View.Snapshot.HasProfile);
|
|
|
|
state.ResetSession();
|
|
|
|
RuntimeAllegianceSnapshot snapshot = state.View.Snapshot;
|
|
Assert.False(state.HasServerSeed);
|
|
Assert.False(snapshot.HasServerSeed);
|
|
Assert.False(snapshot.HasProfile);
|
|
Assert.Equal(0u, snapshot.Rank);
|
|
Assert.Equal(string.Empty, snapshot.AllegianceName);
|
|
Assert.Equal(0u, snapshot.MonarchGuid);
|
|
Assert.Equal(0, snapshot.RecordCount);
|
|
Assert.False(state.IsDisposed);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResetSession_OnAnUnseededOwner_IsANoOpThatDoesNotBumpRevision()
|
|
{
|
|
var state = new RuntimeAllegianceState();
|
|
long before = state.View.Snapshot.Revision;
|
|
|
|
state.ResetSession();
|
|
|
|
Assert.Equal(before, state.View.Snapshot.Revision);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResetSession_AfterDispose_IsANoOpAndDoesNotThrow()
|
|
{
|
|
// Blast SF-5: RuntimeFellowshipState.ResetSession has no disposed
|
|
// guard (matching RuntimeInventoryState.ResetExternalContainer /
|
|
// RuntimeCommunicationState.ResetNegotiatedChannels) because the
|
|
// reset transaction is retryable and disposal is terminal — a
|
|
// throwing guard could never converge on retry. Allegiance's new
|
|
// ResetSession matches that shape.
|
|
var state = new RuntimeAllegianceState();
|
|
state.ApplyUpdate(Update());
|
|
state.Dispose();
|
|
|
|
Exception? thrown = Xunit.Record.Exception(state.ResetSession);
|
|
|
|
Assert.Null(thrown);
|
|
Assert.False(state.View.Snapshot.HasProfile);
|
|
}
|
|
}
|