fix(ui): FA4 re-review REOPEN — re-declare 0x00A6 from the post-world seam, not the pre-world reset

The FA4 fix round's MUST-FIX 3 placed the 0x00A6 reconnect re-arm at the
wrong lifecycle point (re-review 8bbceff5): ResetSessionTransientUi runs
via the SessionDialogs reset stage BEFORE _inWorld=true, so SetPanelOpen
(world-gated, Validate requireWorld:true) returned Inactive and published
nothing — yet _pageVisible was latched true anyway, so no later hook
re-declared and fellow vitals stayed frozen for the whole new session.
The unit test passed only because the fake recorded unconditionally.

Two-part fix, both retail-faithful mechanisms not suppressions:
- SocialFellowshipPageController.SetPageVisible advances the edge-trigger
  latch ONLY when the declaration is Accepted (published), so a dropped
  pre-world send leaves the latch clear and a later attempt retries.
- ResetSessionDeclaration (pre-world) now ONLY clears the latch; the new
  RedeclareAfterWorldEntry fires from the LiveSession EnteredWorld seam
  (wired via RestoreLayout, idempotent if a persisted layout already
  re-showed the page) so a still-open Fellowship page re-declares 0x00A6
  in world and vitals resume.

Regression pins that actually catch it (the prior test could not):
- SetPageVisible_DoesNotLatch_WhenDeclarationDropped_SoItRetriesInWorld
  (widget-level root, world-gated fake);
- Reconnect_ReDeclares0x00A6_AfterWorldEntry_NotDuringPreWorldReset +
  Reconnect_StaysSilent_WhenFellowshipPageIsNotActuallyOpen (panel-level,
  world-gated). RED-verified: reintroducing the pre-world declaration
  fails the reconnect test.

Full Release suite: 13,286 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-12 07:59:16 +02:00
parent 8bbceff594
commit 04161defd8
7 changed files with 148 additions and 42 deletions

View file

@ -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 step 7's damage-and-watch check). EXPECTED: vitals update normally on
the new session, exactly as before the reconnect. BUG if vitals stay the new session, exactly as before the reconnect. BUG if vitals stay
frozen for the rest of the new session — that means `0x00A6` was frozen for the rest of the new session — that means `0x00A6` was
never re-declared after the generation reset never re-declared after the generation reset. (Re-review re-fix
(`SocialPanelController.ResetSessionDeclaration`). 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 10. **Your OWN row's vitals should always update** (your own vitals are
driven by the existing player-vitals pipeline, not the fellowship driven by the existing player-vitals pipeline, not the fellowship
`0x02C0` stream) — this is expected and not a sign that `0x00A6` is `0x02C0` stream) — this is expected and not a sign that `0x00A6` is

View file

@ -157,7 +157,17 @@ internal sealed class LiveSessionRuntimeFactory
ClearCombat: _domain.Actions.Combat.Clear), ClearCombat: _domain.Actions.Combat.Clear),
EnteredWorld: new( EnteredWorld: new(
SetActiveCharacter: _interaction.Settings.SetActiveCharacter, 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(), SyncToolbar: () => _ui.RetailUi?.SyncToolbarWindowButtons(),
LoadCharacterSettings: _interaction.Settings.LoadCharacterContext, LoadCharacterSettings: _interaction.Settings.LoadCharacterContext,
ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm), ArmPlayerModeAutoEntry: _interaction.PlayerModeAutoEntry.Arm),

View file

@ -533,8 +533,18 @@ public sealed class SocialFellowshipPageController
public void SetPageVisible(bool visible) public void SetPageVisible(bool visible)
{ {
if (_pageVisible == visible) return; if (_pageVisible == visible) return;
_pageVisible = visible; // MUST-FIX 3 re-fix (FA4 re-review REOPEN, 2026-08-12): advance the
_bindings.SetPanelOpen(visible); // 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;
} }
/// <summary> /// <summary>

View file

@ -280,26 +280,34 @@ public sealed class SocialPanelController : IRetainedPanelController
_fellowship?.SetPageVisible(_visible && IsShowingFellowship); _fellowship?.SetPageVisible(_visible && IsShowingFellowship);
/// <summary> /// <summary>
/// MUST-FIX 3 (FA4 fix round, 2026-08-12): called from /// MUST-FIX 3 (FA4 fix round, 2026-08-12) — the PRE-WORLD half. Called
/// <see cref="RetailUiRuntime.ResetSessionTransientUi"/> — a seam that /// from <see cref="RetailUiRuntime.ResetSessionTransientUi"/>, a seam
/// already runs on every generation reset — to re-arm the Fellowship /// that runs on every generation reset BEFORE the new session is in
/// page's <c>0x00A6</c> edge-trigger latch. <see cref="SocialPanelController"/> /// world. <see cref="SocialPanelController"/> is process-lifetime and the
/// is process-lifetime and the latch /// Fellowship page's <c>0x00A6</c> latch survives a reconnect unchanged,
/// (<see cref="SocialFellowshipPageController.SetPageVisible"/>) is /// so it must be re-armed for the fresh session — but ONLY CLEARED here.
/// edge-triggered on a bool that survives a reconnect unchanged, so /// It must NOT re-declare from this seam: <c>SetPanelOpen</c> is
/// without this a still-open Fellowship page never re-declares /// world-gated, so a re-declaration attempted before world entry is
/// <c>0x00A6</c> on the fresh session and every fellow's vitals freeze /// dropped (Inactive). The actual re-declaration is
/// for its remaining lifetime. Clears the latch, then re-evaluates the /// <see cref="RedeclareAfterWorldEntry"/>, wired to the post-world
/// SAME D4 conjunction <see cref="OnShown"/>/<see cref="OnHidden"/>/tab /// <c>EnteredWorld</c> seam (FA4 re-review REOPEN, 2026-08-12).
/// 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).
/// </summary> /// </summary>
public void ResetSessionDeclaration() public void ResetSessionDeclaration() => _fellowship?.ResetPageVisibleLatch();
{
_fellowship?.ResetPageVisibleLatch(); /// <summary>
UpdateFellowshipPageVisibility(); /// MUST-FIX 3 (FA4 fix round) — the POST-WORLD half. Wired to the
} /// LiveSession <c>EnteredWorld</c> seam, so it runs after a (re)connect
/// has entered world and <c>SetPanelOpen</c> is Accepted. Re-evaluates
/// the same D4 conjunction <see cref="OnShown"/>/<see cref="OnHidden"/>/
/// tab switches use: a still-open Fellowship page re-declares
/// <c>0x00A6</c> to the fresh server and fellow vitals resume; a closed
/// or other-tab page stays silent (the conjunction is false, so the
/// latch <see cref="ResetSessionDeclaration"/> cleared is left at its
/// default). Idempotent — if a persisted layout already re-showed the
/// page and <see cref="OnShown"/> re-declared, the latch is already set
/// and this is a no-op.
/// </summary>
public void RedeclareAfterWorldEntry() => UpdateFellowshipPageVisibility();
/// <summary> /// <summary>
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no /// Per-frame poll: the Fellowship/Allegiance empty-state gates (no

View file

@ -749,6 +749,14 @@ public sealed class RetailUiRuntime : IDisposable
SocialPanelController?.ResetSessionDeclaration(); SocialPanelController?.ResetSessionDeclaration();
} }
/// <summary>MUST-FIX 3 re-fix (FA4 re-review REOPEN, 2026-08-12): wired to
/// the LiveSession <c>EnteredWorld</c> seam so a still-open Fellowship
/// page re-declares its <c>0x00A6</c> panel-open state AFTER a (re)connect
/// is in world — <see cref="ResetSessionTransientUi"/> runs pre-world and
/// only cleared the latch, and <c>SetPanelOpen</c> is world-gated.</summary>
public void RedeclareSocialPanelAfterWorldEntry() =>
SocialPanelController?.RedeclareAfterWorldEntry();
public void UpdateCursor(IEnumerable<IMouse> mice) public void UpdateCursor(IEnumerable<IMouse> mice)
{ {
CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root); CursorFeedback feedback = _bindings.Cursor.Feedback.Update(Host.Root);

View file

@ -116,6 +116,7 @@ public sealed class SocialFellowshipPageControllerTests
} }
private static readonly RuntimeCommandResult InactiveResult = new(RuntimeCommandStatus.Inactive, default); private static readonly RuntimeCommandResult InactiveResult = new(RuntimeCommandStatus.Inactive, default);
private static readonly RuntimeCommandResult AcceptedResult = new(RuntimeCommandStatus.Accepted, default);
private sealed class FellowshipBindingsBuilder private sealed class FellowshipBindingsBuilder
{ {
@ -127,6 +128,11 @@ public sealed class SocialFellowshipPageControllerTests
public uint LocalPlayerGuid; public uint LocalPlayerGuid;
public Func<uint, uint, UiElement?> TemplateResolver = FakeRowResolver; public Func<uint, uint, UiElement?> TemplateResolver = FakeRowResolver;
public Func<uint, uint, string?> ResolveString = static (_, _) => null; public Func<uint, uint, string?> 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( public SocialFellowshipPageController.Bindings Build() => new(
Snapshot: () => Snapshot, Snapshot: () => Snapshot,
@ -138,7 +144,7 @@ public sealed class SocialFellowshipPageControllerTests
Quit: disband => { Calls.Add($"quit:{disband}"); return InactiveResult; }, Quit: disband => { Calls.Add($"quit:{disband}"); return InactiveResult; },
AssignLeader: guid => { Calls.Add($"assign-leader:{guid:X8}"); return InactiveResult; }, AssignLeader: guid => { Calls.Add($"assign-leader:{guid:X8}"); return InactiveResult; },
SetOpen: isOpen => { Calls.Add($"set-open:{isOpen}"); 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, Selection: Selection,
LocalPlayerGuid: () => LocalPlayerGuid, LocalPlayerGuid: () => LocalPlayerGuid,
CurrentCharacterOption: id => Options.TryGetValue(id, out bool v) && v, CurrentCharacterOption: id => Options.TryGetValue(id, out bool v) && v,
@ -758,4 +764,32 @@ public sealed class SocialFellowshipPageControllerTests
controller.SetPageVisible(false); controller.SetPageVisible(false);
Assert.Contains("set-panel-open:False", b.Calls); 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);
}
} }

View file

@ -19,6 +19,8 @@ public sealed class SocialPanelControllerTests
{ {
private static readonly RuntimeCommandResult InactiveResult = private static readonly RuntimeCommandResult InactiveResult =
new(RuntimeCommandStatus.Inactive, default); new(RuntimeCommandStatus.Inactive, default);
private static readonly RuntimeCommandResult AcceptedResult =
new(RuntimeCommandStatus.Accepted, default);
/// <summary>Campaign FA slice FA4: the Fellowship page's full read/write /// <summary>Campaign FA slice FA4: the Fellowship page's full read/write
/// seam. Every command records its call (by name) into /// seam. Every command records its call (by name) into
@ -35,9 +37,14 @@ public sealed class SocialPanelControllerTests
uint localPlayerGuid = 0u, uint localPlayerGuid = 0u,
Func<CharacterOptionId, bool>? currentCharacterOption = null, Func<CharacterOptionId, bool>? currentCharacterOption = null,
Func<uint, uint, UiElement?>? templateResolver = null, Func<uint, uint, UiElement?>? templateResolver = null,
Func<uint, uint, string?>? resolveString = null) Func<uint, uint, string?>? resolveString = null,
Func<bool>? panelOpenInWorld = null)
{ {
calls ??= new List<string>(); calls ??= new List<string>();
// 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<bool> inWorld = panelOpenInWorld ?? (static () => true);
return new SocialFellowshipPageController.Bindings( return new SocialFellowshipPageController.Bindings(
Snapshot: () => snapshot, Snapshot: () => snapshot,
Members: () => members ?? [], Members: () => members ?? [],
@ -48,7 +55,7 @@ public sealed class SocialPanelControllerTests
Quit: disband => { calls.Add($"fellowship-quit:{disband}"); return InactiveResult; }, Quit: disband => { calls.Add($"fellowship-quit:{disband}"); return InactiveResult; },
AssignLeader: guid => { calls.Add($"fellowship-assign-leader:{guid:X8}"); return InactiveResult; }, AssignLeader: guid => { calls.Add($"fellowship-assign-leader:{guid:X8}"); return InactiveResult; },
SetOpen: isOpen => { calls.Add($"fellowship-set-open:{isOpen}"); 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(), Selection: selection ?? new SelectionState(),
LocalPlayerGuid: () => localPlayerGuid, LocalPlayerGuid: () => localPlayerGuid,
CurrentCharacterOption: currentCharacterOption ?? (_ => false), CurrentCharacterOption: currentCharacterOption ?? (_ => false),
@ -61,12 +68,13 @@ public sealed class SocialPanelControllerTests
RuntimeFellowshipSnapshot fellowship = default, RuntimeFellowshipSnapshot fellowship = default,
RuntimeAllegianceSnapshot allegiance = default, RuntimeAllegianceSnapshot allegiance = default,
FriendsState? friends = null, FriendsState? friends = null,
SquelchState? squelch = null) SquelchState? squelch = null,
Func<bool>? panelOpenInWorld = null)
{ {
calls ??= new List<string>(); calls ??= new List<string>();
return new SocialPanelController.Callbacks( return new SocialPanelController.Callbacks(
Toggle: () => calls.Add("toggle"), Toggle: () => calls.Add("toggle"),
Fellowship: MakeFellowshipBindings(calls, fellowship), Fellowship: MakeFellowshipBindings(calls, fellowship, panelOpenInWorld: panelOpenInWorld),
AllegianceSnapshot: () => allegiance, AllegianceSnapshot: () => allegiance,
Friends: friends ?? new FriendsState(), Friends: friends ?? new FriendsState(),
Squelch: squelch ?? new SquelchState(), Squelch: squelch ?? new SquelchState(),
@ -538,49 +546,73 @@ public sealed class SocialPanelControllerTests
} }
/// <summary> /// <summary>
/// MUST-FIX 3 (FA4 fix round) — a generation reset (reconnect) while /// MUST-FIX 3 (FA4 fix round) + its re-review REOPEN re-fix: a reconnect
/// the Fellowship page is still open must re-declare <c>0x00A6</c> on /// while the Fellowship page stays open must re-declare <c>0x00A6</c> on
/// the fresh session, not silently stay latched from the old one. /// the fresh session — but ONLY AFTER world entry. The generation reset
/// (<see cref="SocialPanelController.ResetSessionDeclaration"/>) runs
/// BEFORE the new session is in world, where <c>SetPanelOpen</c> is
/// dropped (Inactive); it must only clear the latch. The re-declaration
/// is <see cref="SocialPanelController.RedeclareAfterWorldEntry"/>, wired
/// to the post-world <c>EnteredWorld</c> 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.
/// </summary> /// </summary>
[Fact] [Fact]
public void ResetSessionDeclaration_ReArms0x00A6_ForAStillOpenFellowshipPage() public void Reconnect_ReDeclares0x00A6_AfterWorldEntry_NotDuringPreWorldReset()
{ {
var calls = new List<string>(); var calls = new List<string>();
bool inWorld = true;
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost(); ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind( SocialPanelController? controller = SocialPanelController.Bind(
layout, layout,
MakeCallbacks(calls, fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true })); MakeCallbacks(
calls,
fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true },
panelOpenInWorld: () => inWorld));
Assert.NotNull(controller); Assert.NotNull(controller);
controller!.ActivateTabs(); controller!.ActivateTabs();
controller.OnShown(); controller.OnShown();
controller.ShowFellowship(); controller.ShowFellowship();
Assert.Contains("fellowship-set-panel-open:True", calls); Assert.Contains("fellowship-set-panel-open:True", calls); // declared in world
calls.Clear(); calls.Clear();
// Simulate a reconnect: generation reset while the panel stays open // Reconnect: the generation reset runs BEFORE the new session is in
// on the Fellowship tab (nothing else in this controller changes). // 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(); 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); Assert.Contains("fellowship-set-panel-open:True", calls);
} }
/// <summary>MUST-FIX 3's counterpart: a reconnect while the panel is /// <summary>MUST-FIX 3's counterpart: a reconnect while the panel is
/// closed (or on a different tab) must NOT spuriously declare /// closed (or on a different tab) must NOT declare <c>0x00A6</c> true,
/// <c>0x00A6</c> true.</summary> /// even after world entry.</summary>
[Fact] [Fact]
public void ResetSessionDeclaration_StaysSilent_WhenFellowshipPageIsNotActuallyOpen() public void Reconnect_StaysSilent_WhenFellowshipPageIsNotActuallyOpen()
{ {
var calls = new List<string>(); var calls = new List<string>();
bool inWorld = true;
ImportedLayout layout = FixtureLoader.LoadSocialPanelHost(); ImportedLayout layout = FixtureLoader.LoadSocialPanelHost();
SocialPanelController? controller = SocialPanelController.Bind( SocialPanelController? controller = SocialPanelController.Bind(
layout, layout,
MakeCallbacks(calls, fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true })); MakeCallbacks(
calls,
fellowship: new RuntimeFellowshipSnapshot { IsInFellowship = true },
panelOpenInWorld: () => inWorld));
Assert.NotNull(controller); Assert.NotNull(controller);
controller!.ActivateTabs(); // default tab: Allegiance controller!.ActivateTabs(); // default tab: Allegiance (Fellowship not active)
controller.OnShown(); controller.OnShown();
calls.Clear(); calls.Clear();
inWorld = false;
controller.ResetSessionDeclaration(); controller.ResetSessionDeclaration();
inWorld = true;
controller.RedeclareAfterWorldEntry();
Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open")); Assert.DoesNotContain(calls, c => c.StartsWith("fellowship-set-panel-open"));
} }