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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue