From 1be0e826ddfc578f6d8dd022181049bb1ec968bf Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 9 Jun 2026 17:55:07 +0700 Subject: [PATCH 1/2] =?UTF-8?q?fix(viewer):=20cold-open=20wait=20affordanc?= =?UTF-8?q?e=20=E2=80=94=20render=20a=20watchable=20first-beat=20spinner?= =?UTF-8?q?=20(input-lock=20give-up)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RRI 2026-06-09 (vm2-newbie 'input locked 9+ min, no feedback' + the veteran give-up): through the minutes-long cold-open the action bar is locked (live but is_live_view desynced) and the chronicle is empty, so the screen reads as a frozen crash. There was no affordance for the cold-open wait — only the player-move pending/stuck beats render a spinner. Fix: a pure computeColdOpenAwaiting selector (live session + nothing landed yet: empty chronicle, empty party, no pending/stuck beat) gates rendering the EXISTING first-beat DmNarratingBeat (its 'the first beat can take a few minutes — hang tight' copy). The empty 'No moves yet' line is suppressed while it shows. Pure read-model; clears the instant a beat or the party arrives, or a player move arms a pending beat. Mirrors the firstBeat=true/streaming=false path already proven for the post-move first beat. Tests: 7 ColdOpenAwaitingTests (the real live+is_live_view=false frame, the negatives: seated party, existing log, pending/stuck precedence, surface-not-ready). recovery-timing 21 passed; full viewer suite green. --- viewer/openworlds/screen-table.jsx | 47 +++++++++++++++++++++++++++- viewer/tests/test_recovery_timing.py | 44 ++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index 3d526314..2e075e3c 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -481,6 +481,25 @@ function computePlayGate({ surfaceStatus, appStatus, pendingStuck }) { // Exposed for tests/devtools introspection (additive — the component calls the local fn directly). if (typeof window !== "undefined") window.computePlayGate = computePlayGate; +// The cold-open wait affordance gate (RRI 2026-06-09 input-lock give-up). True when the session is +// LIVE (a DM is attached) but NOTHING has landed yet — no chronicle beats, no seated party, and no +// in-flight/stuck pending beat already showing a spinner. The disabled action bar + empty chronicle +// otherwise read as a frozen crash through the minutes-long cold-open (newbie: "input locked 9+ min, +// no feedback"); this lets the table render the existing first-beat DmNarratingBeat so the wait is +// watchable. Pure read-model derivation; clears the instant a beat or the party arrives, or a player +// move arms a pending beat. NOTE the live-OR-isLiveView: the real frame is live=true with +// is_live_view=false (a desync the heal hasn't caught), which is exactly when the bar locks. +function computeColdOpenAwaiting({ surfaceStatus, live, isLiveView, pendingActive, pendingStuck, visibleLogLength, partyEmpty }) { + return Boolean( + surfaceStatus === "ready" && + (live || isLiveView) && + !pendingActive && !pendingStuck && + visibleLogLength === 0 && + partyEmpty + ); +} +if (typeof window !== "undefined") window.computeColdOpenAwaiting = computeColdOpenAwaiting; + function ScreenTable({ onNavigate, state, setState, liveSession }) { const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : []; const activeCampaign = @@ -726,6 +745,23 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { // (alive) rather than the generic "Narrating…" — so the locked bar matches the obviously-alive // chronicle indicator and never looks like the app is wedged. const pendingFirstBeat = Boolean(pendingActive && pending.firstBeat); + // The cold-open wait: render a watchable "DM is opening your world" beat instead of a frozen + // empty chronicle + locked bar (the input-lock give-up). Derived from the read-model only. + const coldOpenAwaiting = computeColdOpenAwaiting({ + surfaceStatus, + live: Boolean(surface && surface.live), + isLiveView: Boolean(surface && surface.is_live_view), + pendingActive, + pendingStuck, + visibleLogLength: visibleLog.length, + partyEmpty: !surface || !Array.isArray(surface.party) || surface.party.length === 0, + }); + const coldOpenSinceRef = React.useRef(null); + if (coldOpenAwaiting) { + if (coldOpenSinceRef.current == null) coldOpenSinceRef.current = Date.now(); + } else { + coldOpenSinceRef.current = null; + } // #344: remember the move that is currently in flight so the "Try again" recovery (shown when a // turn goes `stuck`) can actually RE-POST it. The first submit clears the input box (setInput("")), // so by the time the bar re-opens stuck the box is empty — without the original move stored, the @@ -1061,7 +1097,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { > - )) :
No moves yet
} + )) : coldOpenAwaiting ? null :
No moves yet
} {pendingActive && (
{/* #G3-UX: `streaming` (set by useLiveSession's notePendingProgress the moment live @@ -1078,6 +1114,15 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
)} + {coldOpenAwaiting && ( + /* The cold-open wait: the DM is composing the opening but nothing has landed and the + bar is locked — render the first-beat narrating affordance so the minutes-long wait + reads as a turn-in-progress, not a crash (the input-lock give-up). Mutually exclusive + with the pending beats above (the selector excludes pendingActive/pendingStuck). */ +
+ +
+ )} {/* #G3: PRIMARY action palette — promoted into the MAIN column, anchored in the Chronicle diff --git a/viewer/tests/test_recovery_timing.py b/viewer/tests/test_recovery_timing.py index f022e426..3addf471 100644 --- a/viewer/tests/test_recovery_timing.py +++ b/viewer/tests/test_recovery_timing.py @@ -396,3 +396,47 @@ def test_ready_surface_and_status_is_unblocked(self): self.assertFalse(gate["surfaceStatusBlocksPlay"]) self.assertFalse(gate["appStatusBlocksPlay"]) self.assertFalse(gate["livePlayBlocked"]) + + +@unittest.skipIf(shutil.which("node") is None, "node is required to transpile + run the JSX selector") +class ColdOpenAwaitingTests(_BabelHarness): + """The cold-open wait affordance gate (RRI 2026-06-09 input-lock give-up): computeColdOpenAwaiting + fires the watchable 'DM is opening your world' first-beat affordance ONLY in the cold-open frame — + a LIVE session with nothing landed yet (no chronicle, no seated party) and no pending/stuck beat + already spinning. Pure selector; the render block in ScreenTable just gates on it.""" + + def _awaiting(self, **over): + base = dict(surfaceStatus="ready", live=True, isLiveView=False, pendingActive=False, + pendingStuck=False, visibleLogLength=0, partyEmpty=True) + base.update(over) + return self._run("win.computeColdOpenAwaiting(%s)" % json.dumps(base)) + + def test_fires_at_the_real_coldopen_frame(self): + # The exact bug frame: live=true, is_live_view=false (a desync the heal hasn't caught), the + # bar locked, empty chronicle + empty party, no pending beat. RED today (no affordance). + self.assertTrue(self._awaiting()) + + def test_isliveview_only_also_fires(self): + self.assertTrue(self._awaiting(live=False, isLiveView=True)) + + def test_not_live_does_not_fire(self): + self.assertFalse(self._awaiting(live=False, isLiveView=False), + "a read-only / dead session must not show a cold-open spinner") + + def test_seated_party_does_not_fire(self): + self.assertFalse(self._awaiting(partyEmpty=False), + "once the PC is seated the cold-open affordance must clear") + + def test_existing_log_does_not_fire(self): + self.assertFalse(self._awaiting(visibleLogLength=1), + "once a beat has landed the chronicle carries it; no cold-open spinner") + + def test_pending_beat_takes_precedence(self): + self.assertFalse(self._awaiting(pendingActive=True), + "a player-move pending beat shows its own spinner") + self.assertFalse(self._awaiting(pendingStuck=True), + "a stuck beat shows its own affordance") + + def test_surface_not_ready_does_not_fire(self): + self.assertFalse(self._awaiting(surfaceStatus="loading"), + "don't mask a real surface outage with a cold-open spinner") From 7a4ad2f2f698a6d48b4d34f37d1b5c140c67419f Mon Sep 17 00:00:00 2001 From: Eva Date: Tue, 9 Jun 2026 18:01:48 +0700 Subject: [PATCH 2/2] fix(viewer): reject backward-in-time surface re-fetch (wall-of-text state rollback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RRI 2026-06-09 (vm2-adversarial 'mid-turn navigation reverts session state + loses the DM beat'): the WORST reliability bug. /session-surface header (day/HP/location) is one snapshot read; the chronicle is client-assembled from /chat+/events. During a long DM beat a transient re-fetch projected an OLDER snapshot, so the header regressed (Day 3->1, HP/location reverted) while the live chronicle held — a header-vs-chronicle desync that reads as a save corruption / party wipe. Fix: build_session_surface now emits the snapshot's monotonic 'updated_at'; loadSurface applies an incoming surface via shouldApplySurface(prev,next) — a strictly-OLDER snapshot of the SAME campaign is rejected (keep the newer one already shown), so the header can never go backward in time. A different campaign (real switch), a newer/equal snapshot, a first surface, or an older save with no clock all apply unchanged. Pure read-model; app-status (live/can_act) still updates each poll. Tests: 6 SurfaceFreshnessTests (older-rejected / newer / equal / cross-campaign / first / no-clock) + build_session_surface updated_at assertion. Full viewer suite green. --- viewer/openworlds/screen-table.jsx | 21 ++++++++++++++- viewer/server.py | 6 +++++ viewer/tests/test_live_view_recovery.py | 14 ++++++++++ viewer/tests/test_recovery_timing.py | 36 +++++++++++++++++++++++++ 4 files changed, 76 insertions(+), 1 deletion(-) diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index 2e075e3c..0f79ba3e 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -500,6 +500,22 @@ function computeColdOpenAwaiting({ surfaceStatus, live, isLiveView, pendingActiv } if (typeof window !== "undefined") window.computeColdOpenAwaiting = computeColdOpenAwaiting; +// Monotonic surface freshness guard (RRI 2026-06-09 wall-of-text rollback). Apply an incoming +// session surface UNLESS it is a strictly-OLDER snapshot of the SAME campaign already shown — that +// is a backward-in-time header regression (a mid-beat re-fetch projected an older snapshot, so +// day/HP/location reverted while the live chronicle held). A different campaign (a real switch), a +// newer-or-equal snapshot, a first surface, or a payload with no monotonic clock all apply (so older +// saves without `updated_at` behave exactly as today). +function shouldApplySurface(prev, next) { + if (!prev || !next) return true; + if (prev.campaign_id !== next.campaign_id) return true; + const a = Number(prev.updated_at); + const b = Number(next.updated_at); + if (!Number.isFinite(a) || !Number.isFinite(b)) return true; + return b >= a; +} +if (typeof window !== "undefined") window.shouldApplySurface = shouldApplySurface; + function ScreenTable({ onNavigate, state, setState, liveSession }) { const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : []; const activeCampaign = @@ -625,7 +641,10 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { statusPayload = null; } if (isCancelled()) return; - setSurface(payload); + // Reject a strictly-older same-campaign snapshot (the wall-of-text rollback): keep the newer + // surface already rendered rather than regress the header backward in time. A later poll lands + // the caught-up snapshot. app-status (live/can_act) is independent of the header, so it updates. + setSurface((prev) => (shouldApplySurface(prev, payload) ? payload : prev)); setAppStatus(statusPayload); setSurfaceStatus("ready"); } catch (error) { diff --git a/viewer/server.py b/viewer/server.py index e8be9d69..046875a3 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -1721,6 +1721,12 @@ def build_session_surface( return { "campaign_id": campaign_id, + # Monotonic snapshot clock (RRI 2026-06-09 wall-of-text rollback): the client's + # shouldApplySurface guard rejects a strictly-OLDER same-campaign surface so a transient + # mid-beat re-fetch can't regress the header backward in time (day/HP/location reverting + # while the live chronicle held). None when the snapshot predates the field — the guard + # then no-ops (applies) so older saves behave exactly as today. + "updated_at": snapshot.get("updated_at"), "title": _text(snapshot.get("title"), campaign_id or "Open Worlds"), "world": _text(snapshot.get("world_id"), "unknown"), "day": snapshot.get("day") if isinstance(snapshot.get("day"), int) else None, diff --git a/viewer/tests/test_live_view_recovery.py b/viewer/tests/test_live_view_recovery.py index 7a639fa1..c70cb1a8 100644 --- a/viewer/tests/test_live_view_recovery.py +++ b/viewer/tests/test_live_view_recovery.py @@ -366,6 +366,20 @@ def test_move_refuses_when_sink_dead(self): self.assertFalse(body.get("ok"), "a dead sink must refuse the move") self.assertIn("read-only", str(body.get("reason", ""))) + # -- the monotonic surface clock (wall-of-text rollback) ------------------- + def test_session_surface_exposes_updated_at_clock(self): + """build_session_surface carries the snapshot's monotonic `updated_at` so the client's + shouldApplySurface guard can reject a backward-in-time mid-beat re-fetch (the wall-of-text + rollback). Absent on an older save -> None, so the guard no-ops and behaves exactly as + today.""" + snap = dict(_snap("Clocked")) + snap["updated_at"] = 1780995303.0 + surf = server.build_session_surface(snap, campaign_id="c", live=True, is_live_view=True) + self.assertEqual(surf["updated_at"], 1780995303.0) + surf2 = server.build_session_surface(_snap("NoClock"), campaign_id="c", + live=True, is_live_view=True) + self.assertIsNone(surf2["updated_at"], "an older snapshot with no clock -> None (guard no-ops)") + if __name__ == "__main__": unittest.main() diff --git a/viewer/tests/test_recovery_timing.py b/viewer/tests/test_recovery_timing.py index 3addf471..5c5f8489 100644 --- a/viewer/tests/test_recovery_timing.py +++ b/viewer/tests/test_recovery_timing.py @@ -440,3 +440,39 @@ def test_pending_beat_takes_precedence(self): def test_surface_not_ready_does_not_fire(self): self.assertFalse(self._awaiting(surfaceStatus="loading"), "don't mask a real surface outage with a cold-open spinner") + + +@unittest.skipIf(shutil.which("node") is None, "node is required to transpile + run the JSX selector") +class SurfaceFreshnessTests(_BabelHarness): + """shouldApplySurface monotonic guard (RRI 2026-06-09 wall-of-text rollback): a strictly-OLDER + same-campaign surface is REJECTED so a mid-beat re-fetch can't regress the header backward in + time (day/HP/location reverting while the live chronicle held). Everything else applies.""" + + def _apply(self, prev, nxt): + return self._run("win.shouldApplySurface(%s, %s)" % (json.dumps(prev), json.dumps(nxt))) + + def test_strictly_older_same_campaign_is_rejected(self): + self.assertFalse(self._apply({"campaign_id": "c", "updated_at": 200}, + {"campaign_id": "c", "updated_at": 100}), + "an older same-campaign snapshot must not regress the header") + + def test_newer_same_campaign_applies(self): + self.assertTrue(self._apply({"campaign_id": "c", "updated_at": 100}, + {"campaign_id": "c", "updated_at": 200})) + + def test_equal_same_campaign_applies(self): + self.assertTrue(self._apply({"campaign_id": "c", "updated_at": 100}, + {"campaign_id": "c", "updated_at": 100})) + + def test_different_campaign_always_applies(self): + # A real campaign switch is never a 'regression' — apply even if the new one is older. + self.assertTrue(self._apply({"campaign_id": "live", "updated_at": 200}, + {"campaign_id": "other", "updated_at": 100})) + + def test_first_surface_applies(self): + self.assertTrue(self._apply(None, {"campaign_id": "c", "updated_at": 100})) + + def test_missing_clock_no_ops(self): + # No monotonic signal (an older save without updated_at) -> apply, exactly as today. + self.assertTrue(self._apply({"campaign_id": "c"}, {"campaign_id": "c", "updated_at": 100})) + self.assertTrue(self._apply({"campaign_id": "c", "updated_at": 100}, {"campaign_id": "c"}))