diff --git a/docs/research/2026-08-12-campaign-fa-test-script.md b/docs/research/2026-08-12-campaign-fa-test-script.md index 7b2e58ff..8ce7662b 100644 --- a/docs/research/2026-08-12-campaign-fa-test-script.md +++ b/docs/research/2026-08-12-campaign-fa-test-script.md @@ -328,8 +328,12 @@ behavior when closed — [TWO-CLIENT], the `0x00A6` gate made observable step 7's damage-and-watch check). EXPECTED: vitals update normally on the new session, exactly as before the reconnect. BUG if vitals stay frozen for the rest of the new session — that means `0x00A6` was - never re-declared after the generation reset - (`SocialPanelController.ResetSessionDeclaration`). + never re-declared after the generation reset. (Re-review re-fix + 2026-08-12: the re-declaration fires from the post-world + `EnteredWorld` seam via `SocialPanelController.RedeclareAfterWorldEntry`, + NOT the pre-world `ResetSessionDeclaration`, which only clears the + latch — declaring before world entry was silently dropped by the + world-gated command, the original defect.) 10. **Your OWN row's vitals should always update** (your own vitals are driven by the existing player-vitals pipeline, not the fellowship `0x02C0` stream) — this is expected and not a sign that `0x00A6` is diff --git a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs index f0b778f4..4e21d481 100644 --- a/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs +++ b/src/AcDream.App/Net/LiveSessionRuntimeFactory.cs @@ -157,7 +157,17 @@ internal sealed class LiveSessionRuntimeFactory ClearCombat: _domain.Actions.Combat.Clear), EnteredWorld: new( SetActiveCharacter: _interaction.Settings.SetActiveCharacter, - RestoreLayout: () => _ui.RetailUi?.RestoreLayout(), + RestoreLayout: () => + { + _ui.RetailUi?.RestoreLayout(); + // MUST-FIX 3 re-fix (FA4 re-review REOPEN): re-declare a + // still-open Fellowship page's 0x00A6 now we are in world — + // RestoreLayout is the post-world UI-restore moment, and + // this is idempotent if RestoreLayout already re-showed the + // page (the latch is already set). ResetSessionTransientUi + // (pre-world) only cleared the latch. + _ui.RetailUi?.RedeclareSocialPanelAfterWorldEntry(); + }, SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(), LoadCharacterSettings: _interaction.Settings.LoadCharacterContext, ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm), diff --git a/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs index 825fdc39..f91aba45 100644 --- a/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs +++ b/src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs @@ -533,8 +533,18 @@ public sealed class SocialFellowshipPageController public void SetPageVisible(bool visible) { if (_pageVisible == visible) return; - _pageVisible = visible; - _bindings.SetPanelOpen(visible); + // MUST-FIX 3 re-fix (FA4 re-review REOPEN, 2026-08-12): advance the + // edge-trigger latch ONLY when the 0x00A6 declaration was actually + // published (Accepted). A generation reset runs BEFORE the + // reconnected session is in world, so this command is world-gated and + // returns Inactive there — publishing nothing. The original fix + // latched unconditionally, leaving `_pageVisible = true` while the + // fresh server was never told, so the intended post-world + // re-declaration became a no-op (already latched) and fellow vitals + // stayed frozen. Leaving the latch untouched on a dropped publish + // lets RedeclareAfterWorldEntry retry exactly once, in world. + if (_bindings.SetPanelOpen(visible).Status == RuntimeCommandStatus.Accepted) + _pageVisible = visible; } /// diff --git a/src/AcDream.App/UI/Layout/SocialPanelController.cs b/src/AcDream.App/UI/Layout/SocialPanelController.cs index 65692704..b6e7e60d 100644 --- a/src/AcDream.App/UI/Layout/SocialPanelController.cs +++ b/src/AcDream.App/UI/Layout/SocialPanelController.cs @@ -280,26 +280,34 @@ public sealed class SocialPanelController : IRetainedPanelController _fellowship?.SetPageVisible(_visible && IsShowingFellowship); /// - /// MUST-FIX 3 (FA4 fix round, 2026-08-12): called from - /// — a seam that - /// already runs on every generation reset — to re-arm the Fellowship - /// page's 0x00A6 edge-trigger latch. - /// is process-lifetime and the latch - /// () is - /// edge-triggered on a bool that survives a reconnect unchanged, so - /// without this a still-open Fellowship page never re-declares - /// 0x00A6 on the fresh session and every fellow's vitals freeze - /// for its remaining lifetime. Clears the latch, then re-evaluates the - /// SAME D4 conjunction //tab - /// switches use — a still-open Fellowship page re-declares; a closed or - /// other-tab page correctly stays silent (the conjunction is false, so - /// the now-cleared latch is simply left at its default). + /// MUST-FIX 3 (FA4 fix round, 2026-08-12) — the PRE-WORLD half. Called + /// from , a seam + /// that runs on every generation reset BEFORE the new session is in + /// world. is process-lifetime and the + /// Fellowship page's 0x00A6 latch survives a reconnect unchanged, + /// so it must be re-armed for the fresh session — but ONLY CLEARED here. + /// It must NOT re-declare from this seam: SetPanelOpen is + /// world-gated, so a re-declaration attempted before world entry is + /// dropped (Inactive). The actual re-declaration is + /// , wired to the post-world + /// EnteredWorld seam (FA4 re-review REOPEN, 2026-08-12). /// - public void ResetSessionDeclaration() - { - _fellowship?.ResetPageVisibleLatch(); - UpdateFellowshipPageVisibility(); - } + public void ResetSessionDeclaration() => _fellowship?.ResetPageVisibleLatch(); + + /// + /// MUST-FIX 3 (FA4 fix round) — the POST-WORLD half. Wired to the + /// LiveSession EnteredWorld seam, so it runs after a (re)connect + /// has entered world and SetPanelOpen is Accepted. Re-evaluates + /// the same D4 conjunction // + /// tab switches use: a still-open Fellowship page re-declares + /// 0x00A6 to the fresh server and fellow vitals resume; a closed + /// or other-tab page stays silent (the conjunction is false, so the + /// latch cleared is left at its + /// default). Idempotent — if a persisted layout already re-showed the + /// page and re-declared, the latch is already set + /// and this is a no-op. + /// + public void RedeclareAfterWorldEntry() => UpdateFellowshipPageVisibility(); /// /// Per-frame poll: the Fellowship/Allegiance empty-state gates (no diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index e82a3c01..16fc5f6e 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -749,6 +749,14 @@ public sealed class RetailUiRuntime : IDisposable SocialPanelController?.ResetSessionDeclaration(); } + /// MUST-FIX 3 re-fix (FA4 re-review REOPEN, 2026-08-12): wired to + /// the LiveSession EnteredWorld seam so a still-open Fellowship + /// page re-declares its 0x00A6 panel-open state AFTER a (re)connect + /// is in world — runs pre-world and + /// only cleared the latch, and SetPanelOpen is world-gated. + public void RedeclareSocialPanelAfterWorldEntry() => + SocialPanelController?.RedeclareAfterWorldEntry(); + public void UpdateCursor(IEnumerable mice) { CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root); diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs index 45a4c100..1dcc60ad 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SocialFellowshipPageControllerTests.cs @@ -116,6 +116,7 @@ public sealed class SocialFellowshipPageControllerTests } private static readonly RuntimeCommandResult InactiveResult = new(RuntimeCommandStatus.Inactive, default); + private static readonly RuntimeCommandResult AcceptedResult = new(RuntimeCommandStatus.Accepted, default); private sealed class FellowshipBindingsBuilder { @@ -127,6 +128,11 @@ public sealed class SocialFellowshipPageControllerTests public uint LocalPlayerGuid; public Func TemplateResolver = FakeRowResolver; public Func ResolveString = static (_, _) => null; + // SetPanelOpen (0x00A6) is the one command whose CONTROLLER behavior + // depends on the result status (MUST-FIX 3 re-fix: the latch advances + // only on Accepted). Default in-world so the edge-trigger tests model + // production; a test flips this false to simulate a pre-world reset. + public bool PanelOpenInWorld = true; public SocialFellowshipPageController.Bindings Build() => new( Snapshot: () => Snapshot, @@ -138,7 +144,7 @@ public sealed class SocialFellowshipPageControllerTests Quit: disband => { Calls.Add($"quit:{disband}"); return InactiveResult; }, AssignLeader: guid => { Calls.Add($"assign-leader:{guid:X8}"); return InactiveResult; }, SetOpen: isOpen => { Calls.Add($"set-open:{isOpen}"); return InactiveResult; }, - SetPanelOpen: panelOpen => { Calls.Add($"set-panel-open:{panelOpen}"); return InactiveResult; }, + SetPanelOpen: panelOpen => { Calls.Add($"set-panel-open:{panelOpen}"); return PanelOpenInWorld ? AcceptedResult : InactiveResult; }, Selection: Selection, LocalPlayerGuid: () => LocalPlayerGuid, CurrentCharacterOption: id => Options.TryGetValue(id, out bool v) && v, @@ -758,4 +764,32 @@ public sealed class SocialFellowshipPageControllerTests controller.SetPageVisible(false); Assert.Contains("set-panel-open:False", b.Calls); } + + [Fact] + public void SetPageVisible_DoesNotLatch_WhenDeclarationDropped_SoItRetriesInWorld() + { + // MUST-FIX 3 re-fix (FA4 re-review REOPEN): a declaration attempted + // before world entry is dropped (Inactive). The latch must NOT advance + // on a dropped publish — otherwise the in-world retry is deduplicated + // away and the server never learns the panel is open (fellow vitals + // freeze). This is the widget-level root of the reconnect bug. + UiElement root = BuildPageRoot(out _, out _); + var b = new FellowshipBindingsBuilder + { + Snapshot = new RuntimeFellowshipSnapshot { IsInFellowship = true }, + PanelOpenInWorld = false, // pre-world: SetPanelOpen returns Inactive + }; + SocialFellowshipPageController controller = SocialFellowshipPageController.Bind(root, b.Build())!; + b.Calls.Clear(); + + controller.SetPageVisible(true); + Assert.Contains("set-panel-open:True", b.Calls); // attempted, dropped + b.Calls.Clear(); + + // In world now: the SAME visible=true re-attempts (not deduplicated, + // because the dropped attempt never latched) and this time it sticks. + b.PanelOpenInWorld = true; + controller.SetPageVisible(true); + Assert.Contains("set-panel-open:True", b.Calls); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs index 3640f260..58dce906 100644 --- a/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/SocialPanelControllerTests.cs @@ -19,6 +19,8 @@ public sealed class SocialPanelControllerTests { private static readonly RuntimeCommandResult InactiveResult = new(RuntimeCommandStatus.Inactive, default); + private static readonly RuntimeCommandResult AcceptedResult = + new(RuntimeCommandStatus.Accepted, default); /// Campaign FA slice FA4: the Fellowship page's full read/write /// seam. Every command records its call (by name) into @@ -35,9 +37,14 @@ public sealed class SocialPanelControllerTests uint localPlayerGuid = 0u, Func? currentCharacterOption = null, Func? templateResolver = null, - Func? resolveString = null) + Func? resolveString = null, + Func? panelOpenInWorld = null) { calls ??= new List(); + // SetPanelOpen (0x00A6) is world-gated in production; only its result + // status feeds the MUST-FIX 3 latch. Default in-world (Accepted) so + // the D4-conjunction tests model production; a reconnect test flips it. + Func inWorld = panelOpenInWorld ?? (static () => true); return new SocialFellowshipPageController.Bindings( Snapshot: () => snapshot, Members: () => members ?? [], @@ -48,7 +55,7 @@ public sealed class SocialPanelControllerTests Quit: disband => { calls.Add($"fellowship-quit:{disband}"); return InactiveResult; }, AssignLeader: guid => { calls.Add($"fellowship-assign-leader:{guid:X8}"); return InactiveResult; }, SetOpen: isOpen => { calls.Add($"fellowship-set-open:{isOpen}"); return InactiveResult; }, - SetPanelOpen: panelOpen => { calls.Add($"fellowship-set-panel-open:{panelOpen}"); return InactiveResult; }, + SetPanelOpen: panelOpen => { calls.Add($"fellowship-set-panel-open:{panelOpen}"); return inWorld() ? AcceptedResult : InactiveResult; }, Selection: selection ?? new SelectionState(), LocalPlayerGuid: () => localPlayerGuid, CurrentCharacterOption: currentCharacterOption ?? (_ => false), @@ -61,12 +68,13 @@ public sealed class SocialPanelControllerTests RuntimeFellowshipSnapshot fellowship = default, RuntimeAllegianceSnapshot allegiance = default, FriendsState? friends = null, - SquelchState? squelch = null) + SquelchState? squelch = null, + Func? panelOpenInWorld = null) { calls ??= new List(); return new SocialPanelController.Callbacks( Toggle: () => calls.Add("toggle"), - Fellowship: MakeFellowshipBindings(calls, fellowship), + Fellowship: MakeFellowshipBindings(calls, fellowship, panelOpenInWorld: panelOpenInWorld), AllegianceSnapshot: () => allegiance, Friends: friends ?? new FriendsState(), Squelch: squelch ?? new SquelchState(), @@ -538,49 +546,73 @@ public sealed class SocialPanelControllerTests } /// - /// MUST-FIX 3 (FA4 fix round) — a generation reset (reconnect) while - /// the Fellowship page is still open must re-declare 0x00A6 on - /// the fresh session, not silently stay latched from the old one. + /// MUST-FIX 3 (FA4 fix round) + its re-review REOPEN re-fix: a reconnect + /// while the Fellowship page stays open must re-declare 0x00A6 on + /// the fresh session — but ONLY AFTER world entry. The generation reset + /// () runs + /// BEFORE the new session is in world, where SetPanelOpen is + /// dropped (Inactive); it must only clear the latch. The re-declaration + /// is , wired + /// to the post-world EnteredWorld seam. The original fix declared + /// from the pre-world reset — the fake recorded a send the real + /// world-gated command would have dropped, so the roster stayed frozen. /// [Fact] - public void ResetSessionDeclaration_ReArms0x00A6_ForAStillOpenFellowshipPage() + public void Reconnect_ReDeclares0x00A6_AfterWorldEntry_NotDuringPreWorldReset() { var calls = new List(); + bool inWorld = true; ImportedLayout layout = FixtureLoader.LoadSocialPanelHost(); SocialPanelController? controller = SocialPanelController.Bind( layout, - MakeCallbacks(calls, fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true })); + MakeCallbacks( + calls, + fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true }, + panelOpenInWorld: () => inWorld)); Assert.NotNull(controller); controller!.ActivateTabs(); controller.OnShown(); controller.ShowFellowship(); - Assert.Contains("fellowship-set-panel-open:True", calls); + Assert.Contains("fellowship-set-panel-open:True", calls); // declared in world calls.Clear(); - // Simulate a reconnect: generation reset while the panel stays open - // on the Fellowship tab (nothing else in this controller changes). + // Reconnect: the generation reset runs BEFORE the new session is in + // world. The pre-world reset must leave NO published 0x00A6 (the + // REOPEN bug latched a dropped send here and never retried). + inWorld = false; controller.ResetSessionDeclaration(); + Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open")); + // World entry: the post-world seam re-declares, now Accepted. + inWorld = true; + controller.RedeclareAfterWorldEntry(); Assert.Contains("fellowship-set-panel-open:True", calls); } /// MUST-FIX 3's counterpart: a reconnect while the panel is - /// closed (or on a different tab) must NOT spuriously declare - /// 0x00A6 true. + /// closed (or on a different tab) must NOT declare 0x00A6 true, + /// even after world entry. [Fact] - public void ResetSessionDeclaration_StaysSilent_WhenFellowshipPageIsNotActuallyOpen() + public void Reconnect_StaysSilent_WhenFellowshipPageIsNotActuallyOpen() { var calls = new List(); + bool inWorld = true; ImportedLayout layout = FixtureLoader.LoadSocialPanelHost(); SocialPanelController? controller = SocialPanelController.Bind( layout, - MakeCallbacks(calls, fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true })); + MakeCallbacks( + calls, + fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true }, + panelOpenInWorld: () => inWorld)); Assert.NotNull(controller); - controller!.ActivateTabs(); // default tab: Allegiance + controller!.ActivateTabs(); // default tab: Allegiance (Fellowship not active) controller.OnShown(); calls.Clear(); + inWorld = false; controller.ResetSessionDeclaration(); + inWorld = true; + controller.RedeclareAfterWorldEntry(); Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open")); }