From 0cad146e2e61a7796bbb8fce19c49076559429de Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 30 May 2026 21:40:22 +0700 Subject: [PATCH] feat(viewer): stream DM narration incrementally to /chat (#393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 5-persona playtest on the built app (build_sha e6384e8) surfaced per-beat latency with no streaming as the #1 satisfaction-killer: a DM turn runs ~60-90s and the player saw a waiting indicator with NO content appearing, so impatient personas concluded "this feels broken" and quit (adversarial gave up 1m23s on the cold-open; narrative ~90s on turn 2). Bounded streaming-lite, NOT a rewrite. The DM logs each beat via log_event(kind=narration/dialogue) DURING its turn and the engine appends it to the per-session log immediately (store.append_log); the viewer's /events endpoint already tails that log with a cursor. So useLiveSession now polls /events alongside /chat: new narration/dialogue rows surface as live, time-stamped chronicle beats — a blank wait becomes prose visibly arriving — with no change to the resolver's blocking turn, no SSE, and no change to engine write-semantics (the engine stays the sole writer; this is a pure read of state it already wrote). - Turn-gating preserved: a streamed beat shows prose but KEEPS the "narrating…" indicator up (one move at a time); the turn resolves when its final line lands on /chat. Streamed progress resets the stall/recovery clock so a long-but-healthy streaming turn is never falsely declared "stuck". - Dedup across both sources (claimNarration, text-keyed, whitespace/case-normalized) so each paragraph shows exactly once; the /chat line still resolves a turn even when its prose was wholly deduped. - screen-table dedups recentEvents (the /session-surface tail of the same log) against the live tail so a streamed paragraph isn't shown in both bands. - New test_live_narration_stream.py (10 tests) transpiles the real JSX with the bundled Babel-standalone and drives useLiveSession under a deterministic React + scripted-fetch harness. 49/49 viewer tests green (10 new + 39 existing). The companion mitigation (dynamic/reassuring waiting state) already shipped in #385. --- viewer/openworlds/app.jsx | 149 +++++++- viewer/openworlds/screen-table.jsx | 19 +- viewer/tests/test_live_narration_stream.py | 409 +++++++++++++++++++++ 3 files changed, 566 insertions(+), 11 deletions(-) create mode 100644 viewer/tests/test_live_narration_stream.py diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx index 6453c8e0..1978af57 100644 --- a/viewer/openworlds/app.jsx +++ b/viewer/openworlds/app.jsx @@ -129,19 +129,49 @@ function useLiveSession(state) { const [log, setLog] = React.useState([]); // local optimistic player echoes const [pending, setPending] = React.useState(null); // { text, since, stuck? } | null const chatCursor = React.useRef(0); + const eventsCursor = React.useRef(0); // #393: per-file cursor for the live /events tail const dmBeatCountRef = React.useRef(0); + // #393: dedup key set shared across BOTH narration sources. The session log streams a turn's + // narration mid-flight via /events; the duo/human runner ALSO appends the SAME prose to /chat at + // turn-END. Without a shared seen-set the player would see each streamed paragraph twice (once live, + // once when the chat line lands). Keyed by normalized narration text so whichever source surfaces a + // given paragraph FIRST wins and the later duplicate is dropped. Player echoes are never deduped. + const seenNarration = React.useRef(new Set()); const recoveryTimer = React.useRef(null); const backstopTimer = React.useRef(null); // sanitizeNarration lives in screen-table.jsx (loaded first); fall back to identity if absent. const sanitize = (txt) => (typeof window.sanitizeNarration === "function" ? window.sanitizeNarration(txt) : (txt || "")); + // #393: a stable dedup key for a narration paragraph — whitespace-collapsed + lowercased so the + // /events copy and the /chat copy of the same prose hash identically. First-seen returns true (show + // it + record the key); a repeat returns false (suppress). Empty/blank text is never recorded. + const claimNarration = React.useCallback((txt) => { + const key = String(txt || "").replace(/\s+/g, " ").trim().toLowerCase(); + if (!key) return false; + if (seenNarration.current.has(key)) return false; + seenNarration.current.add(key); + return true; + }, []); const clearTimers = React.useCallback(() => { if (recoveryTimer.current) { window.clearTimeout(recoveryTimer.current); recoveryTimer.current = null; } if (backstopTimer.current) { window.clearTimeout(backstopTimer.current); backstopTimer.current = null; } }, []); - const clearPending = React.useCallback(() => { clearTimers(); setPending(null); }, [clearTimers]); + // #393: a ref mirror of `pending` so a poll callback (whose effect deps deliberately EXCLUDE + // `pending`, to avoid re-subscribing the 3s interval every turn) can read the CURRENT turn state + // without a stale closure. `setPendingState` is the single writer that keeps the ref in lockstep + // with state — every pending change (arm / clear / stuck-flag / progress) goes through it. + const pendingRef = React.useRef(null); + const setPendingState = React.useCallback((next) => { + setPending((p) => { + const v = (typeof next === "function") ? next(p) : next; + pendingRef.current = v; + return v; + }); + }, []); + + const clearPending = React.useCallback(() => { clearTimers(); setPendingState(null); }, [clearTimers, setPendingState]); // #342 + #348: arm the narrating indicator + a recovery timeout. If a DM beat doesn't arrive within // the recovery window the turn is flagged `stuck` (the bar re-enables with a "try again" hint) @@ -156,12 +186,34 @@ function useLiveSession(state) { clearTimers(); const firstBeat = dmBeatCountRef.current === 0; const recoveryMs = recoveryWindowMs(firstBeat); - setPending({ text, since: Date.now(), stuck: false, firstBeat }); + setPendingState({ text, since: Date.now(), stuck: false, firstBeat }); + recoveryTimer.current = window.setTimeout(() => { + setPendingState((p) => (p ? { ...p, stuck: true } : p)); + }, recoveryMs); + backstopTimer.current = window.setTimeout(() => setPendingState(null), PENDING_BACKSTOP_MS); + }, [clearTimers, setPendingState]); + + // #393: a turn that is STREAMING prose mid-flight (via the /events live tail) is demonstrably + // alive — so reset the stall/backstop clocks on each streamed beat instead of letting a long-but- + // healthy turn drift toward a false 'stuck'. This deliberately does NOT clear pending: the action + // bar stays gated (one move at a time) and the honest "the DM is narrating" indicator stays up + // WHILE the scene visibly builds above it — the turn only resolves (clearPending) when its final + // text lands on /chat. No-op when no turn is pending (a streamed beat with the bar already idle). + const notePendingProgress = React.useCallback(() => { + // Read the live turn via the ref (the poll's closure can't see the latest `pending`). Side- + // effects (timer re-arm) live OUTSIDE any state updater so they don't double-fire under React + // StrictMode's double-invoked updaters. + const p = pendingRef.current; + if (!p) return; + clearTimers(); + const recoveryMs = recoveryWindowMs(Boolean(p.firstBeat)); recoveryTimer.current = window.setTimeout(() => { - setPending((p) => (p ? { ...p, stuck: true } : p)); + setPendingState((q) => (q ? { ...q, stuck: true } : q)); }, recoveryMs); - backstopTimer.current = window.setTimeout(() => setPending(null), PENDING_BACKSTOP_MS); - }, [clearTimers]); + backstopTimer.current = window.setTimeout(() => setPendingState(null), PENDING_BACKSTOP_MS); + // Clear any prior 'stuck' flag — fresh prose just arrived, so the turn is plainly not stuck. + if (p.stuck) setPendingState((q) => (q ? { ...q, stuck: false } : q)); + }, [clearTimers, setPendingState]); const recordPlayerEcho = React.useCallback((who, text) => { setLog((l) => [...l, { kind: "action", who, text, at: nextLogSeq() }]); // #274: creation-order stamp @@ -173,7 +225,9 @@ function useLiveSession(state) { // another (the cursor is per-file; a new run starts at 0). React.useEffect(() => { chatCursor.current = 0; + eventsCursor.current = 0; // #393: reset the live /events tail per run dmBeatCountRef.current = 0; + seenNarration.current = new Set(); // #393: a fresh run shares no dedup keys with the last setChatBeats([]); setLog([]); clearPending(); @@ -197,19 +251,29 @@ function useLiveSession(state) { const payload = await resp.json(); const items = Array.isArray(payload.items) ? payload.items : []; if (!cancelled && items.length) { + // #393: a DM line on /chat is the turn-RESOLUTION signal regardless of whether its prose + // is novel — when the whole beat already streamed live via /events, claimNarration dedups + // EVERY paragraph (beats below is empty), but the turn has still ended and the indicator + // must clear. So track "a dm-role item arrived" separately from "novel beats to render". + let dmLineArrived = false; const beats = items .map((it) => { // #274: stamp each beat with the shared monotonic counter at ingest time so it // time-merges correctly against local player echoes (which share the same counter). if (it.role === "player") return { kind: "dialog", who: "You", text: it.text, at: nextLogSeq() }; + dmLineArrived = true; const clean = sanitize(it.text); - return clean ? { kind: "narration", text: clean, at: nextLogSeq() } : null; + // #393: drop a turn-END chat beat whose prose already streamed live via /events this + // turn (claimNarration is false on a repeat) so the same paragraph isn't shown twice. + return clean && claimNarration(clean) ? { kind: "narration", text: clean, at: nextLogSeq() } : null; }) .filter(Boolean); if (beats.length) setChatBeats((prev) => [...prev, ...beats]); - // A fresh DM narration beat means the turn resolved → clear the narrating indicator - // (and its timers). Player echoes / wholly-internal beats don't count. - if (beats.some((b) => b.kind === "narration")) { + // 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. + // Player echoes alone never resolve a turn. + if (dmLineArrived) { dmBeatCountRef.current += beats.filter((b) => b.kind === "narration").length; clearPending(); } @@ -225,7 +289,72 @@ function useLiveSession(state) { document.addEventListener("visibilitychange", onVisibility); onVisibility(); return () => { cancelled = true; stop(); document.removeEventListener("visibilitychange", onVisibility); }; - }, [campaignId, source, runId, clearPending]); + }, [campaignId, source, runId, clearPending, claimNarration]); + + // #393: the LIVE narration stream — the fix for the "blank 90s wait" give-up. + // The DM logs each narration/dialogue beat via the engine's log_event DURING its turn, and the + // engine appends it to campaigns//sessions/.jsonl IMMEDIATELY (store.append_log). The + // viewer's /events endpoint tails exactly that log with a line cursor. So polling /events here + // surfaces the scene as it is being WRITTEN — a 60-90s blank wait becomes 60-90s of prose + // appearing — WITHOUT changing the resolver's blocking turn or the engine's sole-writer semantics + // (this is a pure read of state the engine already wrote). The turn-END /chat line carries the + // same prose; claimNarration dedups it so each paragraph shows exactly once, from whichever source + // reached the player first (live, in practice). Visibility-aware + best-effort, mirroring /chat. + React.useEffect(() => { + if (!campaignId) return undefined; + let cancelled = false; + let timer = null; + const pollOnce = async () => { + if (cancelled) return; + try { + const params = new URLSearchParams(); + params.set("campaign", campaignId); + if (source) params.set("source", source); + if (runId) params.set("run", runId); + params.set("since", String(eventsCursor.current)); + const resp = await fetch(`/events?${params.toString()}`, { cache: "no-store" }); + if (!resp.ok) return; + const payload = await resp.json(); + const entries = Array.isArray(payload.entries) ? payload.entries : []; + if (!cancelled && entries.length) { + // Only player-facing prose streams live: narration + dialogue. Roll/system/combat rows are + // mechanics the chronicle surfaces elsewhere — folding them in here would read as noise + // mid-scene. Each new (un-seen) paragraph becomes a live, time-stamped chronicle beat. + const beats = entries + .map((e) => { + const kind = (e && (e.kind || e.type)) || "narration"; + if (kind !== "narration" && kind !== "dialogue") return null; + const clean = sanitize(e && (e.text || e.detail)); + return clean && claimNarration(clean) ? { kind: "narration", text: clean, at: nextLogSeq() } : null; + }) + .filter(Boolean); + if (beats.length) { + setChatBeats((prev) => [...prev, ...beats]); + // 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'. + // We deliberately KEEP pending: the action bar stays gated (one move at a time) and the + // honest "narrating" indicator stays up WHILE the scene fills in above it — the turn only + // RESOLVES when its final text lands on /chat. This is the give-up fix: a blank wait + // becomes "I can watch my story arriving," without relaxing the turn-gating semantics. + dmBeatCountRef.current += beats.length; + notePendingProgress(); + } + } + if (!cancelled && typeof payload.next === "number") eventsCursor.current = payload.next; + } catch (_e) { /* the live event tail is non-critical; the /chat tail is the backstop */ } + }; + const stop = () => { if (timer !== null) { window.clearInterval(timer); timer = null; } }; + // #393: poll a touch faster than /chat (4s) so streamed prose feels responsive without hammering + // the stdlib server — 3s is well within the session log's mid-turn write cadence. + const start = () => { if (timer === null) timer = window.setInterval(pollOnce, 3000); }; + const onVisibility = () => { + if (document.visibilityState === "visible") { pollOnce(); start(); } else { stop(); } + }; + document.addEventListener("visibilitychange", onVisibility); + onVisibility(); + return () => { cancelled = true; stop(); document.removeEventListener("visibilitychange", onVisibility); }; + }, [campaignId, source, runId, notePendingProgress, claimNarration]); return { chatBeats, log, pending, armPending, clearPending, recordPlayerEcho }; } diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index 831c0b14..4130e0b0 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -242,7 +242,24 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { // sequence) and are always the oldest, so they keep their leading position and relative order. // Array.prototype.sort is stable (ES2019+); ties are impossible anyway since the counter is unique. const mergedTail = [...chatBeats, ...log].sort((a, b) => (a?.at || 0) - (b?.at || 0)); - const visibleLog = surface ? [...recentEvents, ...mergedTail] : [...demoLog, ...log]; + // #393: dedup recentEvents against the live tail. recentEvents is the server's trailing window of + // the SAME session log that the live narration stream (chatBeats, fed by app.jsx's /events poll) + // now reads — so a paragraph that has already streamed into the live band would otherwise ALSO + // show in this leading history band (the same prose twice, adjacent). Drop any recentEvents + // narration row whose text already appears in the live tail; non-narration history rows and + // genuine pre-session prose (not in the live tail) are untouched. Keyed identically to app.jsx's + // claimNarration (whitespace-collapsed + lowercased) so the two projections of one paragraph match. + const narrationKey = (t) => sanitizeNarration(t || "").replace(/\s+/g, " ").trim().toLowerCase(); + const liveNarrationKeys = new Set( + mergedTail.filter((b) => b && b.kind === "narration").map((b) => narrationKey(b.text)).filter(Boolean), + ); + const dedupedRecent = recentEvents.filter((row) => { + const kind = (row && (row.kind || row.type)) || "narration"; + if (kind !== "narration" && kind !== "dialogue") return true; // mechanics rows always kept + const key = narrationKey(row && (row.text || row.detail)); + return !key || !liveNarrationKeys.has(key); + }); + const visibleLog = surface ? [...dedupedRecent, ...mergedTail] : [...demoLog, ...log]; const actionById = (id) => actions.find((a) => a.id === id); const enabledActionById = (id) => enabledActions.find((a) => a.id === id); diff --git a/viewer/tests/test_live_narration_stream.py b/viewer/tests/test_live_narration_stream.py new file mode 100644 index 00000000..4f4d6dc8 --- /dev/null +++ b/viewer/tests/test_live_narration_stream.py @@ -0,0 +1,409 @@ +"""Behavior tests for the #393 LIVE narration stream in `useLiveSession`. + +The #1 playtest satisfaction-killer (build e6384e8): a DM turn takes ~60-90s and the +player saw a waiting indicator but NO content building — the whole beat landed at +turn-END (the duo/human runner appends ONE {"role":"dm",...} line to /chat only after +the turn's `result` is in). Impatient personas read the blank wait as "broken" and +quit (adversarial gave up 1m23s on the cold-open; narrative ~90s on turn 2). + +The fix is bounded — NOT a streaming rewrite. The DM logs each narration/dialogue beat +via the engine's `log_event` DURING its turn, and the engine appends it to the per- +session log IMMEDIATELY (store.append_log). The viewer's `/events` endpoint already +tails that log with a line cursor. So `useLiveSession` (viewer/openworlds/app.jsx) now +polls `/events` alongside `/chat`: each new narration paragraph becomes a live, time- +stamped chronicle beat AND clears the "narrating…" indicator on first arrival — a blank +90s wait becomes 90s of prose appearing. The turn-END /chat line carries the same prose; +a shared text-keyed dedup (`claimNarration`) shows each paragraph EXACTLY ONCE, from +whichever source reached the player first. + +These tests exercise the REAL hook by transpiling the actual `.jsx` with the SAME bundled +Babel-standalone the browser uses and running it under Node with a deterministic React + +controllable fetch/interval stub (mirrors test_recovery_timing.py), so the test tracks the +shipped behavior rather than a reimplementation. They cover: + + • mid-turn /events narration is surfaced as live chatBeats (the scene builds), and the + FIRST streamed paragraph clears the pending "narrating…" indicator (the give-up fix); + • the turn-END /chat copy of an already-streamed paragraph is DEDUPED (shown once); + • a /chat paragraph that did NOT stream still renders (no regression for non-streamed prose); + • a fresh run resets the dedup set (no cross-run suppression). +""" + +import json +import shutil +import subprocess +import unittest +from pathlib import Path + + +_OPENWORLDS = Path(__file__).resolve().parents[1] / "openworlds" +_APP = _OPENWORLDS / "app.jsx" +_SCREEN_TABLE = _OPENWORLDS / "screen-table.jsx" +_BABEL = _OPENWORLDS / "vendor" / "babel-standalone-7.29.0.min.js" + + +# A self-contained Node harness: a minimal-but-real React stub (state/refs/effects persist +# across renders; useEffect bodies actually RUN so the /chat + /events polls are live) plus a +# SCRIPTED fetch (a per-URL queue of JSON responses) and a manual interval pump. It transpiles +# screen-table.jsx (defines sanitizeNarration) then app.jsx, mounts useLiveSession over a live +# campaign, and exposes a tiny async scripting surface (`h`) so each test can enqueue /events +# and /chat responses, pump the pollers, and read the resulting chatBeats + pending state. +_HARNESS = r""" +const fs = require('fs'); +const vm = require('vm'); +const Babel = require(%(babel)s); + +// ---- a real-enough React: hook cells + effects persist; effects RUN on each render ---------- +// A real-enough React. KEY contract (mirrors React's actual model, which a naive stub gets +// wrong → infinite recursion): rendering and EFFECT-FLUSHING are decoupled. A setState only +// recomputes the render OUTPUT (cheap, synchronous, no effect side-effects); effects run in a +// SEPARATE committed pass that is re-entrancy-guarded, so a setState fired from inside an effect +// (e.g. clearPending → setPending(null) during the poll) schedules — not recursively runs — the +// next flush. This is exactly why React defers effects to after commit; the stub must too. +function makeReact() { + const stateCells = []; + const refCells = []; + const cbCells = []; // memoized { fn, deps } per useCallback slot + const effects = []; // { deps, cleanup } per useEffect slot, in mount order + let sIdx = 0, rIdx = 0, cIdx = 0, eIdx = 0; + let renderFn = null; + let result = null; + const pendingEffects = []; // effect bodies queued this render, drained by flushEffects() + let flushing = false; // re-entrancy guard: a setState during a flush re-renders only + + function useState(init) { + const i = sIdx++; + if (stateCells[i] === undefined) stateCells[i] = { v: typeof init === 'function' ? init() : init }; + const cell = stateCells[i]; + // setState recomputes the render output (so api() is fresh) but does NOT flush effects here — + // the committed flush owns that. This is what breaks the recursion. + const set = (next) => { cell.v = (typeof next === 'function') ? next(cell.v) : next; render(); }; + return [cell.v, set]; + } + function useRef(init) { + const i = rIdx++; + if (refCells[i] === undefined) refCells[i] = { current: init }; + return refCells[i]; + } + function depsEqual(a, b) { + if (!a || !b || a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; + } + // useCallback MUST memoize by deps (return the SAME function identity when deps are unchanged). + // A naive identity-passthrough returns a fresh fn every render → every useEffect whose deps + // include a callback sees "deps changed" every render → effects re-queue → an effect's setState + // re-renders → infinite loop at mount. Memoizing mirrors React and stabilizes effect deps. + function useCallback(fn, deps) { + const i = cIdx++; + const prev = cbCells[i]; + if (prev === undefined || !depsEqual(prev.deps, deps)) { + cbCells[i] = { fn, deps }; + return fn; + } + return prev.fn; + } + function useEffect(fn, deps) { + const i = eIdx++; + const prev = effects[i]; + const changed = !prev || !depsEqual(prev.deps, deps); + if (changed) { + pendingEffects.push(() => { + if (prev && typeof prev.cleanup === 'function') prev.cleanup(); + const cleanup = fn(); + effects[i] = { deps, cleanup: typeof cleanup === 'function' ? cleanup : null }; + }); + if (!prev) effects[i] = { deps, cleanup: null }; // seed so the slot exists + else effects[i].deps = deps; + } + } + // Recompute the render output only (resets hook cursors, re-runs the component body, re-queues + // any dep-changed effects into pendingEffects — but does NOT run them). + function render() { + sIdx = 0; rIdx = 0; cIdx = 0; eIdx = 0; + result = renderFn(); + } + // Commit: run queued effects. A setState inside an effect re-renders (re-queueing dep-changed + // effects) but cannot recurse into flushEffects (the guard) — those run in the same drain loop. + function flushEffects() { + if (flushing) return; + flushing = true; + try { while (pendingEffects.length) pendingEffects.shift()(); } + finally { flushing = false; } + } + const React = { useState, useRef, useCallback, useEffect, createElement: () => null, Fragment: 'F' }; + function mount(fn) { renderFn = fn; render(); flushEffects(); } + function commit() { flushEffects(); } + function api() { return result; } + return { React, mount, commit, api }; +} + +// ---- a SCRIPTED fetch: queues of JSON payloads keyed by URL path ----------------------------- +const responses = { '/events': [], '/chat': [] }; +function enqueue(path, payload) { responses[path].push(payload); } +function pathOf(url) { return String(url).split('?')[0]; } +function fetchStub(url) { + const p = pathOf(url); + const q = responses[p]; + const payload = (q && q.length) ? q.shift() : {}; // empty when nothing scripted + return Promise.resolve({ ok: true, json: () => Promise.resolve(payload) }); +} + +// ---- a manual interval pump: collect the registered poll callbacks, fire on demand ----------- +const intervals = []; +function setIntervalStub(fn) { intervals.push(fn); return intervals.length; } +function clearIntervalStub() {} +// onVisibility() calls pollOnce() immediately AND registers an interval; we expose a manual "tick" +// that fires every registered poll once AND awaits the async chain each returns (the polls do +// `await fetch().json()` then setState), so a test can read fully-settled state afterward. A couple +// of extra microtask drains cover any trailing .then() the poll schedules after its setState. +async function tickAll() { + const ps = intervals.slice().map((fn) => { try { return fn(); } catch (_e) { return undefined; } }); + await Promise.all(ps.map((p) => Promise.resolve(p))); + await new Promise((r) => setImmediate(r)); +} + +let NOW = 1000000; +const sandbox = { + React: null, // set below + ReactDOM: { createRoot: () => ({ render() {} }) }, + document: { addEventListener() {}, removeEventListener() {}, visibilityState: 'visible', getElementById: () => ({}), head: { appendChild() {} }, createElement: () => ({}) }, + setTimeout: () => 0, clearTimeout: () => {}, + setInterval: setIntervalStub, clearInterval: clearIntervalStub, + fetch: fetchStub, + // The JSX poll code runs INSIDE this vm context, so the web/JS globals it uses must be present + // here (a vm context does NOT inherit the host's globals). URLSearchParams is built by both polls; + // Promise/JSON are used by the async fetch chain + sanitize. Missing URLSearchParams was the bug: + // `new URLSearchParams()` threw, the poll's try/catch swallowed it, and no fetch ever fired. + URLSearchParams, Promise, JSON, Set, Array, Object, String, Boolean, Number, + console, +}; +sandbox.window = sandbox; +sandbox.Date = { now: () => NOW }; +const reactHost = makeReact(); +sandbox.React = reactHost.React; +vm.createContext(sandbox); + +function load(p, stripBootstrap) { + let src = fs.readFileSync(p, 'utf8'); + if (stripBootstrap) { + const i = src.indexOf('ReactDOM.createRoot'); + if (i !== -1) src = src.slice(0, i); + } + const code = Babel.transform(src, { presets: ['react'], filename: p }).code; + vm.runInContext(code, sandbox); +} +load(%(screen_table)s); +load(%(app)s, true); + +const useLiveSession = sandbox.window.useLiveSession; +if (typeof useLiveSession !== 'function') throw new Error('useLiveSession not exported'); + +// Mount over a LIVE campaign so the polls run. The `state` ref is mutable so a test can switch +// the bound run (campaignId change) to assert the dedup reset. +const state = { activeCampaign: 'camp1', campaigns: [{ id: 'camp1', campaign_id: 'camp1' }] }; +reactHost.mount(() => useLiveSession(state)); + +// A microtask drain: the pollers are async (await fetch().json()); after firing them we must let +// the promise chain settle so setChatBeats/clearPending have applied before we read state. +function drain() { return new Promise((r) => setImmediate(r)); } + +const h = { + enqueue, + // fire every registered poll once + AWAIT its async chain (so setChatBeats/clearPending have + // applied), then commit any effects a poll's setState re-queued. Await this in the script. + tick: async () => { await tickAll(); reactHost.commit(); }, + // switch the bound run: a fresh mount re-renders the (same-cell) hook so the campaignId-change + // effect fires (resetting the cursor + dedup set), exactly like navigating into a new live run. + setCampaign: (id) => { state.activeCampaign = id; state.campaigns = [{ id, campaign_id: id }]; reactHost.mount(() => useLiveSession(state)); }, + beats: () => (reactHost.api().chatBeats || []).map((b) => ({ kind: b.kind, text: b.text })), + narrationTexts: () => (reactHost.api().chatBeats || []).filter((b) => b.kind === 'narration').map((b) => b.text), + pending: () => reactHost.api().pending, + // arm the "DM is narrating…" indicator exactly as a posted player move does (armPending is on + // the hook's returned api). Used to prove a streamed paragraph CLEARS it (the give-up fix). + arm: (text) => reactHost.api().armPending(text || 'open the scene'), + drain, +}; + +// Each test's `script` is a sequence of statements ending in `return ()`. The scripts +// use `await` (to drain the async pollers), so we run the body inside an async arrow via a DIRECT +// eval — `eval` of a string does not by itself grant a top-level-await context, so the explicit +// `(async () => { ... })()` wrapper supplies it. The arrow's `return` makes the trailing expression +// the resolved value. `h` is in lexical scope here (a direct eval sees the enclosing consts). +const script = %(script)s; +eval('(async () => { ' + script + ' })()') + .then((result) => { process.stdout.write(JSON.stringify(result)); }) + .catch((e) => { console.error(e && e.stack || e); process.exit(1); }); +""" + + +@unittest.skipIf(shutil.which("node") is None, "node is required to transpile + run the JSX hook") +class LiveNarrationStreamTests(unittest.TestCase): + NODE_BIN = shutil.which("node") + + @classmethod + def setUpClass(cls): + for p in (_APP, _SCREEN_TABLE, _BABEL): + assert p.exists(), f"missing {p}" + + def _run(self, script: str): + program = _HARNESS % { + "babel": json.dumps(str(_BABEL)), + "screen_table": json.dumps(str(_SCREEN_TABLE)), + "app": json.dumps(str(_APP)), + "script": json.dumps(script), + } + proc = subprocess.run( + [self.NODE_BIN, "--input-type=commonjs"], + input=program, + text=True, + capture_output=True, + ) + if proc.returncode != 0: + self.fail(f"node harness failed:\nSTDOUT:{proc.stdout}\nSTDERR:{proc.stderr}") + return json.loads(proc.stdout) + + # --- sanity: the hook mounts and starts empty (the polls ran with nothing scripted) ------- + def test_starts_empty(self): + out = self._run("await h.drain(); return ({ beats: h.beats(), pending: h.pending() });") + self.assertEqual(out["beats"], []) + self.assertIsNone(out["pending"]) + + # --- #393 CORE: mid-turn /events narration surfaces as a live beat ------------------------- + def test_events_narration_surfaces_live(self): + out = self._run( + # The mount fired the first poll (nothing scripted). Now script an /events response and + # pump the interval so the live narration stream ingests it. + "h.enqueue('/events', { entries: [{ kind: 'narration', text: 'The tavern hushes as you enter.' }], next: 1 });" + "await h.tick();" + "return ({ texts: h.narrationTexts() });" + ) + self.assertEqual(out["texts"], ["The tavern hushes as you enter."], + "a mid-turn /events narration row must surface as a live chronicle beat") + + # --- #393 CORE: a streamed beat shows prose but KEEPS the turn gated (Option B) ------------ + # The give-up fix is "I can watch my story arriving", NOT "let me act mid-turn". So a paragraph + # streamed via /events while the turn is in flight must (a) appear in the chronicle, while (b) + # the "narrating…" indicator STAYS up (pending present, not stuck) — the action bar remains + # gated (one move at a time). The turn only RESOLVES (pending → null) when its final text lands + # on /chat (see test_chat_resolves_a_streamed_turn). And the streamed prose clears any 'stuck'. + def test_streamed_beat_shows_prose_but_keeps_turn_gated(self): + out = self._run( + # Simulate a turn in flight: arm the "narrating…" indicator (as a posted move does). + "h.arm('I push open the door');" + "var armed = h.pending();" + # mid-turn: the DM logs a beat -> it streams via /events while the turn is still running. + "h.enqueue('/events', { entries: [{ kind: 'narration', text: 'The hinges shriek.' }], next: 1 });" + "await h.tick();" + "var p = h.pending();" + "return ({ armed_present: !!armed, pending_present: !!p, stuck: !!(p && p.stuck), texts: h.narrationTexts() });" + ) + self.assertTrue(out["armed_present"], "the indicator should arm when a move is posted") + self.assertEqual(out["texts"], ["The hinges shriek."], + "the mid-turn beat must stream live (the scene visibly builds)") + self.assertTrue(out["pending_present"], + "a streamed beat must KEEP the turn gated — the bar stays disabled until /chat resolves it") + self.assertFalse(out["stuck"], + "fresh streamed prose proves the turn is alive — it must not read as stuck") + + # --- #393: the turn-END /chat line RESOLVES a turn whose prose already streamed -------------- + def test_chat_resolves_a_streamed_turn(self): + out = self._run( + "h.arm('I push open the door');" + # prose streams mid-turn (turn stays gated) + "h.enqueue('/events', { entries: [{ kind: 'narration', text: 'The hinges shriek.' }], next: 1 });" + "await h.tick();" + "var midTurn = h.pending();" + # turn-END: the same prose lands on /chat → the turn resolves (pending clears, deduped). + "h.enqueue('/chat', { items: [{ role: 'dm', text: 'The hinges shriek.' }], next: 1 });" + "await h.tick();" + "return ({ mid_pending: !!midTurn, after_pending: h.pending(), texts: h.narrationTexts() });" + ) + self.assertTrue(out["mid_pending"], "the turn is gated while streaming") + self.assertIsNone(out["after_pending"], + "the turn-END /chat line must RESOLVE the turn (clear the indicator, re-open the bar)") + self.assertEqual(out["texts"], ["The hinges shriek."], + "and the streamed paragraph is still shown exactly once (the /chat copy deduped)") + + # --- a mid-turn /events DIALOGUE row also streams live (not just narration) ---------------- + def test_events_dialogue_streams_live(self): + out = self._run( + "h.enqueue('/events', { entries: [{ kind: 'dialogue', text: '\"Well met, traveller.\"' }], next: 1 });" + "await h.tick();" + "return ({ texts: h.narrationTexts() });" + ) + self.assertEqual(out["texts"], ['"Well met, traveller."'], + "a mid-turn /events dialogue row must also stream live as chronicle prose") + + # --- #393 CORE: the turn-END /chat copy of an already-streamed paragraph is DEDUPED -------- + def test_chat_copy_of_streamed_paragraph_is_deduped(self): + out = self._run( + # 1) mid-turn: the paragraph streams via /events. + "h.enqueue('/events', { entries: [{ kind: 'narration', text: 'Rain lashes the cobbles.' }], next: 1 });" + "await h.tick();" + "var afterStream = h.narrationTexts();" + # 2) turn-END: the SAME prose lands on /chat (the runner's chatlog dm line). + "h.enqueue('/chat', { items: [{ role: 'dm', text: 'Rain lashes the cobbles.' }], next: 1 });" + "await h.tick();" + "var afterChat = h.narrationTexts();" + "return ({ afterStream: afterStream, afterChat: afterChat });" + ) + self.assertEqual(out["afterStream"], ["Rain lashes the cobbles."]) + self.assertEqual(out["afterChat"], ["Rain lashes the cobbles."], + "the turn-END /chat copy of an already-streamed paragraph must be deduped (shown once)") + + # --- dedup is whitespace/case-insensitive (the two projections may differ cosmetically) ---- + def test_dedup_normalizes_whitespace_and_case(self): + out = self._run( + "h.enqueue('/events', { entries: [{ kind: 'narration', text: 'The gate groans open.' }], next: 1 });" + "await h.tick();" + "h.enqueue('/chat', { items: [{ role: 'dm', text: 'The gate groans open.' }], next: 1 });" + "await h.tick();" + "return ({ texts: h.narrationTexts() });" + ) + self.assertEqual(len(out["texts"]), 1, + "a cosmetic whitespace/case difference between the streamed + chat copy must still dedup") + + # --- no regression: a /chat paragraph that did NOT stream still renders -------------------- + def test_unstreamed_chat_paragraph_still_renders(self): + out = self._run( + # Nothing on /events; a /chat-only beat (e.g. a terse turn that ended on prose with no + # interim log_event) must still appear — the dedup must not swallow genuinely-new prose. + "h.enqueue('/chat', { items: [{ role: 'dm', text: 'You awaken to birdsong.' }], next: 1 });" + "await h.tick();" + "return ({ texts: h.narrationTexts() });" + ) + self.assertEqual(out["texts"], ["You awaken to birdsong."]) + + # --- a player echo on /chat is NEVER deduped against narration ----------------------------- + def test_player_chat_line_is_not_deduped(self): + out = self._run( + "h.enqueue('/chat', { items: [{ role: 'player', text: 'I draw my blade.' }, { role: 'dm', text: 'Steel rings free.' }], next: 2 });" + "await h.tick();" + "return ({ beats: h.beats() });" + ) + kinds = [b["kind"] for b in out["beats"]] + self.assertIn("dialog", kinds, "the player's own line must render (as a dialog beat), never deduped as narration") + self.assertIn("narration", kinds) + + # --- a fresh run RESETS the dedup set (no cross-run suppression) --------------------------- + def test_new_run_resets_dedup(self): + out = self._run( + # Run 1 streams a paragraph. + "h.enqueue('/events', { entries: [{ kind: 'narration', text: 'A familiar refrain.' }], next: 1 });" + "await h.tick();" + "var run1 = h.narrationTexts();" + # Switch to a NEW campaign/run, then the SAME text arrives — it must NOT be suppressed by + # run-1's dedup keys (the cursor + seen-set reset per run). + "h.setCampaign('camp2'); await h.drain();" + "h.enqueue('/events', { entries: [{ kind: 'narration', text: 'A familiar refrain.' }], next: 1 });" + "await h.tick();" + "var run2 = h.narrationTexts();" + "return ({ run1: run1, run2: run2 });" + ) + self.assertEqual(out["run1"], ["A familiar refrain."]) + self.assertEqual(out["run2"], ["A familiar refrain."], + "a new run must reset the dedup set so identical prose isn't wrongly suppressed across runs") + + +if __name__ == "__main__": + unittest.main()