using AcDream.Core.Net.Messages; using AcDream.Runtime.Gameplay; namespace AcDream.Runtime.Tests.Gameplay; /// /// Campaign FA slice FA2 (2026-08-12): lifecycle rules for /// — seeding from the unsolicited /// AllegianceUpdate push, the HasServerSeed-style latch, the /// monarch/patron/vassal walk, revision monotonicity, and the /// generation-reset ownership contract. /// /// /// 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 RuntimeGenerationReset stage (see /// for the sibling assertion that /// BOTH Fellowship and Allegiance clear at reset), and no longer seeds from /// 0x027C AllegianceInfoResponse — retail's own handler for that /// response is print-only over a stack-local profile. /// /// 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(() => state.ApplyUpdate(Update())); Assert.Throws( () => 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); } }