Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 66 additions & 2 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,41 @@ 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;

// 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 =
Expand Down Expand Up @@ -606,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) {
Expand Down Expand Up @@ -726,6 +764,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
Expand Down Expand Up @@ -1061,7 +1116,7 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
>
<LogEntry entry={entry} />
</div>
)) : <div className="body-sm muted">No moves yet</div>}
)) : coldOpenAwaiting ? null : <div className="body-sm muted">No moves yet</div>}
{pendingActive && (
<div ref={pendingBeatRef} data-worldos-testid="chronicle-pending-beat" style={{ scrollMarginBlock: 12 }}>
{/* #G3-UX: `streaming` (set by useLiveSession's notePendingProgress the moment live
Expand All @@ -1078,6 +1133,15 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
<DmStuckBeat />
</div>
)}
{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). */
<div data-worldos-testid="chronicle-coldopen-beat" style={{ scrollMarginBlock: 12 }}>
<DmNarratingBeat since={coldOpenSinceRef.current} firstBeat={true} streaming={false} onNavigate={onNavigate} />
</div>
)}
</div>

{/* #G3: PRIMARY action palette — promoted into the MAIN column, anchored in the Chronicle
Expand Down
6 changes: 6 additions & 0 deletions viewer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions viewer/tests/test_live_view_recovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
80 changes: 80 additions & 0 deletions viewer/tests/test_recovery_timing.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,3 +396,83 @@ 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))

Comment on lines +419 to +421

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Cold-open test broadens beyond the stated live-session contract.

Line 420 currently expects the affordance to fire when live=False. That conflicts with the cold-open contract (“live session with nothing landed yet”) and can encode misleading behavior for genuinely non-live sessions.

Suggested fix
-    def test_isliveview_only_also_fires(self):
-        self.assertTrue(self._awaiting(live=False, isLiveView=True))
+    def test_isliveview_only_does_not_fire(self):
+        self.assertFalse(
+            self._awaiting(live=False, isLiveView=True),
+            "cold-open affordance should require a live session",
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@viewer/tests/test_recovery_timing.py` around lines 419 - 421, The test method
test_isliveview_only_also_fires is asserting the cold-open affordance but passes
live=False, which contradicts the cold-open contract; update the call to
self._awaiting in that test to use live=True (i.e., self._awaiting(live=True,
isLiveView=True)) so the test explicitly targets a live session with no landed
content and aligns the assertion with the intended contract.

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


@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"}))
Loading