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:
parent
4272ad0ea4
commit
ded23067aa
8 changed files with 499 additions and 20 deletions
|
|
@ -197,7 +197,14 @@ public sealed class GameRuntimeContractTests
|
|||
ActiveRevealCount: 1,
|
||||
PendingDestinationReadinessCount: 1,
|
||||
HostProjectionCount: 1,
|
||||
PendingHostAcknowledgementCount: 2));
|
||||
PendingHostAcknowledgementCount: 2),
|
||||
// FA2 fix-round SHOULD-FIX 6: Fellowship/Allegiance are no
|
||||
// longer trailing-optional (see RuntimeStateCheckpoint's doc
|
||||
// comment) — `new()` uses each snapshot's explicit
|
||||
// parameterless constructor, which gives a non-null Name/
|
||||
// AllegianceName instead of `default`'s bitwise zero-init.
|
||||
new(),
|
||||
new());
|
||||
|
||||
recorder.AddCheckpoint(stamp, checkpoint);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,102 @@ public sealed class LiveSessionEventRouterTests
|
|||
router.Dispose();
|
||||
}
|
||||
|
||||
// ── Campaign FA slice FA2 fix-round SHOULD-FIX 4 (blast review) ────────
|
||||
// The single production registration site (LiveSessionEventRouter.cs)
|
||||
// was untested — both router-test factories default Fellowship/
|
||||
// Allegiance to null, so every FA2 lambda no-ops through `?.` in every
|
||||
// OTHER test in this file. The self-vs-other guid routing
|
||||
// (onFellowshipQuit/onFellowshipDismiss supply inventory.PlayerGuid()
|
||||
// as the self-guid, which selects "remove one member" vs "clear the
|
||||
// whole snapshot") is the one non-trivial lambda in the whole slice —
|
||||
// a transposed argument or wrong guid source there was invisible to
|
||||
// every test until now.
|
||||
|
||||
[Fact]
|
||||
public void FellowshipQuit_RoutesSelfGuidToClearAndOtherGuidToRemove()
|
||||
{
|
||||
const uint self = 0x50000001u;
|
||||
const uint other = 0x50000002u;
|
||||
using var session = NewSession();
|
||||
var fellowship = new RuntimeFellowshipState();
|
||||
var allegiance = new RuntimeAllegianceState();
|
||||
try
|
||||
{
|
||||
fellowship.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
|
||||
[
|
||||
new GameEvents.FellowMember(
|
||||
self, 0u, 0u, 1u, 100u, 100u, 100u, 100u, 100u, 100u, 0u, "Self"),
|
||||
new GameEvents.FellowMember(
|
||||
other, 0u, 0u, 1u, 100u, 100u, 100u, 100u, 100u, 100u, 0u, "Other"),
|
||||
],
|
||||
"The Fellows",
|
||||
LeaderGuid: self,
|
||||
ShareXp: true,
|
||||
EvenXpSplit: false,
|
||||
OpenFellow: true,
|
||||
Locked: false,
|
||||
Departed: []));
|
||||
|
||||
var router = new LiveSessionEventRouter(
|
||||
session,
|
||||
NoOpEntitySink(),
|
||||
NoOpEnvironmentSink(),
|
||||
new LiveInventorySessionBindings(
|
||||
new ClientObjectTable(),
|
||||
PlayerGuid: () => self,
|
||||
OnShortcuts: null,
|
||||
OnUseDone: null,
|
||||
ItemMana: new ItemManaState(),
|
||||
ExternalContainers: new ExternalContainerState()),
|
||||
NewCharacterBindings(),
|
||||
new LiveSocialSessionBindings(
|
||||
new ChatLog(),
|
||||
new TurbineChatState(),
|
||||
new FriendsState(),
|
||||
new SquelchState(),
|
||||
Fellowship: fellowship,
|
||||
Allegiance: allegiance));
|
||||
router.Attach();
|
||||
|
||||
// Someone ELSE quits -- removes exactly that one member.
|
||||
session.GameEvents.Dispatch(
|
||||
GameEventEnvelope.TryParse(WrapFellowshipQuitEnvelope(other))!.Value);
|
||||
|
||||
Assert.True(fellowship.View.Snapshot.IsInFellowship);
|
||||
Assert.Equal(1, fellowship.View.Snapshot.MemberCount);
|
||||
Assert.False(fellowship.View.TryGetMember(other, out _));
|
||||
Assert.True(fellowship.View.TryGetMember(self, out _));
|
||||
|
||||
// The LOCAL PLAYER quits -- clears the whole snapshot, not just
|
||||
// one member.
|
||||
session.GameEvents.Dispatch(
|
||||
GameEventEnvelope.TryParse(WrapFellowshipQuitEnvelope(self))!.Value);
|
||||
|
||||
Assert.False(fellowship.View.Snapshot.IsInFellowship);
|
||||
Assert.Equal(0, fellowship.View.Snapshot.MemberCount);
|
||||
|
||||
router.Dispose();
|
||||
}
|
||||
finally
|
||||
{
|
||||
fellowship.Dispose();
|
||||
allegiance.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] WrapFellowshipQuitEnvelope(uint quitterGuid)
|
||||
{
|
||||
byte[] payload = new byte[4];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(payload, quitterGuid);
|
||||
byte[] body = new byte[GameEventEnvelope.HeaderSize + payload.Length];
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body, GameEventEnvelope.Opcode);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), 0u);
|
||||
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), (uint)GameEventType.FellowshipQuit);
|
||||
Array.Copy(payload, 0, body, GameEventEnvelope.HeaderSize, payload.Length);
|
||||
return body;
|
||||
}
|
||||
|
||||
// ── Campaign CH slice CH3: TurbineChat ack HResult surfacing ──
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue