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

@ -13,6 +13,7 @@
<ItemGroup> <ItemGroup>
<InternalsVisibleTo Include="AcDream.Core.Net.Tests" /> <InternalsVisibleTo Include="AcDream.Core.Net.Tests" />
<InternalsVisibleTo Include="AcDream.App.Tests" /> <InternalsVisibleTo Include="AcDream.App.Tests" />
<InternalsVisibleTo Include="AcDream.Runtime" />
<InternalsVisibleTo Include="AcDream.Runtime.Tests" /> <InternalsVisibleTo Include="AcDream.Runtime.Tests" />
<InternalsVisibleTo Include="AcDream.Headless.Tests" /> <InternalsVisibleTo Include="AcDream.Headless.Tests" />
</ItemGroup> </ItemGroup>

View file

@ -246,8 +246,18 @@ public static class ClientCommandResponses
/// <c>ParentGuid</c> tags IS the tree (lane C §0's DELETE verdict on /// <c>ParentGuid</c> tags IS the tree (lane C §0's DELETE verdict on
/// <c>Core/Allegiance/AllegianceTree.cs</c>); these are ports of /// <c>Core/Allegiance/AllegianceTree.cs</c>); these are ports of
/// retail's own pointer-walk accessors (lane C §1.5). /// retail's own pointer-walk accessors (lane C §1.5).
///
/// <para>
/// FA2 fix-round SHOULD-FIX 5 (2026-08-12,
/// docs/research/2026-08-12-fa2-review-mechanism.md): promoted from
/// <see langword="private"/> to <see langword="internal"/> (with
/// <c>AcDream.Runtime</c> added to this project's
/// <c>InternalsVisibleTo</c>) so
/// <c>AcDream.Runtime.Gameplay.RuntimeAllegianceState</c> can reuse this
/// walk instead of re-implementing it a second time.
/// </para>
/// </summary> /// </summary>
private static class AllegianceProfileLookups internal static class AllegianceProfileLookups
{ {
/// <summary>Port of <c>AllegianceProfile::GetData</c>.</summary> /// <summary>Port of <c>AllegianceProfile::GetData</c>.</summary>
public static AllegianceMemberRecord? FindData( public static AllegianceMemberRecord? FindData(

View file

@ -136,7 +136,18 @@ public readonly record struct RuntimeFellowshipSnapshot(
bool EvenXpSplit, bool EvenXpSplit,
bool IsOpen, bool IsOpen,
bool Locked, bool Locked,
int MemberCount); int MemberCount)
{
// FA2 fix-round SHOULD-FIX 6 (blast review): an explicit parameterless
// constructor gives `new()` (used as RuntimeStateCheckpoint's default,
// GameRuntimeViews.cs) a non-null Name instead of `default`'s bitwise
// zero-init (structs never run field initializers or this constructor
// for `default(T)` — only `new S()` invokes it).
public RuntimeFellowshipSnapshot()
: this(0, false, string.Empty, 0u, false, false, false, false, 0)
{
}
}
public interface IRuntimeFellowshipView public interface IRuntimeFellowshipView
{ {
@ -172,6 +183,14 @@ public readonly record struct RuntimeAllegianceSnapshot(
int RecordCount) int RecordCount)
{ {
public bool HasMonarch => MonarchGuid != 0u; public bool HasMonarch => MonarchGuid != 0u;
// FA2 fix-round SHOULD-FIX 6 (blast review): see
// RuntimeFellowshipSnapshot's parameterless constructor — same
// non-null-default reasoning, for AllegianceName.
public RuntimeAllegianceSnapshot()
: this(0, false, false, 0u, 0u, 0u, string.Empty, 0u, 0)
{
}
} }
public interface IRuntimeAllegianceView public interface IRuntimeAllegianceView

View file

@ -239,10 +239,21 @@ public readonly record struct RuntimeStateCheckpoint(
RuntimeWorldEnvironmentOwnershipSnapshot EnvironmentOwnership, RuntimeWorldEnvironmentOwnershipSnapshot EnvironmentOwnership,
RuntimePortalSnapshot Portal, RuntimePortalSnapshot Portal,
RuntimeWorldTransitOwnershipSnapshot TransitOwnership, RuntimeWorldTransitOwnershipSnapshot TransitOwnership,
// Campaign FA slice FA2 (2026-08-12): default so every existing // Campaign FA slice FA2 (2026-08-12): originally trailing-optional
// positional construction site (tests) compiles unchanged. // (`= default`) so every existing positional construction site (tests)
RuntimeFellowshipSnapshot Fellowship = default, // compiled unchanged. FA2 fix-round SHOULD-FIX 6 (blast review) made
RuntimeAllegianceSnapshot Allegiance = default); // both parameters REQUIRED instead: `default(RuntimeFellowshipSnapshot)`/
// `default(RuntimeAllegianceSnapshot)` zero-init every field —
// including Name/AllegianceName to null, not string.Empty — and C#
// does not allow a non-constant expression (a real `new(...)` call) as
// an optional-parameter default, so there is no way to give a
// TRAILING-OPTIONAL parameter here a non-null default. Both snapshot
// types still declare an explicit parameterless constructor
// (`RuntimeFellowshipSnapshot()`/`RuntimeAllegianceSnapshot()`,
// GameRuntimeGameplayViews.cs) so callers that want an empty-but-safe
// snapshot can pass `new()` explicitly.
RuntimeFellowshipSnapshot Fellowship,
RuntimeAllegianceSnapshot Allegiance);
public interface IGameRuntimeView public interface IGameRuntimeView
{ {

View file

@ -18,9 +18,14 @@ public readonly record struct RuntimeFellowshipOwnershipSnapshot(
/// fellowship roster — Campaign FA slice FA2 (2026-08-12). Session-scoped: /// fellowship roster — Campaign FA slice FA2 (2026-08-12). Session-scoped:
/// a disconnect drops you from the fellowship server-side, so this clears /// a disconnect drops you from the fellowship server-side, so this clears
/// at generation reset exactly like the external-container precedent /// at generation reset exactly like the external-container precedent
/// (<see cref="RuntimeInventoryState.ResetExternalContainer"/>), unlike /// (<see cref="RuntimeInventoryState.ResetExternalContainer"/>). FA2
/// <see cref="RuntimeAllegianceState"/> which survives reconnect /// fix-round correction (2026-08-12, MUST-FIX 1 in
/// (docs/research/2026-08-11-fa-acdream-seams.md §1.3). /// docs/research/2026-08-12-fa2-review-mechanism.md): the class doc
/// previously contrasted this with <see cref="RuntimeAllegianceState"/>
/// "surviving reconnect" — that citation was inverted (retail clears BOTH
/// at <c>OnEndCharacterSession</c>); <see cref="RuntimeAllegianceState"/>
/// is now ALSO a <see cref="RuntimeGenerationReset"/> stage with the same
/// clear-at-reset shape.
/// ///
/// <para> /// <para>
/// Assembled from the FA1 parsers: a full update (<c>0x02BE</c>) REPLACES /// Assembled from the FA1 parsers: a full update (<c>0x02BE</c>) REPLACES
@ -33,11 +38,32 @@ public readonly record struct RuntimeFellowshipOwnershipSnapshot(
/// rather than subscribing to a push event (D2 — no /// rather than subscribing to a push event (D2 — no
/// <see cref="IRuntimeEventObserver"/> member). /// <see cref="IRuntimeEventObserver"/> member).
/// </para> /// </para>
///
/// <para>
/// FA2 fix-round SHOULD-FIX 3/4 (mechanism review): the incremental upsert
/// path (<see cref="ApplyUpdateFellow"/>) now also ports
/// <c>Fellowship::RecalculateEvenXPSplitting @0x005B92E0</c> (called from
/// retail's <c>AddFellow</c>/<c>UpdateFellow</c>/<c>RemoveFellow</c> — NEVER
/// from a full update, which carries the server's own authoritative flag
/// verbatim) and <c>Fellowship::AddFellow @0x005B9480</c>'s locked/departed
/// admission gate (a brand-new guid is refused while <see cref="_locked"/>
/// unless it appears in the <c>0x02BE</c> field-8 departed-members table
/// within 900 s).
/// </para>
/// </summary> /// </summary>
public sealed class RuntimeFellowshipState : IDisposable public sealed class RuntimeFellowshipState : IDisposable
{ {
/// <summary>
/// Port of the 900 s (<c>0x384</c>) grace window in
/// <c>Fellowship::AddFellow @0x005B94A5</c> — a guid readmitted while
/// <see cref="_locked"/> only if it departed within this many seconds.
/// </summary>
private const int DepartedGraceSeconds = 900;
private readonly object _gate = new(); private readonly object _gate = new();
private readonly TimeProvider _timeProvider;
private readonly Dictionary<uint, GameEvents.FellowMember> _members = []; private readonly Dictionary<uint, GameEvents.FellowMember> _members = [];
private readonly Dictionary<uint, int> _fellowsDeparted = [];
private string _name = string.Empty; private string _name = string.Empty;
private uint _leaderGuid; private uint _leaderGuid;
private bool _shareXp; private bool _shareXp;
@ -48,7 +74,11 @@ public sealed class RuntimeFellowshipState : IDisposable
private long _revision; private long _revision;
private bool _disposed; private bool _disposed;
public RuntimeFellowshipState() => View = new FellowshipView(this); public RuntimeFellowshipState(TimeProvider? timeProvider = null)
{
_timeProvider = timeProvider ?? TimeProvider.System;
View = new FellowshipView(this);
}
public IRuntimeFellowshipView View { get; } public IRuntimeFellowshipView View { get; }
@ -66,15 +96,26 @@ public sealed class RuntimeFellowshipState : IDisposable
/// </summary> /// </summary>
public void ApplyFullUpdate(GameEvents.FellowshipFullUpdate update) public void ApplyFullUpdate(GameEvents.FellowshipFullUpdate update)
{ {
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate) lock (_gate)
{ {
ObjectDisposedException.ThrowIf(_disposed, this);
_members.Clear(); _members.Clear();
foreach (GameEvents.FellowMember member in update.Members) foreach (GameEvents.FellowMember member in update.Members)
_members[member.Guid] = member; _members[member.Guid] = member;
// SHOULD-FIX 4 (mechanism review): field 8 of 0x02BE — the
// _fellows_departed table AddFellow's locked-admission gate
// consults (see ApplyUpdateFellow below). Previously discarded.
_fellowsDeparted.Clear();
foreach (GameEvents.FellowshipDepartedMember departed in update.Departed)
_fellowsDeparted[departed.Guid] = departed.DepartedTimestamp;
_name = update.Name; _name = update.Name;
_leaderGuid = update.LeaderGuid; _leaderGuid = update.LeaderGuid;
_shareXp = update.ShareXp; _shareXp = update.ShareXp;
// Store the wire flag verbatim — do NOT re-derive via
// RecalculateEvenXpSplit here. Lane B §6.2: the full update
// carries the server's own authoritative flag; the client-side
// recompute (SHOULD-FIX 3, below) is a display-only optimistic
// estimate for BETWEEN full updates and must never override it.
_evenXpSplit = update.EvenXpSplit; _evenXpSplit = update.EvenXpSplit;
_isOpen = update.OpenFellow; _isOpen = update.OpenFellow;
_locked = update.Locked; _locked = update.Locked;
@ -88,18 +129,88 @@ public sealed class RuntimeFellowshipState : IDisposable
/// guid (vitals/level/name refresh). A no-op before any full update has /// guid (vitals/level/name refresh). A no-op before any full update has
/// ever established that the local player IS in a fellowship — retail /// ever established that the local player IS in a fellowship — retail
/// never sends this to a client outside one either. /// never sends this to a client outside one either.
///
/// <para>
/// SHOULD-FIX 4 (mechanism review): retail's
/// <c>Fellowship::UpdateFellow @0x005B9730</c> falls through to
/// <c>Fellowship::AddFellow @0x005B9480</c> when the guid is absent from
/// the table, and <c>AddFellow</c> refuses a brand-new guid while
/// <see cref="_locked"/> unless it appears in
/// <see cref="_fellowsDeparted"/> within
/// <see cref="DepartedGraceSeconds"/> seconds — ported below as
/// <see cref="IsAdmissibleWhileLocked"/>. An existing member's own
/// refresh is never gated (only the "is this guid NEW" branch is).
/// </para>
/// </summary> /// </summary>
public void ApplyUpdateFellow(GameEvents.FellowshipUpdateFellow update) public void ApplyUpdateFellow(GameEvents.FellowshipUpdateFellow update)
{ {
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate) lock (_gate)
{ {
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isInFellowship) return; if (!_isInFellowship) return;
bool isNewMember = !_members.ContainsKey(update.MemberGuid);
if (isNewMember && _locked && !IsAdmissibleWhileLocked(update.MemberGuid))
return;
_members[update.MemberGuid] = update.Member; _members[update.MemberGuid] = update.Member;
// SHOULD-FIX 3 (mechanism review): Fellowship::UpdateFellow
// @0x005B9785 calls RecalculateEvenXPSplitting on every upsert
// (both the AddFellow and the existing-member-refresh branch).
RecalculateEvenXpSplit();
Bump(); Bump();
} }
} }
/// <summary>
/// Port of <c>Fellowship::AddFellow @0x005B94A5</c>'s locked-admission
/// check.
/// </summary>
private bool IsAdmissibleWhileLocked(uint guid)
{
if (!_fellowsDeparted.TryGetValue(guid, out int departedTimestamp))
return false;
long nowSeconds = _timeProvider.GetUtcNow().ToUnixTimeSeconds();
return nowSeconds - departedTimestamp <= DepartedGraceSeconds;
}
/// <summary>
/// Port of <c>Fellowship::RecalculateEvenXPSplitting @0x005B92E0</c>
/// (lane B §2.10/§7.4), called from <c>AddFellow</c>/<c>UpdateFellow</c>/
/// <c>RemoveFellow</c> only — NEVER from a full update, which carries
/// the server's own authoritative flag (see <see cref="ApplyFullUpdate"/>'s
/// comment). A local optimistic recompute for DISPLAY between updates; a
/// later <c>0x02BE</c> always overrides it.
/// </summary>
private void RecalculateEvenXpSplit()
{
if (!_shareXp) return; // leaves _evenXpSplit untouched, matching retail
uint minLevel = uint.MaxValue;
uint maxLevel = 0u;
foreach (GameEvents.FellowMember member in _members.Values)
{
if (member.Level < minLevel) minLevel = member.Level;
if (member.Level > maxLevel) maxLevel = member.Level;
}
if (!_members.TryGetValue(_leaderGuid, out GameEvents.FellowMember leader))
{
// Fellowship::GetLeadersLevel @0x005B91B0 returns the
// 0xFFFFFFFF sentinel when the leader isn't in the table; lane B
// §7.4's byte-decode note directs treating that case as "leave
// _even_xp_split at 1" rather than replaying the
// unsigned-wraparound comparison against a sentinel.
_evenXpSplit = true;
return;
}
_evenXpSplit = true;
if (minLevel < 50u)
{
if (maxLevel > leader.Level + 5u) _evenXpSplit = false;
if (minLevel + 5u < leader.Level) _evenXpSplit = false;
}
}
/// <summary> /// <summary>
/// <c>0x00A3 FellowshipQuit</c> (S→C direction) — sent both to the /// <c>0x00A3 FellowshipQuit</c> (S→C direction) — sent both to the
/// quitter and to every remaining member. Self-removal (<paramref /// quitter and to every remaining member. Self-removal (<paramref
@ -109,9 +220,9 @@ public sealed class RuntimeFellowshipState : IDisposable
/// </summary> /// </summary>
public void ApplyQuit(uint quitterGuid, uint selfGuid) public void ApplyQuit(uint quitterGuid, uint selfGuid)
{ {
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate) lock (_gate)
{ {
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isInFellowship) return; if (!_isInFellowship) return;
if (quitterGuid == selfGuid) if (quitterGuid == selfGuid)
{ {
@ -119,7 +230,10 @@ public sealed class RuntimeFellowshipState : IDisposable
return; return;
} }
if (_members.Remove(quitterGuid)) if (_members.Remove(quitterGuid))
{
RecalculateEvenXpSplit();
Bump(); Bump();
}
} }
} }
@ -129,9 +243,9 @@ public sealed class RuntimeFellowshipState : IDisposable
/// </summary> /// </summary>
public void ApplyDismiss(uint dismissedGuid, uint selfGuid) public void ApplyDismiss(uint dismissedGuid, uint selfGuid)
{ {
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate) lock (_gate)
{ {
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isInFellowship) return; if (!_isInFellowship) return;
if (dismissedGuid == selfGuid) if (dismissedGuid == selfGuid)
{ {
@ -139,15 +253,21 @@ public sealed class RuntimeFellowshipState : IDisposable
return; return;
} }
if (_members.Remove(dismissedGuid)) if (_members.Remove(dismissedGuid))
{
RecalculateEvenXpSplit();
Bump(); Bump();
}
} }
} }
/// <summary><c>0x02BF FellowshipDisband</c> — always clears.</summary> /// <summary><c>0x02BF FellowshipDisband</c> — always clears.</summary>
public void ApplyDisband() public void ApplyDisband()
{ {
ObjectDisposedException.ThrowIf(IsDisposed, this); lock (_gate)
lock (_gate) ClearLocked(); {
ObjectDisposedException.ThrowIf(_disposed, this);
ClearLocked();
}
} }
/// <summary>The local player's own current leader guid, or 0 when not in a fellowship.</summary> /// <summary>The local player's own current leader guid, or 0 when not in a fellowship.</summary>
@ -201,10 +321,19 @@ public sealed class RuntimeFellowshipState : IDisposable
_members.Count); _members.Count);
} }
/// <summary>Session-scoped: cleared at every generation reset (reconnect).</summary> /// <summary>
/// Session-scoped: cleared at every generation reset (reconnect).
/// Blast SHOULD-FIX 5: no disposed guard, matching the precedent this
/// class's doc comment cites
/// (<see cref="RuntimeInventoryState.ResetExternalContainer"/>,
/// <see cref="RuntimeCommunicationState.ResetNegotiatedChannels"/> — both
/// bare delegations with no disposal guard). The reset transaction is
/// retryable and disposal is terminal; a throwing guard here could never
/// converge on retry. A no-op after <see cref="Dispose"/> — the fields
/// are already cleared.
/// </summary>
public void ResetSession() public void ResetSession()
{ {
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate) ClearLocked(); lock (_gate) ClearLocked();
} }
@ -229,6 +358,7 @@ public sealed class RuntimeFellowshipState : IDisposable
|| _isOpen || _isOpen
|| _locked; || _locked;
_members.Clear(); _members.Clear();
_fellowsDeparted.Clear();
_name = string.Empty; _name = string.Empty;
_leaderGuid = 0u; _leaderGuid = 0u;
_shareXp = false; _shareXp = false;

View file

@ -197,7 +197,14 @@ public sealed class GameRuntimeContractTests
ActiveRevealCount: 1, ActiveRevealCount: 1,
PendingDestinationReadinessCount: 1, PendingDestinationReadinessCount: 1,
HostProjectionCount: 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); recorder.AddCheckpoint(stamp, checkpoint);

View file

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

View file

@ -156,6 +156,102 @@ public sealed class LiveSessionEventRouterTests
router.Dispose(); 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 ── // ── Campaign CH slice CH3: TurbineChat ack HResult surfacing ──
[Fact] [Fact]