fix(net,runtime): FA2 fix-round SHOULD-FIX -- fellowship mechanism parity, lookup reuse, router test, checkpoint defaults

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>
This commit is contained in:
Erik 2026-08-12 02:17:56 +02:00
parent 4272ad0ea4
commit ded23067aa
8 changed files with 499 additions and 20 deletions

View file

@ -283,6 +283,211 @@ public sealed class RuntimeFellowshipStateTests
Assert.Throws<ObjectDisposedException>(
() => state.ApplyQuit(SelfGuid, SelfGuid));
Assert.Throws<ObjectDisposedException>(state.ApplyDisband);
Assert.Throws<ObjectDisposedException>(state.ResetSession);
}
[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;
}
}