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
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

View file

@ -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),

View file

@ -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;
}
/// <summary>

View file

@ -280,26 +280,34 @@ public sealed class SocialPanelController : IRetainedPanelController
_fellowship?.SetPageVisible(_visible && IsShowingFellowship);
/// <summary>
/// MUST-FIX 3 (FA4 fix round, 2026-08-12): called from
/// <see cref="RetailUiRuntime.ResetSessionTransientUi"/> — a seam that
/// already runs on every generation reset — to re-arm the Fellowship
/// page's <c>0x00A6</c> edge-trigger latch. <see cref="SocialPanelController"/>
/// is process-lifetime and the latch
/// (<see cref="SocialFellowshipPageController.SetPageVisible"/>) is
/// edge-triggered on a bool that survives a reconnect unchanged, so
/// without this a still-open Fellowship page never re-declares
/// <c>0x00A6</c> on the fresh session and every fellow's vitals freeze
/// for its remaining lifetime. Clears the latch, then re-evaluates the
/// SAME D4 conjunction <see cref="OnShown"/>/<see cref="OnHidden"/>/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 <see cref="RetailUiRuntime.ResetSessionTransientUi"/>, a seam
/// that runs on every generation reset BEFORE the new session is in
/// world. <see cref="SocialPanelController"/> is process-lifetime and the
/// Fellowship page's <c>0x00A6</c> 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: <c>SetPanelOpen</c> is
/// world-gated, so a re-declaration attempted before world entry is
/// dropped (Inactive). The actual re-declaration is
/// <see cref="RedeclareAfterWorldEntry"/>, wired to the post-world
/// <c>EnteredWorld</c> seam (FA4 re-review REOPEN, 2026-08-12).
/// </summary>
public void ResetSessionDeclaration()
{
_fellowship?.ResetPageVisibleLatch();
UpdateFellowshipPageVisibility();
}
public void ResetSessionDeclaration() => _fellowship?.ResetPageVisibleLatch();
/// <summary>
/// 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>
/// Per-frame poll: the Fellowship/Allegiance empty-state gates (no

View file

@ -749,6 +749,14 @@ public sealed class RetailUiRuntime : IDisposable
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)
{
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 AcceptedResult = new(RuntimeCommandStatus.Accepted, default);
private sealed class FellowshipBindingsBuilder
{
@ -127,6 +128,11 @@ public sealed class SocialFellowshipPageControllerTests
public uint LocalPlayerGuid;
public Func<uint, uint, UiElement?> TemplateResolver = FakeRowResolver;
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(
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);
}
}

View file

@ -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);
/// <summary>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<CharacterOptionId, bool>? currentCharacterOption = 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>();
// 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(
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<bool>? panelOpenInWorld = null)
{
calls ??= new List<string>();
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
}
/// <summary>
/// MUST-FIX 3 (FA4 fix round) — a generation reset (reconnect) while
/// the Fellowship page is still open must re-declare <c>0x00A6</c> 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 <c>0x00A6</c> on
/// 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>
[Fact]
public void ResetSessionDeclaration_ReArms0x00A6_ForAStillOpenFellowshipPage()
public void Reconnect_ReDeclares0x00A6_AfterWorldEntry_NotDuringPreWorldReset()
{
var calls = new List<string>();
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);
}
/// <summary>MUST-FIX 3's counterpart: a reconnect while the panel is
/// closed (or on a different tab) must NOT spuriously declare
/// <c>0x00A6</c> true.</summary>
/// closed (or on a different tab) must NOT declare <c>0x00A6</c> true,
/// even after world entry.</summary>
[Fact]
public void ResetSessionDeclaration_StaysSilent_WhenFellowshipPageIsNotActuallyOpen()
public void Reconnect_StaysSilent_WhenFellowshipPageIsNotActuallyOpen()
{
var calls = new List<string>();
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"));
}