Remaining SHOULD-FIX findings from the FA2 mechanism/blast reviews: Mechanism SF-3/SF-4 -- RuntimeFellowshipState.ApplyUpdateFellow now ports Fellowship::RecalculateEvenXPSplitting @0x005B92E0 (called from retail's AddFellow/UpdateFellow/RemoveFellow on every upsert/removal, but never from a full update -- that carries the server's own authoritative flag verbatim, lane B 6.2) and Fellowship::AddFellow @0x005B9480's locked/departed admission gate (a brand-new guid is refused while _locked unless it appears in the 0x02BE field-8 _fellows_departed table within 900s, @0x005B94A5). ApplyFullUpdate now stores update.Departed instead of discarding it. A TimeProvider dependency (defaulting to TimeProvider.System, matching the RuntimeCharacterOptionsState precedent) makes the 900s grace window testable. Mechanism SF-5 -- RuntimeAllegianceState's TryGetMember/TryGetPatron/ GetVassals now reuse ClientCommandResponses.AllegianceProfileLookups (promoted private -> internal, AcDream.Runtime added to Core.Net's InternalsVisibleTo) instead of re-implementing the retail walk a second time. Mechanism SF-6 -- RuntimeStateCheckpoint's Fellowship/Allegiance parameters are no longer trailing-optional. `default(RuntimeFellowshipSnapshot)`/ `default(RuntimeAllegianceSnapshot)` zero-init Name/AllegianceName to null, and C# does not allow a non-constant `new(...)` as an optional parameter's default value (CS1736) even when the struct declares an explicit parameterless constructor -- so the only way to guarantee a non-null default was to make the parameters required. Both snapshot types still gained an explicit parameterless constructor for callers that want an empty-but-safe `new()`. Blast SF-4 -- LiveSessionEventRouterTests gains FellowshipQuit_RoutesSelfGuidToClearAndOtherGuidToRemove, wiring real RuntimeFellowshipState/RuntimeAllegianceState owners through the one production registration site and dispatching a real 0x00A3 envelope for both a self-quit and an other-quit -- the one non-trivial lambda in the slice (the self-guid source that decides "remove one member" vs "clear the whole snapshot") was previously untested; every other router test defaults Fellowship/Allegiance to null. Blast SF-5 -- RuntimeFellowshipState.ResetSession dropped its disposed guard to match the precedent its own doc comment names (RuntimeInventoryState.ResetExternalContainer, RuntimeCommunicationState.ResetNegotiatedChannels -- both bare delegations with no disposal guard); the reset transaction is retryable and disposal is terminal, so a throwing guard could never converge on retry. RuntimeAllegianceState.ResetSession (new this fix round) matches the same shape from the start. Blast SF-7 -- IRuntimeAllegianceView.GetVassals' per-call List<> allocation is now documented as an intentional exception to the file's "Snapshot + TryGet*, no allocation" view convention (C# cannot yield-return from inside a lock). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
493 lines
18 KiB
C#
493 lines
18 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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|