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:
parent
1c40104896
commit
369729f06a
19 changed files with 1949 additions and 35 deletions
|
|
@ -230,6 +230,8 @@ public sealed class GameRuntimeContractTests
|
|||
typeof(RuntimeCombatTargetState),
|
||||
typeof(RuntimeCombatModeState),
|
||||
typeof(RuntimeSpellCastState),
|
||||
typeof(RuntimeFellowshipState),
|
||||
typeof(RuntimeAllegianceState),
|
||||
];
|
||||
|
||||
foreach (Type owner in owners)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ public sealed class GameRuntimeTests
|
|||
Assert.Same(runtime.CharacterOwner.View, runtime.Character);
|
||||
Assert.Same(runtime.CommunicationOwner.View, runtime.Chat);
|
||||
Assert.Same(runtime.CommunicationOwner.SocialView, runtime.Social);
|
||||
Assert.Same(runtime.FellowshipOwner.View, runtime.Fellowship);
|
||||
Assert.Same(runtime.AllegianceOwner.View, runtime.Allegiance);
|
||||
Assert.Same(runtime.ActionOwner.View, runtime.Actions);
|
||||
Assert.Same(runtime.MovementOwner.View, runtime.Movement);
|
||||
Assert.Same(runtime.EnvironmentOwner, runtime.Environment);
|
||||
|
|
@ -110,6 +112,8 @@ public sealed class GameRuntimeTests
|
|||
[InlineData((int)GameRuntimeConstructionPoint.InventoryCreated)]
|
||||
[InlineData((int)GameRuntimeConstructionPoint.CharacterCreated)]
|
||||
[InlineData((int)GameRuntimeConstructionPoint.CommunicationCreated)]
|
||||
[InlineData((int)GameRuntimeConstructionPoint.FellowshipCreated)]
|
||||
[InlineData((int)GameRuntimeConstructionPoint.AllegianceCreated)]
|
||||
[InlineData((int)GameRuntimeConstructionPoint.MovementCreated)]
|
||||
[InlineData((int)GameRuntimeConstructionPoint.ActionsCreated)]
|
||||
[InlineData((int)GameRuntimeConstructionPoint.EnvironmentCreated)]
|
||||
|
|
@ -147,6 +151,10 @@ public sealed class GameRuntimeTests
|
|||
Assert.True(captured.Inventory.CaptureOwnership().IsConverged);
|
||||
if (captured.Communication is not null)
|
||||
Assert.True(captured.Communication.CaptureOwnership().IsConverged);
|
||||
if (captured.Fellowship is not null)
|
||||
Assert.True(captured.Fellowship.CaptureOwnership().IsConverged);
|
||||
if (captured.Allegiance is not null)
|
||||
Assert.True(captured.Allegiance.CaptureOwnership().IsConverged);
|
||||
if (captured.EntityObjects is not null)
|
||||
{
|
||||
Assert.True(captured.EntityObjects.CaptureOwnership().IsConverged);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue