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

@ -199,6 +199,50 @@ public sealed class RuntimeGenerationResetTests
Assert.False(runtime.GenerationReset.CaptureSnapshot().IsActive);
}
[Fact]
public void FellowshipClearsAtResetButAllegianceSurvivesReconnect()
{
// Campaign FA slice FA2 (2026-08-12), D2: fellowship is
// session-scoped (cleared at every generation reset, matching the
// ExternalContainer precedent); allegiance is NOT a reset stage at
// all — it survives reconnect exactly like a real disconnect does
// not sever your character's allegiance membership.
using var runtime = Create();
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
runtime.FellowshipOwner.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
[new GameEvents.FellowMember(
0x50000001u, 0u, 0u, 1u, 100u, 100u, 100u, 100u, 100u, 100u, 0u, "Self")],
"The Fellows",
LeaderGuid: 0x50000001u,
ShareXp: true,
EvenXpSplit: false,
OpenFellow: true,
Locked: false,
Departed: []));
runtime.AllegianceOwner.ApplyUpdate(new ClientCommandResponses.AllegianceUpdate(
Rank: 2u,
TotalMembers: 1u,
TotalVassals: 0u,
RecordCount: 1,
AllegianceName: "The Order",
Monarch: new ClientCommandResponses.AllegianceMemberRecord(
0x50000001u, 0u, true, "Self"),
Records: []));
Assert.True(runtime.Fellowship.Snapshot.IsInFellowship);
Assert.True(runtime.Allegiance.Snapshot.HasProfile);
var host = new RecordingResetHost(runtime);
runtime.ResetGeneration(new RuntimeGenerationToken(3), host);
Assert.False(runtime.Fellowship.Snapshot.IsInFellowship);
Assert.Equal(0, runtime.Fellowship.Snapshot.MemberCount);
// Allegiance is untouched by the reset — the data survives.
Assert.True(runtime.Allegiance.Snapshot.HasProfile);
Assert.True(runtime.AllegianceOwner.HasServerSeed);
Assert.Equal("The Order", runtime.Allegiance.Snapshot.AllegianceName);
}
private static GameRuntime Create()
{
var operations = new Operations();