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

@ -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]