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

@ -490,6 +490,210 @@ public sealed class DirectGameRuntimeCommandAdapterTests
runtime.Dispose();
}
// ── Fellowship / Allegiance (Campaign FA slice FA2, 2026-08-12) ────────
private static uint ReadOpcode(byte[] gameAction) =>
System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(
gameAction.AsSpan(8));
[Fact]
public void Fellowship_Create_SendsTheCreateOpcodeWithNameAndShareXp()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
RuntimeCommandResult result = adapter.Fellowship.Create(
runtime.Generation, "TestFellowship", shareXp: true);
Assert.True(result.Accepted);
byte[] sent = Assert.Single(gameActions);
Assert.Equal(SocialActions.FellowshipCreateOpcode, ReadOpcode(sent));
runtime.Dispose();
}
[Fact]
public void Fellowship_Create_RejectsAnEmptyName_WithoutSendingAnything()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
RuntimeCommandResult result = adapter.Fellowship.Create(
runtime.Generation, string.Empty, shareXp: false);
Assert.Equal(RuntimeCommandStatus.Rejected, result.Status);
Assert.Empty(gameActions);
runtime.Dispose();
}
[Fact]
public void Fellowship_RecruitAndDismiss_SendTheirOpcodesWithTheTargetGuid()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Fellowship.Recruit(runtime.Generation, 0x50000005u);
adapter.Fellowship.Dismiss(runtime.Generation, 0x50000006u);
Assert.Equal(2, gameActions.Count);
Assert.Equal(SocialActions.FellowshipRecruitOpcode, ReadOpcode(gameActions[0]));
Assert.Equal(SocialActions.FellowshipDismissOpcode, ReadOpcode(gameActions[1]));
runtime.Dispose();
}
[Fact]
public void Fellowship_Quit_NotLeader_SendsOnlyTheQuitOpcode()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
SeedFellowship(runtime, leader: 0x50000002u, self: 0x50000001u, others: 0x50000003u);
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Fellowship.Quit(runtime.Generation, disband: false);
byte[] sent = Assert.Single(gameActions);
Assert.Equal(SocialActions.FellowshipQuitOpcode, ReadOpcode(sent));
runtime.Dispose();
}
[Fact]
public void Fellowship_Quit_LeaderWithoutDisbanding_SendsAssignNewLeaderBeforeQuit()
{
// Retail's Quit-button leader hand-off (lane B §2.5/§3.6).
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
SeedFellowship(runtime, leader: 0x50000001u, self: 0x50000001u, others: 0x50000003u);
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Fellowship.Quit(runtime.Generation, disband: false);
Assert.Equal(2, gameActions.Count);
Assert.Equal(SocialActions.FellowshipAssignNewLeaderOpcode, ReadOpcode(gameActions[0]));
Assert.Equal(SocialActions.FellowshipQuitOpcode, ReadOpcode(gameActions[1]));
runtime.Dispose();
}
[Fact]
public void Fellowship_Quit_LeaderDisbanding_SendsOnlyTheQuitOpcode()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
SeedFellowship(runtime, leader: 0x50000001u, self: 0x50000001u, others: 0x50000003u);
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Fellowship.Quit(runtime.Generation, disband: true);
byte[] sent = Assert.Single(gameActions);
Assert.Equal(SocialActions.FellowshipQuitOpcode, ReadOpcode(sent));
runtime.Dispose();
}
[Fact]
public void Fellowship_AssignLeaderSetOpenSetPanelOpen_SendTheirOpcodes()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Fellowship.AssignLeader(runtime.Generation, 0x50000009u);
adapter.Fellowship.SetOpen(runtime.Generation, isOpen: true);
adapter.Fellowship.SetPanelOpen(runtime.Generation, panelOpen: true);
Assert.Equal(3, gameActions.Count);
Assert.Equal(SocialActions.FellowshipAssignNewLeaderOpcode, ReadOpcode(gameActions[0]));
Assert.Equal(SocialActions.FellowshipChangeOpennessOpcode, ReadOpcode(gameActions[1]));
Assert.Equal(SocialActions.FellowshipUpdateRequestOpcode, ReadOpcode(gameActions[2]));
runtime.Dispose();
}
[Fact]
public void Allegiance_SwearBreakKick_SendTheirOpcodesWithTheTargetGuid()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Allegiance.Swear(runtime.Generation, 0x50000010u);
adapter.Allegiance.Break(runtime.Generation, 0x50000011u);
adapter.Allegiance.Kick(runtime.Generation, 0x50000012u);
Assert.Equal(3, gameActions.Count);
Assert.Equal(AllegianceRequests.SwearOpcode, ReadOpcode(gameActions[0]));
Assert.Equal(AllegianceRequests.BreakOpcode, ReadOpcode(gameActions[1]));
Assert.Equal(AllegianceRequests.BreakOpcode, ReadOpcode(gameActions[2]));
runtime.Dispose();
}
[Fact]
public void Allegiance_RequestInfoAndSetUpdateSubscription_SendTheirOpcodes()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Allegiance.RequestInfo(runtime.Generation, string.Empty);
adapter.Allegiance.SetUpdateSubscription(runtime.Generation, on: true);
Assert.Equal(2, gameActions.Count);
Assert.Equal(ClientCommandRequests.AllegianceInfoRequestOpcode, ReadOpcode(gameActions[0]));
Assert.Equal(AllegianceRequests.AllegianceUpdateRequestOpcode, ReadOpcode(gameActions[1]));
runtime.Dispose();
}
[Fact]
public void Fellowship_And_Allegiance_Commands_RejectAStaleGeneration()
{
(GameRuntime runtime, DirectGameRuntimeCommandAdapter adapter, FixtureSessionOperations operations) =
CreateStartedHarness();
RuntimeGenerationToken stale = runtime.Generation;
var gameActions = new List<byte[]>();
operations.Sessions[^1].GameActionCapture = body => gameActions.Add(body);
adapter.Session.Reconnect(runtime.Generation);
RuntimeCommandResult fellowshipResult =
adapter.Fellowship.Create(stale, "Stale", false);
RuntimeCommandResult allegianceResult =
adapter.Allegiance.Swear(stale, 0x50000001u);
Assert.Equal(RuntimeCommandStatus.StaleGeneration, fellowshipResult.Status);
Assert.Equal(RuntimeCommandStatus.StaleGeneration, allegianceResult.Status);
Assert.Empty(gameActions);
runtime.Dispose();
}
private static void SeedFellowship(
GameRuntime runtime,
uint leader,
uint self,
uint others)
{
GameEvents.FellowMember Member(uint guid, string name) => new(
guid, 0u, 0u, 1u, 100u, 100u, 100u, 100u, 100u, 100u, 0u, name);
runtime.FellowshipOwner.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
[Member(leader, "Leader"), Member(self, "Self"), Member(others, "Other")],
"The Fellows",
leader,
ShareXp: true,
EvenXpSplit: false,
OpenFellow: true,
Locked: false,
Departed: []));
}
private sealed class ManualTimeProvider : TimeProvider
{
private DateTimeOffset _now = new(2026, 8, 11, 0, 0, 0, TimeSpan.Zero);