From 5143e27a65515b082f1d07bb1f0ef672e4c3039d Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 30 May 2026 23:16:02 +0700 Subject: [PATCH] fix(viewer): keep latest chronicle beat + action box visible; give camp-rest feedback when DM busy (#402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two viewer bugs found in a full-arc playtest. MAJOR — the Chronicle grew unbounded so after ~3 beats the latest DM narration AND the action box were pushed out of view (and an a11y reader truncated before the newest content), stalling the run: - useLiveSession: bound the live tail — chatBeats (60) + player echoes (40) no longer accumulate for the whole session. - screen-table: render only the most-recent 50 rows (CHRONICLE_RENDER_CAP) so the DOM + accessibility tree stay bounded; an affordance points to the Quest Journal for older beats. Anchor the action bar (flex 0 0 auto) so it is never pushed out; label the scroll region role="log". - Smarter auto-follow: scroll to the newest beat when the player is at/near the bottom OR just submitted a move (one-shot snap), but RESPECT a reader who scrolled up (no yank mid-read). Follows streamed paragraphs + the pending/narrating indicator into view. MINOR — Camp "Begin Resting" silently no-op'd while the DM was mid-turn (can_act stays true, so the click POSTed a move that just queued): - Thread the DM-busy (pending) state from the app-level live session into ScreenMap -> CampSidebar; disable the rest CTA + explain ("Resolve the current beat first"), mirroring ScreenTable's "one move at a time" gate. Viewer-only; no wire-contract / --resume / persist_beat changes. Verified with focused single-process viewer tests + a live headless-chromium run of the real OpenWorlds app over a seeded 70-beat session (viewer tests are not in CI). Adds 2 behavioral (bounded-tail) + 3 static tests. --- viewer/openworlds/app.jsx | 33 +++++++- viewer/openworlds/camp-sidebar.jsx | 37 ++++++--- viewer/openworlds/screen-map.jsx | 11 ++- viewer/openworlds/screen-table.jsx | 89 ++++++++++++++++++++-- viewer/tests/test_live_narration_stream.py | 46 +++++++++++ viewer/tests/test_openworlds_static.py | 64 ++++++++++++++++ 6 files changed, 259 insertions(+), 21 deletions(-) diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx index 276acd09..87a36bc5 100644 --- a/viewer/openworlds/app.jsx +++ b/viewer/openworlds/app.jsx @@ -114,6 +114,25 @@ function recoveryWindowMs(firstBeat) { return firstBeat ? PENDING_RECOVERY_FIRST_MS : PENDING_RECOVERY_MS; } +// #402: BOUND the live tail. `chatBeats` (every streamed/turn-end DM narration + dialogue beat) and +// `log` (every optimistic player echo) accumulated for the WHOLE session with no cap — so a long +// playtest grew them without limit. The chronicle rendered ALL of it (screen-table visibleLog.map), +// so the DOM + the accessibility tree grew unbounded: after a few beats an a11y reader (and a real +// screen reader) truncated BEFORE reaching the newest narration, and the latest beat + action box +// were buried under an ever-taller scroll region — the player couldn't see the DM's reply and the +// run stalled. We keep only the most-recent MAX_LIVE_* entries in each array; older prose still +// lives in the server's recentEvents history band (screen-table's leading window), so nothing is +// truly lost — the live tail just stops growing. The caps are generous so a single multi-paragraph +// DM turn (several /events beats in one turn) is NEVER clipped mid-beat. Pure + exported for tests. +const MAX_LIVE_BEATS = 60; // DM narration/dialogue beats kept in the live tail (≫ one turn's paragraphs). +const MAX_LIVE_ECHOES = 40; // optimistic player-action echoes kept in the live tail. +// Trim an append-only array to its last `max` entries WITHOUT copying when already within bound +// (so a steady-state turn doesn't reallocate the array every poll). Returns the same ref when no +// trim is needed — React's setState bails out on an identical ref, avoiding a needless re-render. +function boundTail(arr, max) { + return (Array.isArray(arr) && arr.length > max) ? arr.slice(arr.length - max) : arr; +} + // #274: a monotonic, client-side sequence stamped on every chronicle entry created here (player // echoes + each ingested chat beat) as `.at`. The session log in screen-table.jsx concatenates // three sources (recentEvents → chatBeats → log); because the player's optimistic echo (`log`) and @@ -239,7 +258,9 @@ function useLiveSession(state) { && String(last.text || "").trim() === String(text || "").trim()) { return l; // identical to the row already showing (a Try-again re-POST) — no duplicate. } - return [...l, { kind: "action", who, text, at: nextLogSeq() }]; // #274: creation-order stamp + // #402: bound the echo tail so a long session doesn't grow `log` (and the rendered DOM / + // a11y tree) without limit. Keep the most-recent MAX_LIVE_ECHOES. + return boundTail([...l, { kind: "action", who, text, at: nextLogSeq() }], MAX_LIVE_ECHOES); // #274: creation-order stamp }); }, []); @@ -292,7 +313,7 @@ function useLiveSession(state) { return clean && claimNarration(clean) ? { kind: "narration", text: clean, at: nextLogSeq() } : null; }) .filter(Boolean); - if (beats.length) setChatBeats((prev) => [...prev, ...beats]); + if (beats.length) setChatBeats((prev) => boundTail([...prev, ...beats], MAX_LIVE_BEATS)); // #402: cap the live tail // The arrival of the DM's turn-END line means the turn RESOLVED → clear the narrating // indicator + its timers. This fires even when the prose was wholly deduped (a turn whose // entire beat streamed live via /events), so a fully-streamed turn still re-opens the bar. @@ -353,7 +374,7 @@ function useLiveSession(state) { }) .filter(Boolean); if (beats.length) { - setChatBeats((prev) => [...prev, ...beats]); + setChatBeats((prev) => boundTail([...prev, ...beats], MAX_LIVE_BEATS)); // #402: cap the live tail // The scene is visibly building → the turn is plainly alive. Count the streamed prose as // real DM beats (so the NEXT turn isn't mis-treated as a cold-open 'firstBeat') and reset // the stall clock so a long-but-healthy streaming turn is never falsely declared 'stuck'. @@ -391,6 +412,10 @@ window.__PENDING_TIMING__ = { recoveryFirstMs: PENDING_RECOVERY_FIRST_MS, backstopMs: PENDING_BACKSTOP_MS, }; +// #402: expose the live-tail bound for tests/devtools introspection (purely additive — the hook +// closes over the consts directly; nothing in the running app reads these off window). +window.boundTail = boundTail; +window.__LIVE_TAIL_CAPS__ = { maxBeats: MAX_LIVE_BEATS, maxEchoes: MAX_LIVE_ECHOES }; function App() { const [state, setState] = React.useState(window.INITIAL_STATE || {}); @@ -763,7 +788,7 @@ function ScreenRouter({ screen, state, setState, onNavigate, campMode, setCampMo case "acts": return ; case "seed": return ; case "inventory": return ; - case "map": return ; + case "map": return ; case "journal": return ; case "bestiary": return ; case "merchant": return ; diff --git a/viewer/openworlds/camp-sidebar.jsx b/viewer/openworlds/camp-sidebar.jsx index bfb52776..e9a0909b 100644 --- a/viewer/openworlds/camp-sidebar.jsx +++ b/viewer/openworlds/camp-sidebar.jsx @@ -1,6 +1,6 @@ /* Camp Sidebar — D&D 5e party role assignment during a long rest */ -function CampSidebar({ state, onExit, onBeginRest, onTalk, talkPartner }) { +function CampSidebar({ state, onExit, onBeginRest, onTalk, talkPartner, dmBusy }) { // LIVE party for the active campaign. The camp sidebar has no dedicated surface route, so it // reuses the same /character-surface read-model screen-character.jsx polls (it carries `.party`). // We never fall back to `state.party` (the non-canonical demo party). @@ -103,6 +103,15 @@ function CampSidebar({ state, onExit, onBeginRest, onTalk, talkPartner }) { // morning, and firing companion camp beats). The constrained /move palette accepts `do` // free-text intents, so the watch/cook/recipe/healing choices ride in the sentence. const beginRest = async () => { + // #402: the DM is mid-narration — a long rest is a new action the player can't take yet. Don't + // silently no-op (the old behavior: the button stayed enabled because `can_act` is still true, + // so a click POSTed a move that just queued behind the in-flight turn and nothing advanced — + // it read as an identical reload). Give clear feedback, mirroring ScreenTable's "one move at a + // time" gate. The button is also disabled while busy (below), so this is the keyboard/edge path. + if (dmBusy) { + toast({ kind: "danger", eyebrow: "Camp", title: "The Dungeon Master is still narrating", body: "Resolve the current beat first — then make camp and rest." }); + return; + } if (!canAct || resting) return; const watch = [heroName(roles.watch1), heroName(roles.watch2)].filter(Boolean); const clauses = []; @@ -346,30 +355,38 @@ function CampSidebar({ state, onExit, onBeginRest, onTalk, talkPartner }) { {talkHero && onTalk(null)} />} {/* Begin resting — wired to the engine via /move (CS-01). Enabled + functional when a - live session is attached (can_act); honestly disabled + explained when the chronicle - is read-only, or with no party to rest. */} + live session is attached (can_act) AND the DM isn't mid-turn (#402); honestly disabled + + explained when the chronicle is read-only, the DM is narrating, or there's no party. */}
Leave camp - {resting ? "✺ Resting…" : "✺ Begin Resting"} + {/* #402: the label reflects the DM-busy state so the disabled button isn't a mystery. */} + {resting ? "✺ Resting…" : dmBusy ? "✺ DM is narrating…" : "✺ Begin Resting"}
- {!canAct && ( + {!canAct ? (
The chronicle is read-only. Start a session to make camp and rest.
- )} + ) : dmBusy ? ( + // #402: explain WHY the rest CTA is disabled mid-turn (the silent no-op was the bug). +
+ Resolve the current beat first — the Dungeon Master is narrating. +
+ ) : null}
); diff --git a/viewer/openworlds/screen-map.jsx b/viewer/openworlds/screen-map.jsx index 73da06f0..aa8e38a2 100644 --- a/viewer/openworlds/screen-map.jsx +++ b/viewer/openworlds/screen-map.jsx @@ -34,7 +34,7 @@ function atlasShortLabel(name) { return base.length <= 16 ? base : base.slice(0, 15) + "…"; } -function ScreenMap({ onNavigate, state, campMode, setCampMode }) { +function ScreenMap({ onNavigate, state, campMode, setCampMode, liveSession }) { const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : []; const activeCampaign = campaigns.find((c) => c.id === state?.activeCampaign) || @@ -116,6 +116,14 @@ function ScreenMap({ onNavigate, state, campMode, setCampMode }) { const selectedTravel = selected ? travelOptions.find((t) => t.to === selected.id) : null; const canCamp = Boolean(surface?.camp_available); const canAct = Boolean(surface?.can_act); + // #402: is the DM mid-turn? The /move sink always accepts (it just appends an intent), and + // `can_act` stays true while the DM narrates — so a camp "Begin Resting" click during a beat used + // to POST a move that silently queued behind the in-flight turn (no advance, a misleading success + // toast). Mirror ScreenTable's gate: `pending` (present + not flagged stuck) ⇒ the DM is narrating + // and the player can't take a new action yet. Threaded down to CampSidebar so the rest CTA can + // disable + explain instead of no-op'ing. (`pending` lives on the app-level liveSession hook.) + const dmPending = liveSession?.pending || null; + const dmBusy = Boolean(dmPending && !dmPending.stuck); // Day/night is CLOCK-DRIVEN: read the engine's normalized phase off the surface (falling // back to a sniff of the legacy day label for older builds). There is no manual toggle — // the indicator always reflects the live campaign clock. @@ -230,6 +238,7 @@ function ScreenMap({ onNavigate, state, campMode, setCampMode }) { onBeginRest={beginRest} onTalk={setTalkPartner} talkPartner={talkPartner} + dmBusy={dmBusy} /> ) : ( diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index 9522e999..34e75a97 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -188,6 +188,13 @@ const ACTION_HINTS = { const DICE_HINT = (sides) => `Roll a d${sides} — ask the Dungeon Master to resolve a d${sides} check.`; const DECLARE_HINT = "Type what your hero does in your own words, then Declare to take the turn."; +// #402: the maximum number of chronicle rows MOUNTED at once (the live tail + the leading history +// band, merged). Keeps the DOM + the accessibility tree bounded so the newest DM beat and the +// action bar stay reachable no matter how long the session runs. Generous on purpose: well above a +// handful of turns AND above one multi-paragraph DM turn, so a beat is never clipped as it streams. +// Older beats are still available in full in the Quest Journal. +const CHRONICLE_RENDER_CAP = 50; + function ScreenTable({ onNavigate, state, setState, liveSession }) { const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : []; const activeCampaign = @@ -209,6 +216,13 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { const { chatBeats, log, pending } = session; const logRef = React.useRef(null); const inputRef = React.useRef(null); + // #402: auto-follow state. `stickToBottomRef` is true while the player is at/near the bottom of the + // chronicle (the default) and false once they scroll UP to read history — so the auto-scroll effect + // follows new narration to the bottom WITHOUT yanking a reader back down mid-read. `snapNextRef` is + // a one-shot "force to bottom on the next content change" flag set when the player submits a move + // (a new turn) — so declaring an action always re-pins to the latest, even if they'd scrolled up. + const stickToBottomRef = React.useRef(true); + const snapNextRef = React.useRef(false); const toast = window.useToast ? window.useToast() : (() => {}); const fallbackParty = []; const party = Array.isArray(surface?.party) && surface.party.length ? surface.party : fallbackParty; @@ -260,6 +274,17 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { return !key || !liveNarrationKeys.has(key); }); const visibleLog = surface ? [...dedupedRecent, ...mergedTail] : [...demoLog, ...log]; + // #402: BOUND what the chronicle RENDERS. Even with the live tail capped in useLiveSession, the + // leading history band (recentEvents from the server) can be large, so the merged list could still + // mount hundreds of rows into the DOM + the accessibility tree — the exact thing that buried the + // latest DM beat (and the action box) and made an a11y reader truncate before the newest content. + // We render only the most-recent CHRONICLE_RENDER_CAP rows so a 10-beat session is as navigable as + // a 2-beat one: the latest beat is always near the bottom of a short, fully-exposed list, and the + // sticky action bar below is always reachable. Older beats remain in the Quest Journal (full + // history); a one-line affordance says so when rows are hidden. The cap is generous (≫ a handful of + // turns, and ≫ one multi-paragraph DM turn) so we never clip an in-flight beat as it streams. + const hiddenLogCount = Math.max(0, visibleLog.length - CHRONICLE_RENDER_CAP); + const renderedLog = hiddenLogCount > 0 ? visibleLog.slice(visibleLog.length - CHRONICLE_RENDER_CAP) : visibleLog; const actionById = (id) => actions.find((a) => a.id === id); const enabledActionById = (id) => enabledActions.find((a) => a.id === id); @@ -331,9 +356,34 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { } }, [party, activeHero]); + // #402: auto-follow the newest narration to the bottom — but RESPECT a reader who scrolled up. + // The old effect pinned scrollTop to scrollHeight on EVERY content change unconditionally, which + // (a) yanked a player back down the instant a streamed paragraph or 5s surface poll arrived while + // they were reading history, and (b) never fired when ONLY the pending/narrating indicator toggled + // (it wasn't a dependency), so the "DM is narrating…" beat could sit below the fold. Now we scroll + // to bottom only when the player is already at/near the bottom (stickToBottomRef) OR a new move was + // just submitted (snapNextRef, a one-shot re-pin on a new turn). Depending on `pending` too means + // the narrating indicator (and a freshly-streamed beat) is followed into view the same way. React.useEffect(() => { - if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; - }, [visibleLog]); + const el = logRef.current; + if (!el) return; + if (snapNextRef.current || stickToBottomRef.current) { + el.scrollTop = el.scrollHeight; + snapNextRef.current = false; + stickToBottomRef.current = true; // a programmatic snap leaves us pinned to the bottom + } + }, [renderedLog, pending]); + + // #402: track whether the player is reading history (scrolled up) vs. parked at the bottom. A + // generous threshold (~64px) keeps "near the bottom" sticky through small layout shifts (the + // narrating dots, a wrapping line) so normal play stays auto-following; deliberately scrolling up + // to re-read clears it, and scrolling back to the bottom re-arms it. + const onLogScroll = React.useCallback(() => { + const el = logRef.current; + if (!el) return; + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; + stickToBottomRef.current = distanceFromBottom <= 64; + }, []); // #340 + #342: arming / clearing the "DM is narrating…" pending state now lives in the app-level // useLiveSession hook (so it survives navigation, and carries the 90s recovery + 12-min backstop). @@ -395,6 +445,10 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { } recordPlayerEcho(hero.name, text); armPending(text); + // #402: a new turn was just submitted — force the chronicle back to the bottom on the next + // content change even if the player had scrolled up, so they always see their move land and + // the DM's reply begin. The auto-follow effect honors this one-shot, then re-arms stickiness. + snapNextRef.current = true; loadSurface(); } catch (error) { toast({ kind: "danger", title: "Move not sent", body: error?.message || `The viewer could not reach ${writeLane.endpoint || "/move"}.` }); @@ -546,16 +600,39 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { {/* #320: trimmed "The Tabletop Chronicle" → "Chronicle" and dropped the "·" lead dot (read as visual noise on a busy screen). */} Chronicle -
- {visibleLog.length ? visibleLog.map((entry, i) => ( + {/* #402: role="log" + a label names this region in the accessibility tree so an assistive + reader can target "the latest beat" directly; `onScroll` tracks whether the player is + reading history (scrolled up) so the auto-follow effect doesn't yank them to the bottom + mid-read. The scroll region is the SOLE grower (flex 1 1 auto) — the action bar below is + flex 0 0 auto, so it stays anchored/visible no matter how long the chronicle gets. */} +
+ {/* #402: when older beats are windowed out of the DOM, say so + point to the full history + (the Quest Journal). Keeps the rendered list short so the newest beat + action box stay + reachable, without pretending the earlier story is gone. */} + {hiddenLogCount > 0 && ( +
+ {hiddenLogCount} earlier {hiddenLogCount === 1 ? "beat is" : "beats are"} kept in your{" "} + + {" "}— the latest beats are shown below. +
+ )} + {renderedLog.length ? renderedLog.map((entry, i) => ( )) :
No moves yet
} {pendingActive && } {pendingStuck && }
- {/* Action bar */} -
+ {/* Action bar — #402: flex 0 0 auto so it is ALWAYS anchored at the bottom of the panel and + never pushed out of view by an ever-growing chronicle above it. */} +
Active diff --git a/viewer/tests/test_live_narration_stream.py b/viewer/tests/test_live_narration_stream.py index 61ff3963..329f3ea3 100644 --- a/viewer/tests/test_live_narration_stream.py +++ b/viewer/tests/test_live_narration_stream.py @@ -227,6 +227,10 @@ log: () => (reactHost.api().log || []).map((e) => ({ kind: e.kind, who: e.who, text: e.text })), // #399: the recovery-window selector by turn position (firstBeat ⇒ cold-open window, else later). recoveryWindowMs: (firstBeat) => sandbox.window.recoveryWindowMs(firstBeat), + // #402: the live-tail caps (so a test asserts against the SAME numbers the hook trims to) and the + // raw chatBeats length (the bounded DM-narration/dialogue tail). + caps: () => sandbox.window.__LIVE_TAIL_CAPS__, + beatCount: () => (reactHost.api().chatBeats || []).length, drain, }; @@ -470,6 +474,48 @@ def test_player_echo_keeps_distinct_actions(self): self.assertEqual(len(out["log"]), 2, "two distinct actions must both appear (idempotence only suppresses a back-to-back exact repeat)") + # --- #402: the live chatBeats tail is BOUNDED so a long session can't grow the DOM/a11y tree -- + # The bug: chatBeats accumulated every streamed/turn-end narration for the WHOLE session with no + # cap, so the chronicle rendered an ever-growing list — burying the latest beat + the action box, + # and truncating an a11y reader before it reached the newest content. Stream far MORE than the + # cap of unique narration paragraphs and assert the tail is trimmed to the cap. + def test_live_beats_tail_is_bounded(self): + out = self._run( + "await h.drain();" + "var cap = h.caps().maxBeats;" + "var total = cap + 25;" # stream well past the cap + "for (var i = 0; i < total; i++) {" + " h.enqueue('/events', { entries: [{ kind: 'narration', text: 'paragraph number ' + i }], next: i + 1 });" + " await h.tick();" + "}" + "return ({ cap: cap, total: total, count: h.beatCount() });" + ) + self.assertEqual(out["count"], out["cap"], + "the live narration tail must be trimmed to MAX_LIVE_BEATS no matter how long the session runs (#402)") + self.assertLess(out["count"], out["total"], + "the tail must drop the oldest beats once it exceeds the cap (it must not grow unbounded)") + + # --- #402: trimming keeps the NEWEST beats (the latest DM narration must always survive) ------ + # A naive trim that kept the FIRST N would hide exactly the content the player needs (the reply + # to their latest move). Assert the most-recent paragraph is present and the oldest is gone. + def test_bounded_tail_keeps_the_newest_beats(self): + out = self._run( + "await h.drain();" + "var cap = h.caps().maxBeats;" + "var total = cap + 10;" + "for (var i = 0; i < total; i++) {" + " h.enqueue('/events', { entries: [{ kind: 'narration', text: 'beat ' + i }], next: i + 1 });" + " await h.tick();" + "}" + "var texts = h.narrationTexts();" + "return ({ first: texts[0], last: texts[texts.length - 1], total: total });" + ) + # The newest beat (index total-1) must still be in the tail; the very oldest (beat 0) must not. + self.assertEqual(out["last"], f"beat {out['total'] - 1}", + "the most-recent DM narration must always survive the trim (the player's latest reply)") + self.assertNotEqual(out["first"], "beat 0", + "the oldest beats must be dropped once the cap is exceeded (the tail slides forward)") + if __name__ == "__main__": unittest.main() diff --git a/viewer/tests/test_openworlds_static.py b/viewer/tests/test_openworlds_static.py index e7d7d856..7053c8dd 100644 --- a/viewer/tests/test_openworlds_static.py +++ b/viewer/tests/test_openworlds_static.py @@ -186,6 +186,70 @@ def test_openworlds_table_posts_only_enabled_session_actions(self): self.assertNotIn("snapshot.json", source) self.assertNotIn("writeSnapshot", source) + def test_openworlds_table_bounds_and_anchors_the_chronicle(self): + # #402: the chronicle must stay navigable across a long session — the rendered row count is + # CAPPED (DOM + a11y tree bounded so the latest beat isn't truncated), the scroll region is + # labelled role="log" and tracks user scroll, auto-follow respects a reader scrolled up + # (stick-to-bottom) while a new move snaps to latest, and the action bar is anchored. + status, ctype, body = self._get("/openworlds/screen-table.jsx") + + self.assertEqual(status, 200) + self.assertIn("text/babel", ctype) + source = body.decode("utf-8") + # Rendered window cap (bounds the DOM + accessibility tree). + self.assertIn("CHRONICLE_RENDER_CAP", source) + self.assertIn("renderedLog", source) + self.assertIn("hiddenLogCount", source) + # The scroll region is a labelled log and reports scroll position for the auto-follow guard. + self.assertIn('role="log"', source) + self.assertIn("onLogScroll", source) + # Auto-follow respects a reader scrolled up, and a new move re-pins to the latest. + self.assertIn("stickToBottomRef", source) + self.assertIn("snapNextRef", source) + # The auto-scroll effect follows the pending/narrating indicator into view too (not just log). + self.assertIn("}, [renderedLog, pending]);", source) + # The action bar is explicitly anchored (never pushed out by a growing chronicle). + self.assertIn('flex: "0 0 auto"', source) + + def test_openworlds_app_bounds_the_live_session_tail(self): + # #402: the live tail (chatBeats + player echoes) is bounded in useLiveSession so a long + # session doesn't accumulate state without limit (the upstream half of the DOM-growth fix). + status, ctype, body = self._get("/openworlds/app.jsx") + + self.assertEqual(status, 200) + self.assertIn("text/babel", ctype) + source = body.decode("utf-8") + self.assertIn("MAX_LIVE_BEATS", source) + self.assertIn("MAX_LIVE_ECHOES", source) + self.assertIn("boundTail(", source) + # The cap is applied at the chatBeats append sites and the player-echo append site. + self.assertIn("boundTail([...prev, ...beats], MAX_LIVE_BEATS)", source) + self.assertIn("MAX_LIVE_ECHOES", source) + + def test_openworlds_camp_rest_gives_feedback_when_dm_is_busy(self): + # #402: the Camp "Begin Resting" CTA must give clear feedback when the DM is mid-turn (the + # bug was a silent no-op — can_act stays true so the click POSTed a move that just queued). + # ScreenMap threads the DM-busy state from the live session into CampSidebar, which disables + # the CTA + explains why (and the click handler toasts on the keyboard/edge path). + _s_map, _c_map, map_body = self._get("/openworlds/screen-map.jsx") + map_source = map_body.decode("utf-8") + self.assertIn("liveSession", map_source) + self.assertIn("dmBusy", map_source) + self.assertIn("dmBusy={dmBusy}", map_source) + + _s_camp, _c_camp, camp_body = self._get("/openworlds/camp-sidebar.jsx") + camp_source = camp_body.decode("utf-8") + self.assertIn("dmBusy", camp_source) + # The button is disabled while busy, and the early-return path toasts instead of no-op'ing. + self.assertIn("!canAct || dmBusy", camp_source) + self.assertIn("still narrating", camp_source) + + # And the app actually passes liveSession to the map screen (so dmBusy is real, not always false). + _s_app, _c_app, app_body = self._get("/openworlds/app.jsx") + app_source = app_body.decode("utf-8") + self.assertIn("ScreenMap", app_source) + self.assertRegex(app_source, r"case \"map\":\s*return ]*liveSession=\{liveSession\}") + def test_openworlds_acts_screen_binds_viewer_acts_surface(self): status, ctype, body = self._get("/openworlds/screen-acts.jsx")