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>
This commit is contained in:
Erik 2026-08-12 01:31:54 +02:00
parent 1c40104896
commit 369729f06a
19 changed files with 1949 additions and 35 deletions

View file

@ -0,0 +1,185 @@
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));
}
}

View file

@ -0,0 +1,288 @@
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="RuntimeFellowshipState"/> — full-update assembly, incremental
/// upsert, self-vs-other quit/dismiss removal, disband clear, revision
/// monotonicity, and ownership convergence.
/// </summary>
public sealed class RuntimeFellowshipStateTests
{
private const uint SelfGuid = 0x50000001u;
private const uint LeaderGuid = 0x50000002u;
private const uint OtherGuid = 0x50000003u;
private static GameEvents.FellowMember Member(
uint guid,
string name = "Fellow",
uint currentHealth = 100u,
uint shareLoot = 0u) =>
new(
guid,
CpCache: 0u,
LumCache: 0u,
Level: 10u,
MaxHealth: 100u,
MaxStamina: 100u,
MaxMana: 100u,
CurrentHealth: currentHealth,
CurrentStamina: 100u,
CurrentMana: 100u,
ShareLoot: shareLoot,
Name: name);
private static GameEvents.FellowshipFullUpdate FullUpdate(
params GameEvents.FellowMember[] members) =>
new(
members,
Name: "The Fellows",
LeaderGuid: LeaderGuid,
ShareXp: true,
EvenXpSplit: false,
OpenFellow: true,
Locked: false,
Departed: []);
[Fact]
public void ApplyFullUpdate_ReplacesTheWholeRosterAndFlags()
{
var state = new RuntimeFellowshipState();
state.ApplyFullUpdate(FullUpdate(
Member(SelfGuid, "Self"),
Member(LeaderGuid, "Leader"),
Member(OtherGuid, "Other")));
RuntimeFellowshipSnapshot snapshot = state.View.Snapshot;
Assert.True(snapshot.IsInFellowship);
Assert.Equal("The Fellows", snapshot.Name);
Assert.Equal(LeaderGuid, snapshot.LeaderGuid);
Assert.True(snapshot.ShareXp);
Assert.False(snapshot.EvenXpSplit);
Assert.True(snapshot.IsOpen);
Assert.False(snapshot.Locked);
Assert.Equal(3, snapshot.MemberCount);
Assert.True(state.View.TryGetMember(OtherGuid, out RuntimeFellowMemberSnapshot other));
Assert.Equal("Other", other.Name);
// A SECOND full update REPLACES, not merges — the departed member
// must disappear.
state.ApplyFullUpdate(FullUpdate(Member(SelfGuid, "Self")));
Assert.Equal(1, state.View.Snapshot.MemberCount);
Assert.False(state.View.TryGetMember(OtherGuid, out _));
}
[Fact]
public void ApplyUpdateFellow_UpsertsExactlyOneMemberByGuid()
{
var state = new RuntimeFellowshipState();
state.ApplyFullUpdate(FullUpdate(
Member(SelfGuid, "Self", currentHealth: 100u),
Member(LeaderGuid, "Leader")));
state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow(
SelfGuid,
Member(SelfGuid, "Self", currentHealth: 42u),
UpdateType: 3u));
Assert.True(state.View.TryGetMember(SelfGuid, out RuntimeFellowMemberSnapshot self));
Assert.Equal(42u, self.CurrentHealth);
// The other member is untouched.
Assert.True(state.View.TryGetMember(LeaderGuid, out RuntimeFellowMemberSnapshot leader));
Assert.Equal("Leader", leader.Name);
Assert.Equal(2, state.View.Snapshot.MemberCount);
}
[Fact]
public void ApplyUpdateFellow_BeforeAnyFullUpdate_IsANoOp()
{
var state = new RuntimeFellowshipState();
long before = state.View.Snapshot.Revision;
state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow(
SelfGuid, Member(SelfGuid), UpdateType: 1u));
Assert.Equal(before, state.View.Snapshot.Revision);
Assert.False(state.View.Snapshot.IsInFellowship);
}
[Theory]
[InlineData(false)] // Quit
[InlineData(true)] // Dismiss
public void SelfRemoval_ClearsTheWholeSnapshot(bool viaDismiss)
{
var state = new RuntimeFellowshipState();
state.ApplyFullUpdate(FullUpdate(
Member(SelfGuid),
Member(LeaderGuid),
Member(OtherGuid)));
if (viaDismiss)
state.ApplyDismiss(SelfGuid, SelfGuid);
else
state.ApplyQuit(SelfGuid, SelfGuid);
RuntimeFellowshipSnapshot snapshot = state.View.Snapshot;
Assert.False(snapshot.IsInFellowship);
Assert.Equal(0, snapshot.MemberCount);
Assert.Equal(string.Empty, snapshot.Name);
Assert.Equal(0u, snapshot.LeaderGuid);
}
[Theory]
[InlineData(false)] // Quit
[InlineData(true)] // Dismiss
public void OtherMemberRemoval_RemovesOnlyThatMember(bool viaDismiss)
{
var state = new RuntimeFellowshipState();
state.ApplyFullUpdate(FullUpdate(
Member(SelfGuid),
Member(LeaderGuid),
Member(OtherGuid)));
if (viaDismiss)
state.ApplyDismiss(OtherGuid, SelfGuid);
else
state.ApplyQuit(OtherGuid, SelfGuid);
RuntimeFellowshipSnapshot snapshot = state.View.Snapshot;
Assert.True(snapshot.IsInFellowship);
Assert.Equal(2, snapshot.MemberCount);
Assert.False(state.View.TryGetMember(OtherGuid, out _));
Assert.True(state.View.TryGetMember(SelfGuid, out _));
}
[Fact]
public void ApplyDisband_AlwaysClears()
{
var state = new RuntimeFellowshipState();
state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid)));
state.ApplyDisband();
Assert.False(state.View.Snapshot.IsInFellowship);
Assert.Equal(0, state.View.Snapshot.MemberCount);
}
[Fact]
public void RequiresLeaderHandoffBeforeQuit_OnlyWhenSelfIsLeaderAndNotDisbanding()
{
var state = new RuntimeFellowshipState();
state.ApplyFullUpdate(FullUpdate(
Member(SelfGuid),
Member(LeaderGuid),
Member(OtherGuid)));
// Not the leader (SelfGuid isn't the leader here) — no handoff.
Assert.False(state.RequiresLeaderHandoffBeforeQuit(SelfGuid, disband: false, out _));
// The leader disbanding — no handoff needed (the fellowship dissolves).
Assert.False(state.RequiresLeaderHandoffBeforeQuit(LeaderGuid, disband: true, out _));
// The leader quitting WITHOUT disbanding — hand off to a non-leader fellow.
Assert.True(state.RequiresLeaderHandoffBeforeQuit(LeaderGuid, disband: false, out uint newLeader));
Assert.NotEqual(LeaderGuid, newLeader);
Assert.True(newLeader is SelfGuid or OtherGuid);
// Sole member (no one else to hand off to) — no handoff, even as leader.
var solo = new RuntimeFellowshipState();
solo.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
[Member(LeaderGuid)],
"Solo",
LeaderGuid,
ShareXp: false,
EvenXpSplit: false,
OpenFellow: false,
Locked: false,
Departed: []));
Assert.False(solo.RequiresLeaderHandoffBeforeQuit(LeaderGuid, disband: false, out _));
}
[Fact]
public void Revision_IsMonotonicAcrossEveryMutationKind()
{
var state = new RuntimeFellowshipState();
long r0 = state.View.Snapshot.Revision;
state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid)));
long r1 = state.View.Snapshot.Revision;
Assert.True(r1 > r0);
state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow(
SelfGuid, Member(SelfGuid, currentHealth: 1u), UpdateType: 3u));
long r2 = state.View.Snapshot.Revision;
Assert.True(r2 > r1);
state.ApplyQuit(LeaderGuid, SelfGuid);
long r3 = state.View.Snapshot.Revision;
Assert.True(r3 > r2);
state.ApplyDisband();
long r4 = state.View.Snapshot.Revision;
Assert.True(r4 > r3);
}
[Fact]
public void ShareLoot_ReadsTheRawWireBitAsNonZero_NeverEqualsOne()
{
// D5 (lane B §4.1): ACE encodes ShareLoot two mutually-inconsistent
// ways (0x10 full update, <<1 incremental) — the only safe read is
// != 0.
var state = new RuntimeFellowshipState();
state.ApplyFullUpdate(FullUpdate(Member(SelfGuid, shareLoot: 0x10u)));
Assert.True(state.View.TryGetMember(SelfGuid, out RuntimeFellowMemberSnapshot member));
Assert.True(member.ShareLoot);
}
[Fact]
public void CaptureOwnership_ConvergesOnlyAfterDisposeAndEmptyRoster()
{
var state = new RuntimeFellowshipState();
state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid)));
Assert.False(state.CaptureOwnership().IsConverged);
state.Dispose();
RuntimeFellowshipOwnershipSnapshot retired = state.CaptureOwnership();
Assert.True(retired.IsConverged);
Assert.True(retired.IsDisposed);
Assert.False(retired.IsInFellowship);
Assert.Equal(0, retired.MemberCount);
}
[Fact]
public void ResetSession_ClearsSessionScopedRoster_MatchingTheExternalContainerPrecedent()
{
// D2: fellowship is session-scoped — cleared at generation reset
// exactly like RuntimeInventoryState.ResetExternalContainer, unlike
// RuntimeAllegianceState which survives reconnect.
var state = new RuntimeFellowshipState();
state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid)));
state.ResetSession();
RuntimeFellowshipSnapshot snapshot = state.View.Snapshot;
Assert.False(snapshot.IsInFellowship);
Assert.Equal(0, snapshot.MemberCount);
Assert.False(state.IsDisposed);
}
[Fact]
public void MutatingAfterDispose_Throws()
{
var state = new RuntimeFellowshipState();
state.Dispose();
Assert.Throws<ObjectDisposedException>(
() => state.ApplyFullUpdate(FullUpdate(Member(SelfGuid))));
Assert.Throws<ObjectDisposedException>(
() => state.ApplyQuit(SelfGuid, SelfGuid));
Assert.Throws<ObjectDisposedException>(state.ApplyDisband);
Assert.Throws<ObjectDisposedException>(state.ResetSession);
}
}

