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