diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index 32ee8252..bb348818 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -110,11 +110,6 @@ public static class GameEventWiring Action? onFellowshipDismiss = null, Action? onFellowshipDisband = null, Action? 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? onAllegianceInfoResponseSelf = null, Action? onAllegianceUpdateDone = null, Action? onAllegianceUpdateAborted = null, Action? 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) ────────────── diff --git a/src/AcDream.Runtime/GameRuntime.cs b/src/AcDream.Runtime/GameRuntime.cs index 293cc12c..da5f474a 100644 --- a/src/AcDream.Runtime/GameRuntime.cs +++ b/src/AcDream.Runtime/GameRuntime.cs @@ -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 diff --git a/src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs b/src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs index ca059010..b8140a4c 100644 --- a/src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs +++ b/src/AcDream.Runtime/Gameplay/RuntimeAllegianceState.cs @@ -15,29 +15,77 @@ public readonly record struct RuntimeAllegianceOwnershipSnapshot( /// /// Canonical presentation-independent owner for the local player's -/// allegiance profile — Campaign FA slice FA2 (2026-08-12). Survives -/// reconnect (unlike , which is -/// session-scoped): it is NOT a -/// stage, and its data persists across -/// a generation boundary the same way a re-login does not sever your -/// character's allegiance membership. The -/// latch is a - -/// 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 . +/// allegiance profile — Campaign FA slice FA2 (2026-08-12). +/// +/// +/// FA2 fix-round correction (2026-08-12, MUST-FIX 1 in +/// docs/research/2026-08-12-fa2-review-mechanism.md): originally +/// documented (and tested) as surviving reconnect, unlike +/// . That was wrong on three counts — +/// retail clears the profile at exactly this boundary +/// (ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0 +/// tail-calls AllegianceProfile::Clear, mirroring the sibling +/// ClientFellowshipSystem::OnEndCharacterSession @0x005690A0 this +/// owner's own fellowship sibling already honors); the cited precedent +/// () CLEARS and +/// re-latches on ResetSession, it does not persist; and the +/// graphical host's connect path constructs +/// LiveSessionConnectOptions with no character selector +/// (SessionPlayerComposition.cs:1127), 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 +/// stage +/// () exactly like +/// : clears +/// the profile AND drops , matching +/// '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. +/// +/// +/// +/// is a +/// -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 +/// . +/// +/// +/// +/// Seeded ONLY by 0x0020 AllegianceUpdate (the unsolicited/ +/// subscribed push — always about the local player's own tree). +/// FA2 fix-round correction (MUST-FIX 2, same doc): this owner was +/// previously also seeded, self-gated, by 0x027C +/// AllegianceInfoResponse. Retail's own handler for that response +/// (CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent @0x006a7470 +/// unpacks into a STACK-LOCAL CAllegianceProfile that is destroyed on +/// return; ClientAllegianceSystem:: +/// Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0 reads it +/// only to print AddTextToScroll lines — retail's allegiance panel +/// is fed exclusively by 0x0020. Seeding from 0x027C also +/// fabricated (that wire +/// carries no rank field) on any client whose first allegiance message was +/// a self @allegiance info query. The chat-text output for +/// @allegiance info is unaffected — see +/// 's AllegianceInfoResponse +/// registration, which still parses and prints it, just no longer forwards +/// it to this owner. +/// /// /// -/// Seeded by 0x0020 AllegianceUpdate (the unsolicited/subscribed -/// push — always about the local player's own tree) and, self-gated, by -/// 0x027C AllegianceInfoResponse when the response's TargetGuid is -/// the local player's own guid (an explicit @allegiance info -/// self-query; a by-name query against another player's tree is NOT -/// applied here — see 's registration). /// Wraps ClientCommandResponses.AllegianceMemberRecord — the /// FA1-assembled flat vassal list + monarch/patron/self blocks /// (AllegianceTree 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 (, +/// , +/// ) reuse +/// ClientCommandResponses.AllegianceProfileLookups rather than +/// re-implementing the walk a second time (FA2 fix-round SHOULD-FIX 5). /// /// public sealed class RuntimeAllegianceState : IDisposable @@ -65,21 +113,22 @@ public sealed class RuntimeAllegianceState : IDisposable } /// - /// Has any real allegiance push landed since construction? A one-way - /// latch — see the class doc. Never cleared by - /// wiring (this owner has none); only clears it. + /// Has any real allegiance push landed THIS generation? A one-way + /// latch within a generation — see the class doc. Cleared by + /// (every generation reset) and by terminal + /// . /// public bool HasServerSeed { get { lock (_gate) return _hasServerSeed; } } - /// 0x0020 AllegianceUpdate — the unsolicited/subscribed profile push. + /// 0x0020 AllegianceUpdate — the unsolicited/subscribed profile push; the ONLY inbound writer of this owner's profile (see the class doc's MUST-FIX 2 correction). 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 } } - /// - /// 0x027C AllegianceInfoResponse, self-gated by the caller - /// ('s registration checks - /// TargetGuid == playerGuid() before invoking this). Carries no - /// rank field on the wire — the last known rank (if any) is retained. - /// - 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(); - } - } - /// /// 0x027A AllegianceLoginNotification — bumps the revision so a /// polling consumer can observe the event happened; the retail-faithful @@ -130,22 +156,31 @@ public sealed class RuntimeAllegianceState : IDisposable /// public void ApplyLoginNotification(uint characterGuid, bool isLoggedIn) { - ObjectDisposedException.ThrowIf(IsDisposed, this); - lock (_gate) Bump(); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + Bump(); + } } /// 0x01C8 AllegianceUpdateDone — clears the panel busy latch; carries the WeenieError for a failed swear/break. public void ApplyUpdateDone(uint weenieError) { - ObjectDisposedException.ThrowIf(IsDisposed, this); - lock (_gate) Bump(); + lock (_gate) + { + ObjectDisposedException.ThrowIf(_disposed, this); + Bump(); + } } /// 0x0003 AllegianceUpdateAborted — declared by retail but never actually sent by ACE; parsed for forward-compat. 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); } + /// + /// FA2 fix-round MUST-FIX 1: the generation-reset stage + /// () — clears the + /// profile AND drops , mirroring + /// 's + /// clear-and-relatch exactly, and matching + /// 's no-disposed-guard + /// shape (blast SHOULD-FIX 5's precedent — + /// and + /// 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 — the fields + /// are already cleared. + /// + 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 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(); - 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( diff --git a/src/AcDream.Runtime/RuntimeGenerationReset.cs b/src/AcDream.Runtime/RuntimeGenerationReset.cs index c2f1d055..cc17697e 100644 --- a/src/AcDream.Runtime/RuntimeGenerationReset.cs +++ b/src/AcDream.Runtime/RuntimeGenerationReset.cs @@ -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 's class doc). + /// stages. /// Fellowship = 12, - BeginEntityRetirement = 13, - RetireEntities = 14, - DrainHostProjection = 15, - CompleteCanonicalEntities = 16, - CompleteHostProjection = 17, - ChatIdentity = 18, - PlayerSnapshots = 19, - PlayerIdentity = 20, - Complete = 21, + /// + /// 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 + /// ClientAllegianceSystem::OnEndCharacterSession @0x00569FA0 + /// tail-calls AllegianceProfile::Clear at exactly this + /// per-character-session boundary, and the precedent this owner cites + /// () clears AND + /// re-latches, it does not persist. See + /// 's class doc for the full + /// correction (the original "survives reconnect" design was inverted + /// from the precedent it named). + /// + 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 diff --git a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs index 9427eb87..6182a01b 100644 --- a/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs +++ b/src/AcDream.Runtime/Session/LiveSessionEventRouter.cs @@ -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 — diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 30982524..5f7fd456 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -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); } diff --git a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs index e2bdc415..8bf08678 100644 --- a/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs +++ b/tests/AcDream.Runtime.Tests/GameRuntimeTests.cs @@ -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() diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeAllegianceStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeAllegianceStateTests.cs index 588a7d1a..1a336269 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeAllegianceStateTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeAllegianceStateTests.cs @@ -6,12 +6,19 @@ namespace AcDream.Runtime.Tests.Gameplay; /// /// Campaign FA slice FA2 (2026-08-12): lifecycle rules for /// — seeding from the unsolicited -/// AllegianceUpdate push and from a self-gated -/// AllegianceInfoResponse, the HasServerSeed-style latch, the -/// monarch/patron/vassal walk, revision monotonicity, and the "survives -/// reconnect" ownership contract (no RuntimeGenerationReset stage — -/// see for the sibling assertion -/// that Fellowship IS a reset stage and Allegiance is not). +/// AllegianceUpdate push, the HasServerSeed-style latch, the +/// monarch/patron/vassal walk, revision monotonicity, and the +/// generation-reset ownership contract. +/// +/// +/// 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 RuntimeGenerationReset stage (see +/// for the sibling assertion that +/// BOTH Fellowship and Allegiance clear at reset), and no longer seeds from +/// 0x027C AllegianceInfoResponse — retail's own handler for that +/// response is print-only over a stack-local profile. +/// /// 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( () => 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); + } } diff --git a/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs b/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs index a13eebed..0878e08f 100644 --- a/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs +++ b/tests/AcDream.Runtime.Tests/RuntimeGenerationResetTests.cs @@ -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()