acdream/tests/AcDream.Runtime.Tests/Gameplay/RuntimeAllegianceStateTests.cs
Erik 369729f06a feat(runtime): FA2 -- RuntimeFellowshipState + RuntimeAllegianceState sibling J-owners
Two new sibling Runtime owners under GameRuntime per the Slice-J
pattern (D2): RuntimeFellowshipState is session-scoped (a new
RuntimeGenerationResetStage.Fellowship clears it at every generation
reset, matching the ExternalContainer precedent); RuntimeAllegianceState
survives reconnect behind a HasServerSeed-style one-way latch and
participates in NO reset stage (its data persists like a real
disconnect does not sever allegiance membership).

Fellowship: full-update REPLACE, incremental-fellow UPSERT, self-vs-
other quit/dismiss removal (self clears the whole snapshot), disband
clear, and retail's leader hand-off rule for the Quit button
(RequiresLeaderHandoffBeforeQuit -- the current leader quitting WITHOUT
disbanding must send 0x0290 AssignNewLeader before 0x00A3, lane B
§2.5/§3.6).

Allegiance: seeded by AllegianceUpdate (0x0020, always self) and,
self-gated on TargetGuid == playerGuid(), by AllegianceInfoResponse
(0x027C); wraps the FA1-assembled flat AllegianceMemberRecord list
directly (AllegianceTree was deleted at FA1 -- nothing left to wrap).

Both apply the full 8-edit J-owner template: construction + fault
points + Owner/View properties + CaptureOwnership + a new
GameRuntimeTeardownStage pair (FellowshipDisposed/AllegianceDisposed,
stage count 11->13) + RuntimeGameplayOwnershipSnapshot inclusion +
RuntimeStateCheckpoint/trace fields. IRuntimeFellowshipCommands/
IRuntimeAllegianceCommands added to IGameRuntimeCommands and
implemented on DirectGameRuntimeCommandAdapter. No IRuntimeEventObserver
member added (D2) -- consumers poll Snapshot.Revision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 01:31:54 +02:00

185 lines
6.5 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 and from a self-gated
/// <c>AllegianceInfoResponse</c>, the <c>HasServerSeed</c>-style latch, the
/// monarch/patron/vassal walk, revision monotonicity, and the "survives
/// reconnect" ownership contract (no <c>RuntimeGenerationReset</c> stage —
/// see <see cref="RuntimeGenerationResetTests"/> for the sibling assertion
/// that Fellowship IS a reset stage and Allegiance is not).
/// </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 ApplyInfoResponseSelf_SeedsTheProfileButLeavesRankUntouched()
{
var state = new RuntimeAllegianceState();
state.ApplyUpdate(Update(rank: 9u));
var response = new ClientCommandResponses.AllegianceInfoResponse(
TargetGuid: SelfGuid,
TotalMembers: 5u,
TotalVassals: 3u,
RecordCount: 5,
AllegianceName: "The Order",
Monarch: Record(MonarchGuid, 0u, "Monarch"),
Records:
[
Record(PatronGuid, MonarchGuid, "Patron"),
Record(SelfGuid, PatronGuid, "Self"),
]);
state.ApplyInfoResponseSelf(response);
RuntimeAllegianceSnapshot snapshot = state.View.Snapshot;
Assert.True(snapshot.HasProfile);
// AllegianceInfoResponse carries no rank field on the wire — the
// last known rank from the earlier AllegianceUpdate is retained.
Assert.Equal(9u, snapshot.Rank);
Assert.Equal(2, 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));
}
}