View file

@ -18,6 +18,8 @@ public sealed class RuntimeGameplayOwnershipTests
var communication = new RuntimeCommunicationState();
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
var movement = new RuntimeLocalPlayerMovementState();
var fellowship = new RuntimeFellowshipState();
var allegiance = new RuntimeAllegianceState();
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
inventory.Transactions.IncrementBusyCount();
@ -34,7 +36,9 @@ public sealed class RuntimeGameplayOwnershipTests
character,
communication,
actions,
movement);
movement,
fellowship,
allegiance);
Assert.False(populated.IsConverged);
movement.Dispose();
@ -42,6 +46,8 @@ public sealed class RuntimeGameplayOwnershipTests
communication.Dispose();
character.Dispose();
inventory.Dispose();
fellowship.Dispose();
allegiance.Dispose();
entities.Dispose();
RuntimeSimulationOwnershipSnapshot retired =
@ -51,7 +57,9 @@ public sealed class RuntimeGameplayOwnershipTests
character,
communication,
actions,
movement);
movement,
fellowship,
allegiance);
Assert.True(retired.IsConverged);
Assert.True(retired.EntityObjects.IsDisposed);
Assert.True(retired.Physics.IsDisposed);
@ -66,6 +74,8 @@ public sealed class RuntimeGameplayOwnershipTests
var communication = new RuntimeCommunicationState();
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
var movement = new RuntimeLocalPlayerMovementState();
var fellowship = new RuntimeFellowshipState();
var allegiance = new RuntimeAllegianceState();
movement.Execute(RuntimeMovementCommand.ToggleRunLock);
inventory.Shortcuts.Changed += static () => { };
inventory.Shortcuts.Load([new ShortcutEntry(1, 2u, 3u)]);
@ -127,7 +137,9 @@ public sealed class RuntimeGameplayOwnershipTests
character,
communication,
actions,
movement);
movement,
fellowship,
allegiance);
Assert.False(populated.IsConverged);
Assert.Equal(1, populated.Inventory.ShortcutCount);
@ -147,6 +159,8 @@ public sealed class RuntimeGameplayOwnershipTests
communication.Dispose();
character.Dispose();
inventory.Dispose();
fellowship.Dispose();
allegiance.Dispose();
subscription.Dispose();
RuntimeGameplayOwnershipSnapshot retired =
@ -155,7 +169,9 @@ public sealed class RuntimeGameplayOwnershipTests
character,
communication,
actions,
movement);
movement,
fellowship,
allegiance);
Assert.True(retired.IsConverged);
Assert.Equal(0, retired.Inventory.ShortcutSubscriberCount);
@ -173,6 +189,8 @@ public sealed class RuntimeGameplayOwnershipTests
var communication = new RuntimeCommunicationState();
var actions = RuntimeActionTestFactory.Create(inventory.Transactions);
var movement = new RuntimeLocalPlayerMovementState();
var fellowship = new RuntimeFellowshipState();
var allegiance = new RuntimeAllegianceState();
inventory.ExternalContainers.RequestOpen(0x70000001u);
inventory.ExternalContainers.ApplyViewContents(0x70000001u);
@ -195,6 +213,8 @@ public sealed class RuntimeGameplayOwnershipTests
Assert.Throws<AggregateException>(inventory.Dispose);
Assert.Throws<AggregateException>(character.Dispose);
communication.Dispose();
fellowship.Dispose();
allegiance.Dispose();
RuntimeGameplayOwnershipSnapshot retired =
RuntimeGameplayOwnership.Capture(
@ -202,7 +222,9 @@ public sealed class RuntimeGameplayOwnershipTests
character,
communication,
actions,
movement);
movement,
fellowship,
allegiance);
Assert.True(retired.IsConverged);
Assert.Equal(1, retired.Communication.DispatchFailureCount);