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>
<InternalsVisibleTo Include="AcDream.Core.Net.Tests" />
<InternalsVisibleTo Include="AcDream.App.Tests" />
<InternalsVisibleTo Include="AcDream.Runtime" />
<InternalsVisibleTo Include="AcDream.Runtime.Tests" />
<InternalsVisibleTo Include="AcDream.Headless.Tests" />
</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>Core/Allegiance/AllegianceTree.cs</c>); these are ports of
/// 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>
private static class AllegianceProfileLookups
internal static class AllegianceProfileLookups
{
/// <summary>Port of <c>AllegianceProfile::GetData</c>.</summary>
public static AllegianceMemberRecord? FindData(

View file

@ -136,7 +136,18 @@ public readonly record struct RuntimeFellowshipSnapshot(
bool EvenXpSplit,
bool IsOpen,
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
{
@ -172,6 +183,14 @@ public readonly record struct RuntimeAllegianceSnapshot(
int RecordCount)
{
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

View file

@ -239,10 +239,21 @@ public readonly record struct RuntimeStateCheckpoint(
RuntimeWorldEnvironmentOwnershipSnapshot EnvironmentOwnership,
RuntimePortalSnapshot Portal,
RuntimeWorldTransitOwnershipSnapshot TransitOwnership,
// Campaign FA slice FA2 (2026-08-12): default so every existing
// positional construction site (tests) compiles unchanged.
RuntimeFellowshipSnapshot Fellowship = default,
RuntimeAllegianceSnapshot Allegiance = default);
// Campaign FA slice FA2 (2026-08-12): originally trailing-optional
// (`= default`) so every existing positional construction site (tests)
// compiled unchanged. FA2 fix-round SHOULD-FIX 6 (blast review) made
// 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
{

View file

@ -18,9 +18,14 @@ public readonly record struct RuntimeFellowshipOwnershipSnapshot(
/// fellowship roster — Campaign FA slice FA2 (2026-08-12). Session-scoped:
/// a disconnect drops you from the fellowship server-side, so this clears
/// at generation reset exactly like the external-container precedent
/// (<see cref="RuntimeInventoryState.ResetExternalContainer"/>), unlike
/// <see cref="RuntimeAllegianceState"/> which survives reconnect
/// (docs/research/2026-08-11-fa-acdream-seams.md §1.3).
/// (<see cref="RuntimeInventoryState.ResetExternalContainer"/>). FA2
/// fix-round correction (2026-08-12, MUST-FIX 1 in
/// 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>
/// 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
/// <see cref="IRuntimeEventObserver"/> member).
/// </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>
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 TimeProvider _timeProvider;
private readonly Dictionary<uint, GameEvents.FellowMember> _members = [];
private readonly Dictionary<uint, int> _fellowsDeparted = [];
private string _name = string.Empty;
private uint _leaderGuid;
private bool _shareXp;
@ -48,7 +74,11 @@ public sealed class RuntimeFellowshipState : IDisposable
private long _revision;
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; }
@ -66,15 +96,26 @@ public sealed class RuntimeFellowshipState : IDisposable
/// </summary>
public void ApplyFullUpdate(GameEvents.FellowshipFullUpdate update)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_members.Clear();
foreach (GameEvents.FellowMember member in update.Members)
_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;
_leaderGuid = update.LeaderGuid;
_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;
_isOpen = update.OpenFellow;
_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
/// ever established that the local player IS in a fellowship — retail
/// 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>
public void ApplyUpdateFellow(GameEvents.FellowshipUpdateFellow update)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isInFellowship) return;
bool isNewMember = !_members.ContainsKey(update.MemberGuid);
if (isNewMember && _locked && !IsAdmissibleWhileLocked(update.MemberGuid))
return;
_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();
}
}
/// <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>
/// <c>0x00A3 FellowshipQuit</c> (S→C direction) — sent both to the
/// quitter and to every remaining member. Self-removal (<paramref
@ -109,9 +220,9 @@ public sealed class RuntimeFellowshipState : IDisposable
/// </summary>
public void ApplyQuit(uint quitterGuid, uint selfGuid)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isInFellowship) return;
if (quitterGuid == selfGuid)
{
@ -119,7 +230,10 @@ public sealed class RuntimeFellowshipState : IDisposable
return;
}
if (_members.Remove(quitterGuid))
{
RecalculateEvenXpSplit();
Bump();
}
}
}
@ -129,9 +243,9 @@ public sealed class RuntimeFellowshipState : IDisposable
/// </summary>
public void ApplyDismiss(uint dismissedGuid, uint selfGuid)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_isInFellowship) return;
if (dismissedGuid == selfGuid)
{
@ -139,15 +253,21 @@ public sealed class RuntimeFellowshipState : IDisposable
return;
}
if (_members.Remove(dismissedGuid))
{
RecalculateEvenXpSplit();
Bump();
}
}
}
/// <summary><c>0x02BF FellowshipDisband</c> — always clears.</summary>
public void ApplyDisband()
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate) ClearLocked();
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
ClearLocked();
}
}
/// <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);
}
/// <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()
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
lock (_gate) ClearLocked();
}
@ -229,6 +358,7 @@ public sealed class RuntimeFellowshipState : IDisposable
|| _isOpen
|| _locked;
_members.Clear();
_fellowsDeparted.Clear();
_name = string.Empty;
_leaderGuid = 0u;
_shareXp = false;