fix(net,runtime): FA2 fix-round MUST-FIX -- allegiance clears at reset, 0x027C stops seeding
Two MUST-FIX findings from the FA2 mechanism/blast reviews (docs/research/2026-08-12-fa2-review-mechanism.md, docs/research/2026-08-12-fa2-review-blast.md): MF-1 (mechanism) -- RuntimeAllegianceState survived a generation reset, contradicting retail (ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0 tail-calls AllegianceProfile::Clear at the same boundary Fellowship already clears at), contradicting the precedent it cited (RuntimeCharacterOptionsState.ResetSession clears-and-relatches, it does not persist), and pinned by a test asserting the wrong behavior. Fixed: RuntimeAllegianceState.ResetSession() clears the profile and drops HasServerSeed; a new RuntimeGenerationResetStage.Allegiance stage runs it on every generation reset, mirroring RuntimeFellowshipState exactly. RuntimeGenerationResetTests' FellowshipClearsAtResetButAllegianceSurvivesReconnect inverted to FellowshipAndAllegianceBothClearAtGenerationReset. MF-2 (mechanism) / blast MF-2 -- 0x027C AllegianceInfoResponse fed the Runtime allegiance owner (self-gated). Retail's own handler for 0x027C (CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent @0x006a7470) unpacks into a stack-local profile destroyed on return; the consumer (Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0) only prints AddTextToScroll lines. Retail's panel is fed exclusively by 0x0020 AllegianceUpdate. The removed seeding also fabricated RuntimeAllegianceSnapshot.Rank (0x027C carries no rank field) on any client whose first allegiance message was a self @allegiance info query. Fixed: dropped ApplyInfoResponseSelf, the onAllegianceInfoResponseSelf delegate hole, and the self-gate; 0x027C is text-only again, matching retail and the pre-FA2 shape. Also covers blast SHOULD-FIX 1 in the same edit to LiveSessionEventRouter.cs: the fellowship/allegiance delegate holes are now passed conditionally on the owner being supplied, so GameEventDispatcher.GetUnhandledCount reads correctly for callers without an owner (bare-ChatLog tests, a future partial host) instead of silently reading 0 for 9 event types whose parse result was discarded. RuntimeAllegianceState.cs and the two owners' Apply* mutators also move their ObjectDisposedException.ThrowIf checks inside the lock they already take (mechanism SHOULD-FIX 2) -- the prior check-then-lock shape let an inbound event on the decode thread race Dispose on the host thread and repopulate state after _disposed = true, permanently falsifying CaptureOwnership().IsConverged at teardown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
63649c8053
commit
4272ad0ea4
9 changed files with 425 additions and 229 deletions
|
|
@ -110,11 +110,6 @@ public static class GameEventWiring
|
|||
Action<uint /*dismissedGuid*/>? onFellowshipDismiss = null,
|
||||
Action? onFellowshipDisband = null,
|
||||
Action<ClientCommandResponses.AllegianceUpdate>? onAllegianceUpdate = null,
|
||||
// Self-gated: fires only when the response's TargetGuid is the
|
||||
// local player's own guid (see the registration below) — a
|
||||
// by-name @allegiance info query on ANOTHER player must not
|
||||
// overwrite the Runtime allegiance owner's own-tree snapshot.
|
||||
Action<ClientCommandResponses.AllegianceInfoResponse>? onAllegianceInfoResponseSelf = null,
|
||||
Action<uint /*weenieError*/>? onAllegianceUpdateDone = null,
|
||||
Action<uint /*weenieError*/>? onAllegianceUpdateAborted = null,
|
||||
Action<GameEvents.AllegianceLoginNotification>? onAllegianceLoginNotification = null)
|
||||
|
|
@ -218,28 +213,31 @@ public static class GameEventWiring
|
|||
// superseded handler only comes back if the newer registration's
|
||||
// token is later disposed). The seam doc's "the dispatcher supports
|
||||
// multiple owned handlers per type — both fire" claim
|
||||
// (docs/research/2026-08-11-fa-acdream-seams.md §2.3) does not hold
|
||||
// against the actual dispatcher; a literal second Register call
|
||||
// here would have silently killed the already-live `@allegiance
|
||||
// info` chat-text output the moment a caller supplied
|
||||
// onAllegianceInfoResponseSelf. Both behaviors are folded into this
|
||||
// ONE registration instead. Self-gated on TargetGuid == playerGuid()
|
||||
// so a by-name query against ANOTHER player's allegiance never
|
||||
// overwrites the Runtime owner's own-tree snapshot; skipped
|
||||
// entirely when no playerGuid resolver was supplied (matches every
|
||||
// other playerGuid-gated site above).
|
||||
// (docs/research/2026-08-11-fa-acdream-seams.md §2.3, dated addendum
|
||||
// added) does not hold against the actual dispatcher — a second
|
||||
// Register call here would silently kill this already-live
|
||||
// `@allegiance info` chat-text output.
|
||||
//
|
||||
// FA2 fix-round MUST-FIX 2 (2026-08-12,
|
||||
// docs/research/2026-08-12-fa2-review-mechanism.md /
|
||||
// docs/research/2026-08-12-fa2-review-blast.md): this handler
|
||||
// previously ALSO forwarded to a Runtime allegiance-owner callback
|
||||
// (self-gated on TargetGuid == playerGuid()). Retail's own handler
|
||||
// for 0x027C (CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent
|
||||
// @0x006a7470) unpacks into a STACK-LOCAL profile destroyed on
|
||||
// return and is consumed only by
|
||||
// Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0's
|
||||
// AddTextToScroll calls — retail's allegiance panel is fed
|
||||
// EXCLUSIVELY by 0x0020 AllegianceUpdate. Forwarding also fabricated
|
||||
// RuntimeAllegianceSnapshot.Rank (0x027C carries no rank field) on
|
||||
// any client whose first allegiance message was a self
|
||||
// `@allegiance info` query. Restored to text-only, matching retail.
|
||||
registrar.Register(GameEventType.AllegianceInfoResponse, e =>
|
||||
{
|
||||
var info = ClientCommandResponses.ParseAllegianceInfoResponse(e.Payload.Span);
|
||||
if (info is null) return;
|
||||
foreach (string line in ClientCommandResponses.FormatAllegianceInfoLines(info.Value))
|
||||
chat.OnSystemMessage(line, chatType: 0u);
|
||||
if (onAllegianceInfoResponseSelf is not null
|
||||
&& playerGuid is not null
|
||||
&& info.Value.TargetGuid == playerGuid())
|
||||
{
|
||||
onAllegianceInfoResponseSelf(info.Value);
|
||||
}
|
||||
});
|
||||
|
||||
// ── Fellowship (Campaign FA slice FA2, 2026-08-12) ──────────────
|
||||
|
|
|
|||
|
|
@ -223,10 +223,18 @@ public sealed class GameRuntime
|
|||
faultInjection);
|
||||
|
||||
// Campaign FA slice FA2 (2026-08-12): two sibling J-owners, not
|
||||
// children of Communication — the reset semantics differ
|
||||
// (fellowship is session-scoped, allegiance survives reconnect;
|
||||
// docs/research/2026-08-11-fa-acdream-seams.md §1.3).
|
||||
context.Fellowship = new RuntimeFellowshipState();
|
||||
// children of Communication. Originally documented as differing
|
||||
// in reset semantics (fellowship session-scoped, allegiance
|
||||
// surviving reconnect) — the FA2 fix-round MUST-FIX 1
|
||||
// (docs/research/2026-08-12-fa2-review-mechanism.md) found that
|
||||
// citation inverted (retail clears BOTH at
|
||||
// OnEndCharacterSession) and corrected both owners to clear at
|
||||
// every generation reset. They remain two owners because
|
||||
// fellowship and allegiance are still independent retail
|
||||
// systems with independent wire families — not because their
|
||||
// lifetimes differ anymore.
|
||||
context.Fellowship = new RuntimeFellowshipState(
|
||||
timeProvider: dependencies.TimeProvider);
|
||||
construction.Own(context.Fellowship);
|
||||
Fault(
|
||||
GameRuntimeConstructionPoint.FellowshipCreated,
|
||||
|
|
@ -293,7 +301,8 @@ public sealed class GameRuntime
|
|||
context.EntityObjects,
|
||||
context.Character,
|
||||
context.PlayerIdentity,
|
||||
context.Fellowship);
|
||||
context.Fellowship,
|
||||
context.Allegiance);
|
||||
|
||||
context.Movement.AttachPhysicsPublication(
|
||||
new RuntimeLocalPlayerPhysicsPublicationState(
|
||||
|
|
@ -713,16 +722,19 @@ public sealed class GameRuntime
|
|||
| GameRuntimeTeardownStage.MovementDisposed
|
||||
| GameRuntimeTeardownStage.CharacterDisposed
|
||||
| GameRuntimeTeardownStage.InventoryDisposed,
|
||||
9 => GameRuntimeTeardownStage.HostLeasesReleased
|
||||
| GameRuntimeTeardownStage.EventsDetached
|
||||
| GameRuntimeTeardownStage.SessionDisposed
|
||||
| GameRuntimeTeardownStage.TransitReset
|
||||
| GameRuntimeTeardownStage.ActionsDisposed
|
||||
| GameRuntimeTeardownStage.MovementDisposed
|
||||
| GameRuntimeTeardownStage.CharacterDisposed
|
||||
| GameRuntimeTeardownStage.InventoryDisposed
|
||||
| GameRuntimeTeardownStage.CommunicationDisposed
|
||||
| GameRuntimeTeardownStage.FellowshipDisposed,
|
||||
// Blast MF-1 (docs/research/2026-08-12-fa2-review-blast.md,
|
||||
// 2026-08-12 fix round): stage 9 disposes Fellowship (see
|
||||
// DrainCurrentStage/case 9 below), so at _disposeStage == 9
|
||||
// only stages 0..8 have COMPLETED — the flag set must end at
|
||||
// CommunicationDisposed (9 flags), not include
|
||||
// FellowshipDisposed. The original longhand spelling had one
|
||||
// flag too many; restored to the self-checking subtraction form
|
||||
// every other case already uses.
|
||||
9 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.FellowshipDisposed
|
||||
& ~GameRuntimeTeardownStage.AllegianceDisposed
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
& ~GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
10 => GameRuntimeTeardownStage.Complete
|
||||
& ~GameRuntimeTeardownStage.AllegianceDisposed
|
||||
& ~GameRuntimeTeardownStage.IdentityDisposed
|
||||
|
|
|
|||
|
|
@ -15,29 +15,77 @@ public readonly record struct RuntimeAllegianceOwnershipSnapshot(
|
|||
|
||||
/// <summary>
|
||||
/// Canonical presentation-independent owner for the local player's
|
||||
/// allegiance profile — Campaign FA slice FA2 (2026-08-12). Survives
|
||||
/// reconnect (unlike <see cref="RuntimeFellowshipState"/>, which is
|
||||
/// session-scoped): it is NOT a
|
||||
/// <see cref="RuntimeGenerationReset"/> stage, and its data persists across
|
||||
/// a generation boundary the same way a re-login does not sever your
|
||||
/// character's allegiance membership. The <see cref="HasServerSeed"/>
|
||||
/// latch is a <see cref="RuntimeCharacterOptionsState.HasServerSeed"/>-
|
||||
/// style one-way flag distinguishing "no profile has ever arrived" from
|
||||
/// "genuinely no allegiance" (a real push with a null monarch) — it only
|
||||
/// clears at terminal <see cref="Dispose"/>.
|
||||
/// allegiance profile — Campaign FA slice FA2 (2026-08-12).
|
||||
///
|
||||
/// <para>
|
||||
/// <b>FA2 fix-round correction (2026-08-12, MUST-FIX 1 in
|
||||
/// docs/research/2026-08-12-fa2-review-mechanism.md):</b> originally
|
||||
/// documented (and tested) as surviving reconnect, unlike
|
||||
/// <see cref="RuntimeFellowshipState"/>. That was wrong on three counts —
|
||||
/// retail clears the profile at exactly this boundary
|
||||
/// (<c>ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0</c>
|
||||
/// tail-calls <c>AllegianceProfile::Clear</c>, mirroring the sibling
|
||||
/// <c>ClientFellowshipSystem::OnEndCharacterSession @0x005690A0</c> this
|
||||
/// owner's own fellowship sibling already honors); the cited precedent
|
||||
/// (<see cref="RuntimeCharacterOptionsState.HasServerSeed"/>) CLEARS and
|
||||
/// re-latches on <c>ResetSession</c>, it does not persist; and the
|
||||
/// graphical host's connect path constructs
|
||||
/// <c>LiveSessionConnectOptions</c> with no character selector
|
||||
/// (<c>SessionPlayerComposition.cs:1127</c>), so a reconnect genuinely can
|
||||
/// select a different character at generation N+1 with nothing keying this
|
||||
/// owner's cached tree to a character identity. This owner is now a
|
||||
/// <see cref="RuntimeGenerationReset"/> stage
|
||||
/// (<see cref="RuntimeGenerationResetStage.Allegiance"/>) exactly like
|
||||
/// <see cref="RuntimeFellowshipState"/>: <see cref="ResetSession"/> clears
|
||||
/// the profile AND drops <see cref="HasServerSeed"/>, matching
|
||||
/// <see cref="RuntimeCharacterOptionsState.ResetSession"/>'s own
|
||||
/// clear-and-relatch shape precisely. The two owners remain separate
|
||||
/// classes because fellowship and allegiance are independent retail
|
||||
/// systems with independent wire families, not because their lifetimes
|
||||
/// differ.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="HasServerSeed"/> is a
|
||||
/// <see cref="RuntimeCharacterOptionsState.HasServerSeed"/>-style one-way
|
||||
/// latch distinguishing "no profile has arrived THIS generation" from
|
||||
/// "genuinely no allegiance" (a real push with a null monarch) — it clears
|
||||
/// at every generation reset (see above) and at terminal
|
||||
/// <see cref="Dispose"/>.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Seeded ONLY by <c>0x0020 AllegianceUpdate</c> (the unsolicited/
|
||||
/// subscribed push — always about the local player's own tree).
|
||||
/// <b>FA2 fix-round correction (MUST-FIX 2, same doc):</b> this owner was
|
||||
/// previously also seeded, self-gated, by <c>0x027C
|
||||
/// AllegianceInfoResponse</c>. Retail's own handler for that response
|
||||
/// (<c>CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent @0x006a7470</c>
|
||||
/// unpacks into a STACK-LOCAL <c>CAllegianceProfile</c> that is destroyed on
|
||||
/// return; <c>ClientAllegianceSystem::
|
||||
/// Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0</c> reads it
|
||||
/// only to print <c>AddTextToScroll</c> lines — retail's allegiance panel
|
||||
/// is fed exclusively by <c>0x0020</c>. Seeding from <c>0x027C</c> also
|
||||
/// fabricated <see cref="RuntimeAllegianceSnapshot.Rank"/> (that wire
|
||||
/// carries no rank field) on any client whose first allegiance message was
|
||||
/// a self <c>@allegiance info</c> query. The chat-text output for
|
||||
/// <c>@allegiance info</c> is unaffected — see
|
||||
/// <see cref="GameEventWiring"/>'s <c>AllegianceInfoResponse</c>
|
||||
/// registration, which still parses and prints it, just no longer forwards
|
||||
/// it to this owner.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// Seeded by <c>0x0020 AllegianceUpdate</c> (the unsolicited/subscribed
|
||||
/// push — always about the local player's own tree) and, self-gated, by
|
||||
/// <c>0x027C AllegianceInfoResponse</c> when the response's TargetGuid is
|
||||
/// the local player's own guid (an explicit <c>@allegiance info</c>
|
||||
/// self-query; a by-name query against another player's tree is NOT
|
||||
/// applied here — see <see cref="GameEventWiring"/>'s registration).
|
||||
/// Wraps <c>ClientCommandResponses.AllegianceMemberRecord</c> — the
|
||||
/// FA1-assembled flat vassal list + monarch/patron/self blocks
|
||||
/// (<c>AllegianceTree</c> was DELETED at FA1; there is nothing left to
|
||||
/// wrap — see the seam-map addendum,
|
||||
/// docs/research/2026-08-11-fa-acdream-seams.md §8).
|
||||
/// docs/research/2026-08-11-fa-acdream-seams.md §8). The parent-index
|
||||
/// lookups (<see cref="AllegianceView.TryGetMember"/>,
|
||||
/// <see cref="AllegianceView.TryGetPatron"/>,
|
||||
/// <see cref="AllegianceView.GetVassals"/>) reuse
|
||||
/// <c>ClientCommandResponses.AllegianceProfileLookups</c> rather than
|
||||
/// re-implementing the walk a second time (FA2 fix-round SHOULD-FIX 5).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RuntimeAllegianceState : IDisposable
|
||||
|
|
@ -65,21 +113,22 @@ public sealed class RuntimeAllegianceState : IDisposable
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Has any real allegiance push landed since construction? A one-way
|
||||
/// latch — see the class doc. Never cleared by <see cref="ResetSession"/>
|
||||
/// wiring (this owner has none); only <see cref="Dispose"/> clears it.
|
||||
/// Has any real allegiance push landed THIS generation? A one-way
|
||||
/// latch within a generation — see the class doc. Cleared by
|
||||
/// <see cref="ResetSession"/> (every generation reset) and by terminal
|
||||
/// <see cref="Dispose"/>.
|
||||
/// </summary>
|
||||
public bool HasServerSeed
|
||||
{
|
||||
get { lock (_gate) return _hasServerSeed; }
|
||||
}
|
||||
|
||||
/// <summary><c>0x0020 AllegianceUpdate</c> — the unsolicited/subscribed profile push.</summary>
|
||||
/// <summary><c>0x0020 AllegianceUpdate</c> — the unsolicited/subscribed profile push; the ONLY inbound writer of this owner's profile (see the class doc's MUST-FIX 2 correction).</summary>
|
||||
public void ApplyUpdate(ClientCommandResponses.AllegianceUpdate update)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
_monarch = update.Monarch;
|
||||
_records = update.Records;
|
||||
_allegianceName = update.AllegianceName;
|
||||
|
|
@ -92,29 +141,6 @@ public sealed class RuntimeAllegianceState : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x027C AllegianceInfoResponse</c>, self-gated by the caller
|
||||
/// (<see cref="GameEventWiring"/>'s registration checks
|
||||
/// <c>TargetGuid == playerGuid()</c> before invoking this). Carries no
|
||||
/// rank field on the wire — the last known rank (if any) is retained.
|
||||
/// </summary>
|
||||
public void ApplyInfoResponseSelf(
|
||||
ClientCommandResponses.AllegianceInfoResponse response)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
lock (_gate)
|
||||
{
|
||||
_monarch = response.Monarch;
|
||||
_records = response.Records;
|
||||
_allegianceName = response.AllegianceName;
|
||||
_totalMembers = response.TotalMembers;
|
||||
_totalVassals = response.TotalVassals;
|
||||
_hasProfile = true;
|
||||
_hasServerSeed = true;
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>0x027A AllegianceLoginNotification</c> — bumps the revision so a
|
||||
/// polling consumer can observe the event happened; the retail-faithful
|
||||
|
|
@ -130,22 +156,31 @@ public sealed class RuntimeAllegianceState : IDisposable
|
|||
/// </summary>
|
||||
public void ApplyLoginNotification(uint characterGuid, bool isLoggedIn)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
lock (_gate) Bump();
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>0x01C8 AllegianceUpdateDone</c> — clears the panel busy latch; carries the WeenieError for a failed swear/break.</summary>
|
||||
public void ApplyUpdateDone(uint weenieError)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
lock (_gate) Bump();
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary><c>0x0003 AllegianceUpdateAborted</c> — declared by retail but never actually sent by ACE; parsed for forward-compat.</summary>
|
||||
public void ApplyUpdateAborted(uint weenieError)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(IsDisposed, this);
|
||||
lock (_gate) Bump();
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
Bump();
|
||||
}
|
||||
}
|
||||
|
||||
public RuntimeAllegianceOwnershipSnapshot CaptureOwnership()
|
||||
|
|
@ -157,23 +192,57 @@ public sealed class RuntimeAllegianceState : IDisposable
|
|||
_records.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// FA2 fix-round MUST-FIX 1: the generation-reset stage
|
||||
/// (<see cref="RuntimeGenerationResetStage.Allegiance"/>) — clears the
|
||||
/// profile AND drops <see cref="HasServerSeed"/>, mirroring
|
||||
/// <see cref="RuntimeCharacterOptionsState.ResetSession"/>'s
|
||||
/// clear-and-relatch exactly, and matching
|
||||
/// <see cref="RuntimeFellowshipState.ResetSession"/>'s no-disposed-guard
|
||||
/// shape (blast SHOULD-FIX 5's precedent —
|
||||
/// <see cref="RuntimeInventoryState.ResetExternalContainer"/> and
|
||||
/// <see cref="RuntimeCommunicationState.ResetNegotiatedChannels"/> are
|
||||
/// bare delegations with no disposal guard; the reset transaction is
|
||||
/// retryable and disposal is terminal, so a guard here could never
|
||||
/// converge on retry). A no-op after <see cref="Dispose"/> — the fields
|
||||
/// are already cleared.
|
||||
/// </summary>
|
||||
public void ResetSession()
|
||||
{
|
||||
lock (_gate) ClearLocked();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed) return;
|
||||
_monarch = null;
|
||||
_records = [];
|
||||
_allegianceName = string.Empty;
|
||||
_totalMembers = 0u;
|
||||
_totalVassals = 0u;
|
||||
_rank = 0u;
|
||||
_hasProfile = false;
|
||||
_hasServerSeed = false;
|
||||
ClearLocked();
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void ClearLocked()
|
||||
{
|
||||
bool changed = _monarch is not null
|
||||
|| _records.Count != 0
|
||||
|| _allegianceName.Length != 0
|
||||
|| _totalMembers != 0u
|
||||
|| _totalVassals != 0u
|
||||
|| _rank != 0u
|
||||
|| _hasProfile
|
||||
|| _hasServerSeed;
|
||||
_monarch = null;
|
||||
_records = [];
|
||||
_allegianceName = string.Empty;
|
||||
_totalMembers = 0u;
|
||||
_totalVassals = 0u;
|
||||
_rank = 0u;
|
||||
_hasProfile = false;
|
||||
_hasServerSeed = false;
|
||||
if (changed) Bump();
|
||||
}
|
||||
|
||||
private void Bump() => _revision++;
|
||||
|
||||
private sealed class AllegianceView(RuntimeAllegianceState owner)
|
||||
|
|
@ -211,23 +280,25 @@ public sealed class RuntimeAllegianceState : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
// FA2 fix-round SHOULD-FIX 5 (blast review): reuse
|
||||
// ClientCommandResponses.AllegianceProfileLookups (promoted to
|
||||
// internal + AcDream.Runtime granted InternalsVisibleTo) instead of
|
||||
// re-implementing the FindData/GetPatron/FindVassals walk a second
|
||||
// time — two copies of a retail walk is the shape that drifts.
|
||||
public bool TryGetMember(uint guid, out RuntimeAllegianceMemberSnapshot member)
|
||||
{
|
||||
lock (owner._gate)
|
||||
{
|
||||
if (owner._monarch is { } monarch && monarch.CharacterId == guid)
|
||||
ClientCommandResponses.AllegianceMemberRecord? record =
|
||||
ClientCommandResponses.AllegianceProfileLookups.FindData(
|
||||
owner._monarch, owner._records, guid);
|
||||
if (record is not { } found)
|
||||
{
|
||||
member = ToSnapshot(monarch);
|
||||
return true;
|
||||
member = default;
|
||||
return false;
|
||||
}
|
||||
foreach (ClientCommandResponses.AllegianceMemberRecord record in owner._records)
|
||||
{
|
||||
if (record.CharacterId != guid) continue;
|
||||
member = ToSnapshot(record);
|
||||
return true;
|
||||
}
|
||||
member = default;
|
||||
return false;
|
||||
member = ToSnapshot(found);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,19 +306,16 @@ public sealed class RuntimeAllegianceState : IDisposable
|
|||
{
|
||||
lock (owner._gate)
|
||||
{
|
||||
if (owner._monarch is { } monarch && monarch.CharacterId == guid)
|
||||
ClientCommandResponses.AllegianceMemberRecord? record =
|
||||
ClientCommandResponses.AllegianceProfileLookups.FindPatron(
|
||||
owner._monarch, owner._records, guid);
|
||||
if (record is not { } found)
|
||||
{
|
||||
// Port of AllegianceProfile::GetPatron: the monarch has no patron.
|
||||
patron = default;
|
||||
return false;
|
||||
}
|
||||
foreach (ClientCommandResponses.AllegianceMemberRecord record in owner._records)
|
||||
{
|
||||
if (record.CharacterId != guid) continue;
|
||||
return TryGetMemberLocked(record.ParentGuid, out patron);
|
||||
}
|
||||
patron = default;
|
||||
return false;
|
||||
patron = ToSnapshot(found);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -256,39 +324,25 @@ public sealed class RuntimeAllegianceState : IDisposable
|
|||
List<RuntimeAllegianceMemberSnapshot> result;
|
||||
lock (owner._gate)
|
||||
{
|
||||
// Lane C §4.4 point 3: each new record is PREPENDED to its
|
||||
// parent's vassal list on assembly, so the walk visits
|
||||
// siblings in REVERSE wire order.
|
||||
// Lane C §4.4 point 3 (via AllegianceProfileLookups.
|
||||
// FindVassals): each new record is PREPENDED to its parent's
|
||||
// vassal list on assembly, so the walk visits siblings in
|
||||
// REVERSE wire order. Materialized into a List while holding
|
||||
// the lock — C# cannot `yield return` from inside a lock
|
||||
// block, so this is an intentional exception to this file's
|
||||
// "no allocation" view convention (blast SHOULD-FIX 7); a
|
||||
// per-frame panel poll should cache the result rather than
|
||||
// re-invoke every frame.
|
||||
result = new List<RuntimeAllegianceMemberSnapshot>();
|
||||
for (int i = owner._records.Count - 1; i >= 0; i--)
|
||||
foreach (ClientCommandResponses.AllegianceMemberRecord record in
|
||||
ClientCommandResponses.AllegianceProfileLookups.FindVassals(owner._records, guid))
|
||||
{
|
||||
ClientCommandResponses.AllegianceMemberRecord record = owner._records[i];
|
||||
if (record.ParentGuid == guid)
|
||||
result.Add(ToSnapshot(record));
|
||||
result.Add(ToSnapshot(record));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private bool TryGetMemberLocked(
|
||||
uint guid,
|
||||
out RuntimeAllegianceMemberSnapshot member)
|
||||
{
|
||||
if (owner._monarch is { } monarch && monarch.CharacterId == guid)
|
||||
{
|
||||
member = ToSnapshot(monarch);
|
||||
return true;
|
||||
}
|
||||
foreach (ClientCommandResponses.AllegianceMemberRecord record in owner._records)
|
||||
{
|
||||
if (record.CharacterId != guid) continue;
|
||||
member = ToSnapshot(record);
|
||||
return true;
|
||||
}
|
||||
member = default;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static RuntimeAllegianceMemberSnapshot ToSnapshot(
|
||||
ClientCommandResponses.AllegianceMemberRecord record) =>
|
||||
new(
|
||||
|
|
|
|||
|
|
@ -36,19 +36,32 @@ public enum RuntimeGenerationResetStage
|
|||
/// Campaign FA slice FA2 (2026-08-12): the fellowship roster is
|
||||
/// session-scoped (a disconnect drops you from the fellowship
|
||||
/// server-side) — clear it here, alongside the other social-list
|
||||
/// stages. Allegiance is deliberately NOT a reset stage — it survives
|
||||
/// reconnect (see <see cref="RuntimeAllegianceState"/>'s class doc).
|
||||
/// stages.
|
||||
/// </summary>
|
||||
Fellowship = 12,
|
||||
BeginEntityRetirement = 13,
|
||||
RetireEntities = 14,
|
||||
DrainHostProjection = 15,
|
||||
CompleteCanonicalEntities = 16,
|
||||
CompleteHostProjection = 17,
|
||||
ChatIdentity = 18,
|
||||
PlayerSnapshots = 19,
|
||||
PlayerIdentity = 20,
|
||||
Complete = 21,
|
||||
/// <summary>
|
||||
/// FA2 fix-round MUST-FIX 1 (2026-08-12,
|
||||
/// docs/research/2026-08-12-fa2-review-mechanism.md MF-1): the
|
||||
/// allegiance profile is ALSO cleared here — retail's
|
||||
/// <c>ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0</c>
|
||||
/// tail-calls <c>AllegianceProfile::Clear</c> at exactly this
|
||||
/// per-character-session boundary, and the precedent this owner cites
|
||||
/// (<see cref="RuntimeCharacterOptionsState.ResetSession"/>) clears AND
|
||||
/// re-latches, it does not persist. See
|
||||
/// <see cref="RuntimeAllegianceState"/>'s class doc for the full
|
||||
/// correction (the original "survives reconnect" design was inverted
|
||||
/// from the precedent it named).
|
||||
/// </summary>
|
||||
Allegiance = 13,
|
||||
BeginEntityRetirement = 14,
|
||||
RetireEntities = 15,
|
||||
DrainHostProjection = 16,
|
||||
CompleteCanonicalEntities = 17,
|
||||
CompleteHostProjection = 18,
|
||||
ChatIdentity = 19,
|
||||
PlayerSnapshots = 20,
|
||||
PlayerIdentity = 21,
|
||||
Complete = 22,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeGenerationResetSnapshot(
|
||||
|
|
@ -97,6 +110,7 @@ public sealed class RuntimeGenerationReset
|
|||
private readonly RuntimeCharacterState _character;
|
||||
private readonly RuntimeLocalPlayerIdentityState _identity;
|
||||
private readonly RuntimeFellowshipState _fellowship;
|
||||
private readonly RuntimeAllegianceState _allegiance;
|
||||
private ResetState? _state;
|
||||
private RuntimeGenerationToken _lastCompletedGeneration;
|
||||
private bool _hasCompletedGeneration;
|
||||
|
|
@ -112,7 +126,8 @@ public sealed class RuntimeGenerationReset
|
|||
RuntimeEntityObjectLifetime entityObjects,
|
||||
RuntimeCharacterState character,
|
||||
RuntimeLocalPlayerIdentityState identity,
|
||||
RuntimeFellowshipState fellowship)
|
||||
RuntimeFellowshipState fellowship,
|
||||
RuntimeAllegianceState allegiance)
|
||||
{
|
||||
_transit = transit ?? throw new ArgumentNullException(nameof(transit));
|
||||
_communication = communication
|
||||
|
|
@ -130,6 +145,8 @@ public sealed class RuntimeGenerationReset
|
|||
?? throw new ArgumentNullException(nameof(identity));
|
||||
_fellowship = fellowship
|
||||
?? throw new ArgumentNullException(nameof(fellowship));
|
||||
_allegiance = allegiance
|
||||
?? throw new ArgumentNullException(nameof(allegiance));
|
||||
}
|
||||
|
||||
public RuntimeGenerationToken? ActiveRetiringGeneration =>
|
||||
|
|
@ -300,6 +317,9 @@ public sealed class RuntimeGenerationReset
|
|||
case RuntimeGenerationResetStage.Fellowship:
|
||||
Advance(state, _fellowship.ResetSession);
|
||||
break;
|
||||
case RuntimeGenerationResetStage.Allegiance:
|
||||
Advance(state, _allegiance.ResetSession);
|
||||
break;
|
||||
case RuntimeGenerationResetStage.BeginEntityRetirement:
|
||||
_ = _entityObjects.BeginSessionClear();
|
||||
state.Retirements = _entityObjects
|
||||
|
|
|
|||
|
|
@ -241,27 +241,47 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
|
|||
// reached identically by both hosts since LiveSessionEventRouter
|
||||
// itself is shared (LiveSessionEventRouter.Attach, K-slice
|
||||
// unification).
|
||||
onFellowshipFullUpdate: update =>
|
||||
social.Fellowship?.ApplyFullUpdate(update),
|
||||
onFellowshipUpdateFellow: update =>
|
||||
social.Fellowship?.ApplyUpdateFellow(update),
|
||||
onFellowshipQuit: quitterGuid =>
|
||||
social.Fellowship?.ApplyQuit(quitterGuid, inventory.PlayerGuid()),
|
||||
onFellowshipDismiss: dismissedGuid =>
|
||||
social.Fellowship?.ApplyDismiss(dismissedGuid, inventory.PlayerGuid()),
|
||||
onFellowshipDisband: () => social.Fellowship?.ApplyDisband(),
|
||||
onAllegianceUpdate: update =>
|
||||
social.Allegiance?.ApplyUpdate(update),
|
||||
onAllegianceInfoResponseSelf: response =>
|
||||
social.Allegiance?.ApplyInfoResponseSelf(response),
|
||||
onAllegianceUpdateDone: weenieError =>
|
||||
social.Allegiance?.ApplyUpdateDone(weenieError),
|
||||
onAllegianceUpdateAborted: weenieError =>
|
||||
social.Allegiance?.ApplyUpdateAborted(weenieError),
|
||||
onAllegianceLoginNotification: notice =>
|
||||
social.Allegiance?.ApplyLoginNotification(
|
||||
//
|
||||
// FA2 fix-round SHOULD-FIX 1 (2026-08-12,
|
||||
// docs/research/2026-08-12-fa2-review-mechanism.md): pass
|
||||
// each delegate hole CONDITIONALLY on the matching owner
|
||||
// being supplied, rather than an always-non-null lambda that
|
||||
// no-ops through `?.`. GameEventWiring only registers a
|
||||
// handler (and so only counts toward
|
||||
// GameEventDispatcher.GetUnhandledCount) when its delegate
|
||||
// hole is non-null — an always-non-null lambda made every
|
||||
// caller without an owner (bare-ChatLog tests, a future
|
||||
// partial host) silently read 0 unhandled events for these
|
||||
// 9 types even though the parse result was discarded.
|
||||
onFellowshipFullUpdate: social.Fellowship is { } fellowshipFull
|
||||
? fellowshipFull.ApplyFullUpdate
|
||||
: null,
|
||||
onFellowshipUpdateFellow: social.Fellowship is { } fellowshipUpdate
|
||||
? fellowshipUpdate.ApplyUpdateFellow
|
||||
: null,
|
||||
onFellowshipQuit: social.Fellowship is { } fellowshipQuit
|
||||
? quitterGuid => fellowshipQuit.ApplyQuit(quitterGuid, inventory.PlayerGuid())
|
||||
: null,
|
||||
onFellowshipDismiss: social.Fellowship is { } fellowshipDismiss
|
||||
? dismissedGuid => fellowshipDismiss.ApplyDismiss(dismissedGuid, inventory.PlayerGuid())
|
||||
: null,
|
||||
onFellowshipDisband: social.Fellowship is { } fellowshipDisband
|
||||
? fellowshipDisband.ApplyDisband
|
||||
: null,
|
||||
onAllegianceUpdate: social.Allegiance is { } allegianceUpdate
|
||||
? allegianceUpdate.ApplyUpdate
|
||||
: null,
|
||||
onAllegianceUpdateDone: social.Allegiance is { } allegianceUpdateDone
|
||||
? allegianceUpdateDone.ApplyUpdateDone
|
||||
: null,
|
||||
onAllegianceUpdateAborted: social.Allegiance is { } allegianceUpdateAborted
|
||||
? allegianceUpdateAborted.ApplyUpdateAborted
|
||||
: null,
|
||||
onAllegianceLoginNotification: social.Allegiance is { } allegianceLogin
|
||||
? notice => allegianceLogin.ApplyLoginNotification(
|
||||
notice.CharacterGuid,
|
||||
notice.IsLoggedIn)));
|
||||
notice.IsLoggedIn)
|
||||
: null));
|
||||
ConstructionCheckpoint();
|
||||
|
||||
// Campaign P Slice P1 (2026-07-30): burden recompute triggers —
|
||||
|
|
|
|||
|
|
@ -1698,36 +1698,36 @@ public sealed class GameEventWiringTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void WireAll_AllegianceInfoResponse_SelfGated_FiresOnlyForOwnGuid()
|
||||
public void WireAll_AllegianceInfoResponse_IsTextOnly_ForSelfAndOtherGuidsAlike()
|
||||
{
|
||||
// FA2 fix-round MUST-FIX 2 (docs/research/2026-08-12-fa2-review-mechanism.md):
|
||||
// 0x027C AllegianceInfoResponse no longer forwards to a Runtime
|
||||
// allegiance-owner callback at all — retail's own handler for this
|
||||
// response is print-only over a stack-local profile
|
||||
// (CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent
|
||||
// @0x006a7470 / Handle_Allegiance__AllegianceInfoResponseEvent
|
||||
// @0x0056a1d0). The chat-text output must fire unconditionally for
|
||||
// BOTH a self query and a by-name query about another player —
|
||||
// there is no self-gate left to test.
|
||||
const uint self = 0x50000001u;
|
||||
const uint other = 0x50000002u;
|
||||
var dispatcher = new GameEventDispatcher();
|
||||
ClientCommandResponses.AllegianceInfoResponse? observed = null;
|
||||
int chatLines = 0;
|
||||
var chat = new ChatLog();
|
||||
chat.EntryAppended += _ => chatLines++;
|
||||
GameEventWiring.WireAll(
|
||||
dispatcher, new ClientObjectTable(), new CombatState(), new Spellbook(), chat,
|
||||
playerGuid: () => self,
|
||||
onAllegianceInfoResponseSelf: response => observed = response);
|
||||
playerGuid: () => self);
|
||||
|
||||
// A response about ANOTHER player: the self-gated Runtime callback
|
||||
// must NOT fire, but the pre-existing `@allegiance info` chat-text
|
||||
// handler (unconditional) must still fire unchanged.
|
||||
byte[] otherWire = BuildMinimalAllegianceProfile(leadingField: other, monarchGuid: other, monarchName: "Other");
|
||||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||||
WrapEnvelope(GameEventType.AllegianceInfoResponse, otherWire))!.Value);
|
||||
Assert.Null(observed);
|
||||
Assert.True(chatLines > 0);
|
||||
|
||||
chatLines = 0;
|
||||
byte[] selfWire = BuildMinimalAllegianceProfile(leadingField: self, monarchGuid: self, monarchName: "Self");
|
||||
dispatcher.Dispatch(GameEventEnvelope.TryParse(
|
||||
WrapEnvelope(GameEventType.AllegianceInfoResponse, selfWire))!.Value);
|
||||
Assert.NotNull(observed);
|
||||
Assert.Equal(self, observed.Value.TargetGuid);
|
||||
// The chat-text handler fires for EVERY response, self or not.
|
||||
Assert.True(chatLines > 0);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using System.Reflection;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Selection;
|
||||
|
|
@ -207,6 +208,54 @@ public sealed class GameRuntimeTests
|
|||
Assert.Equal(0u, ownership.PlayerIdentity.ServerGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompletedTeardownStagesAccumulatesExactlyOneFlagPerStage()
|
||||
{
|
||||
// Blast MF-1 (docs/research/2026-08-12-fa2-review-blast.md): the
|
||||
// FA2 rewrite of case 9 in the private CompletedTeardownStages
|
||||
// switch claimed FellowshipDisposed one stage early (10 flags
|
||||
// instead of 9) — invisible to both existing tests, which only
|
||||
// sample the endpoints (stage 0 and Complete). This test walks
|
||||
// every intermediate stage directly via reflection so the next
|
||||
// owner insertion cannot repeat the class of bug: at
|
||||
// _disposeStage == N, EXACTLY the first N stages' flags must be
|
||||
// set, in DrainCurrentStage's disposal order.
|
||||
using var runtime = Create();
|
||||
FieldInfo stageField = typeof(GameRuntime).GetField(
|
||||
"_disposeStage", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
PropertyInfo completedProperty = typeof(GameRuntime).GetProperty(
|
||||
"CompletedTeardownStages", BindingFlags.NonPublic | BindingFlags.Instance)!;
|
||||
|
||||
GameRuntimeTeardownStage[] orderedFlags =
|
||||
[
|
||||
GameRuntimeTeardownStage.HostLeasesReleased,
|
||||
GameRuntimeTeardownStage.EventsDetached,
|
||||
GameRuntimeTeardownStage.SessionDisposed,
|
||||
GameRuntimeTeardownStage.TransitReset,
|
||||
GameRuntimeTeardownStage.ActionsDisposed,
|
||||
GameRuntimeTeardownStage.MovementDisposed,
|
||||
GameRuntimeTeardownStage.CharacterDisposed,
|
||||
GameRuntimeTeardownStage.InventoryDisposed,
|
||||
GameRuntimeTeardownStage.CommunicationDisposed,
|
||||
GameRuntimeTeardownStage.FellowshipDisposed,
|
||||
GameRuntimeTeardownStage.AllegianceDisposed,
|
||||
GameRuntimeTeardownStage.IdentityDisposed,
|
||||
GameRuntimeTeardownStage.EntityObjectsDisposed,
|
||||
];
|
||||
|
||||
GameRuntimeTeardownStage expected = GameRuntimeTeardownStage.None;
|
||||
for (int stage = 0; stage <= orderedFlags.Length; stage++)
|
||||
{
|
||||
stageField.SetValue(runtime, stage);
|
||||
var actual = (GameRuntimeTeardownStage)completedProperty.GetValue(runtime)!;
|
||||
Assert.Equal(expected, actual);
|
||||
if (stage < orderedFlags.Length)
|
||||
expected |= orderedFlags[stage];
|
||||
}
|
||||
|
||||
Assert.Equal(GameRuntimeTeardownStage.Complete, expected);
|
||||
}
|
||||
|
||||
private static GameRuntime Create() => new(Dependencies());
|
||||
|
||||
private static GameRuntimeDependencies Dependencies()
|
||||
|
|
|
|||
|
|
@ -6,12 +6,19 @@ namespace AcDream.Runtime.Tests.Gameplay;
|
|||
/// <summary>
|
||||
/// Campaign FA slice FA2 (2026-08-12): lifecycle rules for
|
||||
/// <see cref="RuntimeAllegianceState"/> — seeding from the unsolicited
|
||||
/// <c>AllegianceUpdate</c> push and from a self-gated
|
||||
/// <c>AllegianceInfoResponse</c>, the <c>HasServerSeed</c>-style latch, the
|
||||
/// monarch/patron/vassal walk, revision monotonicity, and the "survives
|
||||
/// reconnect" ownership contract (no <c>RuntimeGenerationReset</c> stage —
|
||||
/// see <see cref="RuntimeGenerationResetTests"/> for the sibling assertion
|
||||
/// that Fellowship IS a reset stage and Allegiance is not).
|
||||
/// <c>AllegianceUpdate</c> push, the <c>HasServerSeed</c>-style latch, the
|
||||
/// monarch/patron/vassal walk, revision monotonicity, and the
|
||||
/// generation-reset ownership contract.
|
||||
///
|
||||
/// <para>
|
||||
/// FA2 fix-round correction (2026-08-12,
|
||||
/// docs/research/2026-08-12-fa2-review-mechanism.md MF-1/MF-2): this owner
|
||||
/// is now a <c>RuntimeGenerationReset</c> stage (see
|
||||
/// <see cref="RuntimeGenerationResetTests"/> for the sibling assertion that
|
||||
/// BOTH Fellowship and Allegiance clear at reset), and no longer seeds from
|
||||
/// <c>0x027C AllegianceInfoResponse</c> — retail's own handler for that
|
||||
/// response is print-only over a stack-local profile.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class RuntimeAllegianceStateTests
|
||||
{
|
||||
|
|
@ -63,35 +70,6 @@ public sealed class RuntimeAllegianceStateTests
|
|||
Assert.Equal(4, snapshot.RecordCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ApplyInfoResponseSelf_SeedsTheProfileButLeavesRankUntouched()
|
||||
{
|
||||
var state = new RuntimeAllegianceState();
|
||||
state.ApplyUpdate(Update(rank: 9u));
|
||||
|
||||
var response = new ClientCommandResponses.AllegianceInfoResponse(
|
||||
TargetGuid: SelfGuid,
|
||||
TotalMembers: 5u,
|
||||
TotalVassals: 3u,
|
||||
RecordCount: 5,
|
||||
AllegianceName: "The Order",
|
||||
Monarch: Record(MonarchGuid, 0u, "Monarch"),
|
||||
Records:
|
||||
[
|
||||
Record(PatronGuid, MonarchGuid, "Patron"),
|
||||
Record(SelfGuid, PatronGuid, "Self"),
|
||||
]);
|
||||
|
||||
state.ApplyInfoResponseSelf(response);
|
||||
|
||||
RuntimeAllegianceSnapshot snapshot = state.View.Snapshot;
|
||||
Assert.True(snapshot.HasProfile);
|
||||
// AllegianceInfoResponse carries no rank field on the wire — the
|
||||
// last known rank from the earlier AllegianceUpdate is retained.
|
||||
Assert.Equal(9u, snapshot.Rank);
|
||||
Assert.Equal(2, snapshot.RecordCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetMonarchPatronAndVassals_WalkTheFlatRecordList()
|
||||
{
|
||||
|
|
@ -182,4 +160,60 @@ public sealed class RuntimeAllegianceStateTests
|
|||
Assert.Throws<ObjectDisposedException>(
|
||||
() => state.ApplyLoginNotification(SelfGuid, true));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSession_ClearsProfileAndDropsHasServerSeed_MatchingOnEndCharacterSession()
|
||||
{
|
||||
// MF-1 (docs/research/2026-08-12-fa2-review-mechanism.md): retail's
|
||||
// ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0
|
||||
// tail-calls AllegianceProfile::Clear at exactly this boundary —
|
||||
// the fix-round replaces the prior (inverted) "survives reconnect"
|
||||
// test with this one.
|
||||
var state = new RuntimeAllegianceState();
|
||||
state.ApplyUpdate(Update(rank: 7u));
|
||||
Assert.True(state.HasServerSeed);
|
||||
Assert.True(state.View.Snapshot.HasProfile);
|
||||
|
||||
state.ResetSession();
|
||||
|
||||
RuntimeAllegianceSnapshot snapshot = state.View.Snapshot;
|
||||
Assert.False(state.HasServerSeed);
|
||||
Assert.False(snapshot.HasServerSeed);
|
||||
Assert.False(snapshot.HasProfile);
|
||||
Assert.Equal(0u, snapshot.Rank);
|
||||
Assert.Equal(string.Empty, snapshot.AllegianceName);
|
||||
Assert.Equal(0u, snapshot.MonarchGuid);
|
||||
Assert.Equal(0, snapshot.RecordCount);
|
||||
Assert.False(state.IsDisposed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSession_OnAnUnseededOwner_IsANoOpThatDoesNotBumpRevision()
|
||||
{
|
||||
var state = new RuntimeAllegianceState();
|
||||
long before = state.View.Snapshot.Revision;
|
||||
|
||||
state.ResetSession();
|
||||
|
||||
Assert.Equal(before, state.View.Snapshot.Revision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResetSession_AfterDispose_IsANoOpAndDoesNotThrow()
|
||||
{
|
||||
// Blast SF-5: RuntimeFellowshipState.ResetSession has no disposed
|
||||
// guard (matching RuntimeInventoryState.ResetExternalContainer /
|
||||
// RuntimeCommunicationState.ResetNegotiatedChannels) because the
|
||||
// reset transaction is retryable and disposal is terminal — a
|
||||
// throwing guard could never converge on retry. Allegiance's new
|
||||
// ResetSession matches that shape.
|
||||
var state = new RuntimeAllegianceState();
|
||||
state.ApplyUpdate(Update());
|
||||
state.Dispose();
|
||||
|
||||
Exception? thrown = Xunit.Record.Exception(state.ResetSession);
|
||||
|
||||
Assert.Null(thrown);
|
||||
Assert.False(state.View.Snapshot.HasProfile);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -200,13 +200,20 @@ public sealed class RuntimeGenerationResetTests
|
|||
}
|
||||
|
||||
[Fact]
|
||||
public void FellowshipClearsAtResetButAllegianceSurvivesReconnect()
|
||||
public void FellowshipAndAllegianceBothClearAtGenerationReset()
|
||||
{
|
||||
// Campaign FA slice FA2 (2026-08-12), D2: fellowship is
|
||||
// session-scoped (cleared at every generation reset, matching the
|
||||
// ExternalContainer precedent); allegiance is NOT a reset stage at
|
||||
// all — it survives reconnect exactly like a real disconnect does
|
||||
// not sever your character's allegiance membership.
|
||||
// Campaign FA slice FA2 (2026-08-12), D2, CORRECTED by the FA2
|
||||
// fix-round MUST-FIX 1 (2026-08-12,
|
||||
// docs/research/2026-08-12-fa2-review-mechanism.md): this test
|
||||
// previously asserted the opposite of retail's behavior —
|
||||
// "allegiance survives reconnect". Retail's
|
||||
// ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0
|
||||
// tail-calls AllegianceProfile::Clear at exactly this boundary,
|
||||
// mirroring the sibling ClientFellowshipSystem::
|
||||
// OnEndCharacterSession @0x005690A0 fellowship already honored, and
|
||||
// the cited precedent (RuntimeCharacterOptionsState.ResetSession)
|
||||
// CLEARS and re-latches, it does not persist. Both owners are now
|
||||
// RuntimeGenerationReset stages and both clear here.
|
||||
using var runtime = Create();
|
||||
runtime.PlayerIdentity.ServerGuid = 0x50000001u;
|
||||
runtime.FellowshipOwner.ApplyFullUpdate(new GameEvents.FellowshipFullUpdate(
|
||||
|
|
@ -231,16 +238,18 @@ public sealed class RuntimeGenerationResetTests
|
|||
|
||||
Assert.True(runtime.Fellowship.Snapshot.IsInFellowship);
|
||||
Assert.True(runtime.Allegiance.Snapshot.HasProfile);
|
||||
Assert.True(runtime.AllegianceOwner.HasServerSeed);
|
||||
|
||||
var host = new RecordingResetHost(runtime);
|
||||
runtime.ResetGeneration(new RuntimeGenerationToken(3), host);
|
||||
|
||||
Assert.False(runtime.Fellowship.Snapshot.IsInFellowship);
|
||||
Assert.Equal(0, runtime.Fellowship.Snapshot.MemberCount);
|
||||
// Allegiance is untouched by the reset — the data survives.
|
||||
Assert.True(runtime.Allegiance.Snapshot.HasProfile);
|
||||
Assert.True(runtime.AllegianceOwner.HasServerSeed);
|
||||
Assert.Equal("The Order", runtime.Allegiance.Snapshot.AllegianceName);
|
||||
Assert.False(runtime.Allegiance.Snapshot.HasProfile);
|
||||
Assert.False(runtime.AllegianceOwner.HasServerSeed);
|
||||
Assert.Equal(string.Empty, runtime.Allegiance.Snapshot.AllegianceName);
|
||||
Assert.Equal(0u, runtime.Allegiance.Snapshot.MonarchGuid);
|
||||
Assert.Equal(0, runtime.Allegiance.Snapshot.RecordCount);
|
||||
}
|
||||
|
||||
private static GameRuntime Create()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue