IRuntimeFellowshipView.GetMembers() gives the fellowship panel a way to enumerate the whole roster (TryGetMember alone needs the guid first, which a UI roster build doesn't have yet). Implemented on RuntimeFellowshipState.FellowshipView as a materialized snapshot under the same lock every other read there uses. SelectionChangeSource.Social covers a fellowship-roster row click (gmFellowshipUI's list-selection arm calls the same ACCWeenieObject::SetSelectedObject primitive every other selection origin uses -- lane B docs/research/2026-08-11-fa-panel-structure.md §6.2/§2.8). Runtime tests: +4 (RuntimeFellowshipStateTests.GetMembers_*). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
545 lines
20 KiB
C#
545 lines
20 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="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);
|
|
}
|
|
|
|
[Fact]
|
|
public void ResetSession_AfterDispose_IsANoOpAndDoesNotThrow()
|
|
{
|
|
// Blast SHOULD-FIX 5: ResetSession has NO disposed guard, matching
|
|
// the precedent its own doc comment cites
|
|
// (RuntimeInventoryState.ResetExternalContainer,
|
|
// RuntimeCommunicationState.ResetNegotiatedChannels — both bare
|
|
// delegations with no disposal guard). The reset transaction is
|
|
// retryable and disposal is terminal; a throwing guard could never
|
|
// converge on retry.
|
|
var state = new RuntimeFellowshipState();
|
|
state.ApplyFullUpdate(FullUpdate(Member(SelfGuid)));
|
|
state.Dispose();
|
|
|
|
Exception? thrown = Record.Exception(state.ResetSession);
|
|
|
|
Assert.Null(thrown);
|
|
Assert.False(state.View.Snapshot.IsInFellowship);
|
|
}
|
|
|
|
// ── SHOULD-FIX 3 (mechanism review): RecalculateEvenXPSplitting ────────
|
|
|
|
[Theory]
|
|
[InlineData(10u, 10u, true)] // within +/-5 of the leader -> stays even
|
|
[InlineData(10u, 20u, false)] // 10 spread, minLevel<50 -> not even
|
|
[InlineData(60u, 200u, true)] // minLevel >= 50 -> the spread check never runs, stays even
|
|
public void ApplyUpdateFellow_RecalculatesEvenXpSplit_MatchingRetailWideSpreadRule(
|
|
uint memberLevel,
|
|
uint leaderLevel,
|
|
bool expectedEvenXpSplit)
|
|
{
|
|
var state = new RuntimeFellowshipState();
|
|
state.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
|
|
[Member(LeaderGuid)],
|
|
"The Fellows",
|
|
LeaderGuid,
|
|
ShareXp: true,
|
|
EvenXpSplit: true,
|
|
OpenFellow: true,
|
|
Locked: false,
|
|
Departed: []));
|
|
// Overwrite the leader's own level to the test's value via a
|
|
// same-guid upsert (an existing-member refresh, never gated).
|
|
state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow(
|
|
LeaderGuid,
|
|
Member(LeaderGuid) with { Level = leaderLevel },
|
|
UpdateType: 3u));
|
|
|
|
state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow(
|
|
SelfGuid,
|
|
Member(SelfGuid) with { Level = memberLevel },
|
|
UpdateType: 1u));
|
|
|
|
Assert.Equal(expectedEvenXpSplit, state.View.Snapshot.EvenXpSplit);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyUpdateFellow_ShareXpOff_LeavesEvenXpSplitUntouched()
|
|
{
|
|
var state = new RuntimeFellowshipState();
|
|
state.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
|
|
[Member(LeaderGuid)],
|
|
"The Fellows",
|
|
LeaderGuid,
|
|
ShareXp: false,
|
|
EvenXpSplit: true, // deliberately mismatched with ShareXp: false
|
|
OpenFellow: true,
|
|
Locked: false,
|
|
Departed: []));
|
|
|
|
state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow(
|
|
SelfGuid, Member(SelfGuid) with { Level = 999u }, UpdateType: 1u));
|
|
|
|
// ShareXp == false -> RecalculateEvenXPSplitting's retail body
|
|
// returns immediately, leaving the wire-supplied flag alone.
|
|
Assert.True(state.View.Snapshot.EvenXpSplit);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyFullUpdate_NeverRecomputesEvenXpSplit_StoresTheWireFlagVerbatim()
|
|
{
|
|
// Lane B §6.2: the full update carries the server's own
|
|
// authoritative flag; ApplyFullUpdate must store it as-is even when
|
|
// the client-side recompute would disagree (leader/member levels
|
|
// far enough apart that RecalculateEvenXPSplitting would say false).
|
|
var state = new RuntimeFellowshipState();
|
|
|
|
state.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
|
|
[
|
|
Member(LeaderGuid) with { Level = 5u },
|
|
Member(SelfGuid) with { Level = 500u },
|
|
],
|
|
"The Fellows",
|
|
LeaderGuid,
|
|
ShareXp: true,
|
|
EvenXpSplit: true, // server says even despite the huge spread
|
|
OpenFellow: true,
|
|
Locked: false,
|
|
Departed: []));
|
|
|
|
Assert.True(state.View.Snapshot.EvenXpSplit);
|
|
}
|
|
|
|
// ── SHOULD-FIX 4 (mechanism review): locked/departed admission gate ───
|
|
|
|
[Fact]
|
|
public void ApplyUpdateFellow_LockedFellowship_RefusesABrandNewGuidNotInDeparted()
|
|
{
|
|
var state = new RuntimeFellowshipState();
|
|
state.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
|
|
[Member(LeaderGuid)],
|
|
"The Fellows",
|
|
LeaderGuid,
|
|
ShareXp: false,
|
|
EvenXpSplit: false,
|
|
OpenFellow: false,
|
|
Locked: true,
|
|
Departed: []));
|
|
long before = state.View.Snapshot.Revision;
|
|
|
|
state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow(
|
|
OtherGuid, Member(OtherGuid), UpdateType: 1u));
|
|
|
|
Assert.False(state.View.TryGetMember(OtherGuid, out _));
|
|
Assert.Equal(1, state.View.Snapshot.MemberCount);
|
|
Assert.Equal(before, state.View.Snapshot.Revision);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyUpdateFellow_LockedFellowship_AdmitsAGuidThatDepartedWithinTheGraceWindow()
|
|
{
|
|
var clock = new ManualTimeProvider();
|
|
var state = new RuntimeFellowshipState(clock);
|
|
state.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
|
|
[Member(LeaderGuid)],
|
|
"The Fellows",
|
|
LeaderGuid,
|
|
ShareXp: false,
|
|
EvenXpSplit: false,
|
|
OpenFellow: false,
|
|
Locked: true,
|
|
Departed: [new GameEvents.FellowshipDepartedMember(
|
|
OtherGuid, (int)clock.GetUtcNow().ToUnixTimeSeconds())]));
|
|
clock.Advance(TimeSpan.FromSeconds(899));
|
|
|
|
state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow(
|
|
OtherGuid, Member(OtherGuid), UpdateType: 1u));
|
|
|
|
Assert.True(state.View.TryGetMember(OtherGuid, out _));
|
|
Assert.Equal(2, state.View.Snapshot.MemberCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyUpdateFellow_LockedFellowship_RefusesAGuidThatDepartedOutsideTheGraceWindow()
|
|
{
|
|
var clock = new ManualTimeProvider();
|
|
var state = new RuntimeFellowshipState(clock);
|
|
state.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
|
|
[Member(LeaderGuid)],
|
|
"The Fellows",
|
|
LeaderGuid,
|
|
ShareXp: false,
|
|
EvenXpSplit: false,
|
|
OpenFellow: false,
|
|
Locked: true,
|
|
Departed: [new GameEvents.FellowshipDepartedMember(
|
|
OtherGuid, (int)clock.GetUtcNow().ToUnixTimeSeconds())]));
|
|
clock.Advance(TimeSpan.FromSeconds(901));
|
|
|
|
state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow(
|
|
OtherGuid, Member(OtherGuid), UpdateType: 1u));
|
|
|
|
Assert.False(state.View.TryGetMember(OtherGuid, out _));
|
|
Assert.Equal(1, state.View.Snapshot.MemberCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void ApplyUpdateFellow_LockedFellowship_NeverGatesAnExistingMembersOwnRefresh()
|
|
{
|
|
var state = new RuntimeFellowshipState();
|
|
state.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
|
|
[Member(LeaderGuid), Member(SelfGuid, currentHealth: 100u)],
|
|
"The Fellows",
|
|
LeaderGuid,
|
|
ShareXp: false,
|
|
EvenXpSplit: false,
|
|
OpenFellow: false,
|
|
Locked: true,
|
|
Departed: []));
|
|
|
|
state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow(
|
|
SelfGuid, Member(SelfGuid, currentHealth: 42u), UpdateType: 3u));
|
|
|
|
Assert.True(state.View.TryGetMember(SelfGuid, out RuntimeFellowMemberSnapshot self));
|
|
Assert.Equal(42u, self.CurrentHealth);
|
|
}
|
|
|
|
// ── Campaign FA slice FA4: GetMembers (the fellowship panel's roster
|
|
// enumeration — TryGetMember alone cannot build a roster without the
|
|
// guid first). ────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void GetMembers_ReturnsEveryCurrentMember()
|
|
{
|
|
var state = new RuntimeFellowshipState();
|
|
state.ApplyFullUpdate(FullUpdate(
|
|
Member(SelfGuid, "Self"), Member(LeaderGuid, "Leader"), Member(OtherGuid, "Other")));
|
|
|
|
var guids = state.View.GetMembers().Select(m => m.Guid).ToHashSet();
|
|
|
|
Assert.Equal(3, guids.Count);
|
|
Assert.Contains(SelfGuid, guids);
|
|
Assert.Contains(LeaderGuid, guids);
|
|
Assert.Contains(OtherGuid, guids);
|
|
}
|
|
|
|
[Fact]
|
|
public void GetMembers_EmptyWhenNotInAFellowship()
|
|
{
|
|
var state = new RuntimeFellowshipState();
|
|
|
|
Assert.Empty(state.View.GetMembers());
|
|
}
|
|
|
|
[Fact]
|
|
public void GetMembers_ReflectsAnIncrementalUpsert()
|
|
{
|
|
var state = new RuntimeFellowshipState();
|
|
state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid)));
|
|
|
|
state.ApplyUpdateFellow(new GameEvents.FellowshipUpdateFellow(
|
|
OtherGuid, Member(OtherGuid, "Other"), UpdateType: 1u));
|
|
|
|
Assert.Equal(3, state.View.GetMembers().Count());
|
|
Assert.Contains(state.View.GetMembers(), m => m.Guid == OtherGuid && m.Name == "Other");
|
|
}
|
|
|
|
[Fact]
|
|
public void GetMembers_ReflectsRemovalAfterDismiss()
|
|
{
|
|
var state = new RuntimeFellowshipState();
|
|
state.ApplyFullUpdate(FullUpdate(Member(SelfGuid), Member(LeaderGuid), Member(OtherGuid)));
|
|
|
|
state.ApplyDismiss(OtherGuid, SelfGuid);
|
|
|
|
Assert.DoesNotContain(state.View.GetMembers(), m => m.Guid == OtherGuid);
|
|
Assert.Equal(2, state.View.GetMembers().Count());
|
|
}
|
|
|
|
private sealed class ManualTimeProvider : TimeProvider
|
|
{
|
|
private DateTimeOffset _now = new(2026, 8, 12, 0, 0, 0, TimeSpan.Zero);
|
|
|
|
public override DateTimeOffset GetUtcNow() => _now;
|
|
|
|
public void Advance(TimeSpan elapsed) => _now += elapsed;
|
|
}
|
|
}
|