diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx
index 5d29c5e7..6894141a 100644
--- a/viewer/openworlds/app.jsx
+++ b/viewer/openworlds/app.jsx
@@ -42,6 +42,151 @@ window.OpenWorldsA11y = window.OpenWorldsA11y || {
},
};
+// #342: neutralize markup in player free-text BEFORE it is sent to the engine or echoed into the
+// chronicle. The adversarial run (#324 v2) found that submitting "", "{{ }}", or
+// "…" sent the raw markup straight to the DM (it stalled 35s+) and rode along in the local
+// echo. React already escapes on *display* (it never renders raw HTML), so this is NOT an XSS fix —
+// it is a robustness fix: a hostile/odd free-text turn must not be able to wedge the DM or the loop.
+// We strip angle-bracket tags and defang template-style "{{ … }}" / "}}" runs to plain text, collapse
+// whitespace, and cap absurd length — keeping ordinary apostrophes, quotes, punctuation, and emoji
+// intact so a normal in-character line is untouched. Viewer-side only; the engine stays sole writer.
+window.neutralizeMarkup = window.neutralizeMarkup || function neutralizeMarkup(raw) {
+ if (typeof raw !== "string") return "";
+ let t = raw;
+ // Drop anything that looks like an HTML/XML tag (incl. bodies are kept as text
+ // once their tags are removed). Do it twice so "<>" style nesting can't leave a stray bracket.
+ t = t.replace(/<\/?[a-zA-Z][^>]*>/g, " ").replace(/<\/?[a-zA-Z][^>]*>/g, " ");
+ // Defang stray angle brackets that weren't part of a full tag.
+ t = t.replace(/[<>]/g, " ");
+ // Defang template/handlebars-style delimiters so they can't be interpreted downstream.
+ t = t.replace(/\{\{+/g, "(").replace(/\}\}+/g, ")");
+ // Collapse whitespace runs (a pasted wall of newlines/tabs shouldn't reach the DM as-is).
+ t = t.replace(/\s+/g, " ").trim();
+ // Hard cap — a 1000+ char dump is an attack-class input, not a turn.
+ if (t.length > 2000) t = t.slice(0, 2000).trim();
+ return t;
+};
+
+// #340 + #342: the live-session "in-flight turn" state — the /chat tail, its cursor, the
+// accumulated DM/player beats, the local player echo, AND the "DM is narrating…" pending
+// indicator — lifted from ScreenTable to the App so it SURVIVES screen navigation. Previously
+// all of this was local to ScreenTable, so navigating away (Table→Map→Party) unmounted it: the
+// in-flight DM beat that landed while away was never ingested (a silent "story hole", #340) and
+// the pending indicator reset to null on return (the bar re-opened as if the turn had finished).
+// Owning it at the app level means the /chat poll keeps running regardless of which screen is
+// mounted, the beat always lands in the log, and the narrating state clears correctly on the
+// turn that actually resolved it — no matter where the player wandered.
+//
+// The poll is best-effort and a no-op unless a LIVE campaign is bound (mirrors the server's
+// /chat gating: empty items when no chat is configured / the view isn't the live run).
+const PENDING_RECOVERY_MS = 90 * 1000; // #342: re-enable the bar if the DM stalls this long…
+const PENDING_BACKSTOP_MS = 12 * 60 * 1000; // …with the original hard backstop as a final net.
+function useLiveSession(state) {
+ const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : [];
+ const activeCampaign =
+ campaigns.find((c) => c.id === state?.activeCampaign) ||
+ campaigns[0] ||
+ {};
+ const campaignId = activeCampaign.campaign_id || state?.activeCampaign || activeCampaign.id || "";
+ const source = activeCampaign.source || "";
+ const runId = activeCampaign.runId || "";
+
+ const [chatBeats, setChatBeats] = React.useState([]);
+ 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 dmBeatCountRef = React.useRef(0);
+ 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 || ""));
+
+ 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]);
+
+ // #342: arm the narrating indicator + a SHORT recovery timeout. If a DM beat doesn't arrive within
+ // PENDING_RECOVERY_MS the turn is flagged `stuck` (the bar re-enables with a "try again" hint)
+ // instead of staying frozen until the 12-minute backstop. A real beat (below) clears it outright.
+ const armPending = React.useCallback((text) => {
+ clearTimers();
+ setPending({ text, since: Date.now(), stuck: false });
+ recoveryTimer.current = window.setTimeout(() => {
+ setPending((p) => (p ? { ...p, stuck: true } : p));
+ }, PENDING_RECOVERY_MS);
+ backstopTimer.current = window.setTimeout(() => setPending(null), PENDING_BACKSTOP_MS);
+ }, [clearTimers]);
+
+ const recordPlayerEcho = React.useCallback((who, text) => {
+ setLog((l) => [...l, { kind: "action", who, text }]);
+ }, []);
+
+ React.useEffect(() => clearTimers, [clearTimers]);
+
+ // When the bound live campaign changes, reset the tail so we don't bleed one run's beats into
+ // another (the cursor is per-file; a new run starts at 0).
+ React.useEffect(() => {
+ chatCursor.current = 0;
+ dmBeatCountRef.current = 0;
+ setChatBeats([]);
+ setLog([]);
+ clearPending();
+ }, [campaignId, source, runId, clearPending]);
+
+ // The app-level /chat poll. Visibility-aware (pauses when the tab is hidden) and best-effort.
+ 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(chatCursor.current));
+ const resp = await fetch(`/chat?${params.toString()}`, { cache: "no-store" });
+ if (!resp.ok) return;
+ const payload = await resp.json();
+ const items = Array.isArray(payload.items) ? payload.items : [];
+ if (!cancelled && items.length) {
+ const beats = items
+ .map((it) => {
+ if (it.role === "player") return { kind: "dialog", who: "You", text: it.text };
+ const clean = sanitize(it.text);
+ return clean ? { kind: "narration", text: clean } : 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")) {
+ dmBeatCountRef.current += beats.filter((b) => b.kind === "narration").length;
+ clearPending();
+ }
+ }
+ if (!cancelled && typeof payload.next === "number") chatCursor.current = payload.next;
+ } catch (_e) { /* chat tail is non-critical; keep last good */ }
+ };
+ const stop = () => { if (timer !== null) { window.clearInterval(timer); timer = null; } };
+ const start = () => { if (timer === null) timer = window.setInterval(pollOnce, 4000); };
+ 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, clearPending]);
+
+ return { chatBeats, log, pending, armPending, clearPending, recordPlayerEcho };
+}
+window.useLiveSession = useLiveSession;
+
function App() {
const [state, setState] = React.useState(window.INITIAL_STATE || {});
const [screen, setScreen] = React.useState("launcher");
@@ -57,6 +202,12 @@ function App() {
? window.useTweaks(TWEAK_DEFAULTS)
: [TWEAK_DEFAULTS, () => {}]);
+ // #340: the in-flight-turn / live-narration state lives HERE (above the screen router) so it
+ // survives navigation — the DM beat lands and the narrating indicator clears no matter which
+ // screen is mounted when the turn resolves. ScreenTable reads/writes it via the `liveSession`
+ // prop; the nav rail and every other screen are intentionally untouched by it.
+ const liveSession = useLiveSession(state);
+
React.useEffect(() => {
document.documentElement.setAttribute("data-palette", t.palette || "warm");
}, [t.palette]);
@@ -281,6 +432,7 @@ function App() {
setCampMode={setCampMode}
nativeState={nativeState}
refreshNative={refreshNative}
+ liveSession={liveSession}
/>
@@ -358,11 +510,11 @@ function capabilityForScreen(screen, nativeState) {
return null;
}
-function ScreenRouter({ screen, state, setState, onNavigate, campMode, setCampMode, nativeState, refreshNative }) {
+function ScreenRouter({ screen, state, setState, onNavigate, campMode, setCampMode, nativeState, refreshNative, liveSession }) {
switch (screen) {
case "launcher": return ;
case "roster": return ;
- case "table": return ;
+ case "table": return ;
case "combat": return ;
case "character": return ;
case "create": return ;
diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx
index 79171ff3..dcc44e17 100644
--- a/viewer/openworlds/screen-table.jsx
+++ b/viewer/openworlds/screen-table.jsx
@@ -79,7 +79,7 @@ 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.";
-function ScreenTable({ onNavigate, state, setState }) {
+function ScreenTable({ onNavigate, state, setState, liveSession }) {
const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : [];
const activeCampaign =
campaigns.find((c) => c.id === state?.activeCampaign) ||
@@ -90,18 +90,15 @@ function ScreenTable({ onNavigate, state, setState }) {
const [advisory, setAdvisory] = React.useState(null);
const [surfaceStatus, setSurfaceStatus] = React.useState("loading");
const demoLog = [];
- const [log, setLog] = React.useState([]);
- const [chatBeats, setChatBeats] = React.useState([]);
- const chatCursor = React.useRef(0);
const [input, setInput] = React.useState("");
- // #327: a visible "DM is narrating…" affordance. The unchanged DM's opening / resolving turn
- // can take many minutes; without this the play loop reads as frozen the moment a move is sent.
- // `pending` holds the submitted line + when it was sent; it is set the instant /move succeeds
- // and cleared when a NEW DM narration beat lands via /chat (tracked by dmBeatCount below) — or
- // by a long safety timeout so a dropped beat can never wedge the bar shut forever.
- const [pending, setPending] = React.useState(null);
- const dmBeatCountRef = React.useRef(0);
- const pendingTimer = React.useRef(null);
+ // #340: the in-flight-turn state (the /chat tail + accumulated beats, the local player echo, and
+ // the "DM is narrating…" pending indicator) is owned by the APP (useLiveSession) so it survives
+ // screen navigation — a DM beat that lands while the player is on another screen still gets
+ // ingested, and the narrating state clears on the turn that actually resolved it. ScreenTable
+ // reads/writes that lifted state through the `liveSession` prop. A no-op fallback keeps the
+ // screen renderable in isolation (e.g. a direct deep-link before the hook has bound a campaign).
+ const session = liveSession || { chatBeats: [], log: [], pending: null, armPending: () => {}, clearPending: () => {}, recordPlayerEcho: () => {} };
+ const { chatBeats, log, pending } = session;
const logRef = React.useRef(null);
const inputRef = React.useRef(null);
const toast = window.useToast ? window.useToast() : (() => {});
@@ -158,42 +155,11 @@ function ScreenTable({ onNavigate, state, setState }) {
if (!isCancelled()) setAdvisory(advPayload?.directorAdvisory || null);
}
} catch (error) { /* advisory is non-critical; keep last good */ }
- // Live DM narration (#242 EPIC C): tail /chat so the player sees the DM's prose beats +
- // their own lines during a LIVE session — not just engine recentEvents. Best-effort;
- // empty/no-op when no chat is configured (read-only view). Mirrors dashboard.html's poll.
- try {
- const cParams = new URLSearchParams(params);
- cParams.set("since", String(chatCursor.current));
- const cResp = await fetch(`/chat?${cParams.toString()}`, { cache: "no-store" });
- if (cResp.ok) {
- const cPayload = await cResp.json();
- const items = Array.isArray(cPayload.items) ? cPayload.items : [];
- if (!isCancelled() && items.length) {
- // #335: player dialog passes through untouched; DM narration is run through
- // sanitizeNarration so a GM-advisory directive / bare engine-tool line that
- // bled into the /chat stream never reaches the player's chronicle. A beat that
- // sanitizes to empty (it was *entirely* internal) is dropped.
- const beats = items
- .map((it) => {
- if (it.role === "player") return { kind: "dialog", who: "You", text: it.text };
- const clean = sanitizeNarration(it.text);
- return clean ? { kind: "narration", text: clean } : null;
- })
- .filter(Boolean);
- if (beats.length) setChatBeats((prev) => [...prev, ...beats]);
- // #327: a fresh DM narration beat means the turn resolved — clear the pending indicator
- // so the action bar re-opens and the spinner stops. (Player echoes don't count; and a
- // beat that was wholly internal advisory — now dropped — must not count either, else a
- // leak-only turn would silently re-open the bar with no visible narration. #335)
- const dmArrived = beats.some((b) => b.kind === "narration");
- if (dmArrived) {
- dmBeatCountRef.current += beats.filter((b) => b.kind === "narration").length;
- setPending(null);
- }
- }
- if (!isCancelled() && typeof cPayload.next === "number") chatCursor.current = cPayload.next;
- }
- } catch (error) { /* chat tail is non-critical; keep last good */ }
+ // NOTE (#340): the live DM-narration /chat tail used to be polled HERE, but it's now owned by
+ // the app-level useLiveSession hook (app.jsx) so a beat that lands while the player is on
+ // another screen still gets ingested and the narrating indicator clears correctly. ScreenTable
+ // only loads its own surface + advisory; the chronicle's chat beats arrive via the `liveSession`
+ // prop. (Engine stays sole writer — this is purely where the read-poll lives.)
}, [campaignId, activeCampaign.source, activeCampaign.runId]);
React.useEffect(() => {
@@ -241,34 +207,51 @@ function ScreenTable({ onNavigate, state, setState }) {
if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
}, [visibleLog]);
- // #327: arm the "DM is narrating…" pending state when a move is accepted, and a safety
- // auto-clear so a missed /chat beat can't lock the bar forever (the DM turn is long but
- // bounded). The /chat poll clears `pending` the instant a new DM narration beat arrives.
- const armPending = (text) => {
- setPending({ text, since: Date.now() });
- if (pendingTimer.current) window.clearTimeout(pendingTimer.current);
- pendingTimer.current = window.setTimeout(() => setPending(null), 12 * 60 * 1000);
- };
- React.useEffect(() => () => { if (pendingTimer.current) window.clearTimeout(pendingTimer.current); }, []);
+ // #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).
+ // ScreenTable just calls into it. `pendingActive` is the gate for the action bar — a turn that the
+ // recovery timeout flagged `stuck` is NO LONGER pending (the bar re-opens so the player can retry).
+ const armPending = session.armPending;
+ const recordPlayerEcho = session.recordPlayerEcho;
+ const pendingActive = Boolean(pending && !pending.stuck);
+ const pendingStuck = Boolean(pending && pending.stuck);
+
+ // #342: surface the recovery exactly once when a turn goes `stuck` so the player knows the bar
+ // re-opened on purpose (the DM stalled) rather than silently.
+ const stuckNotified = React.useRef(false);
+ React.useEffect(() => {
+ if (pendingStuck && !stuckNotified.current) {
+ stuckNotified.current = true;
+ toast({ kind: "danger", title: "The Dungeon Master seems stuck", body: "No reply came back in time — your input is re-enabled. Try again or rephrase." });
+ }
+ if (!pending) stuckNotified.current = false;
+ }, [pendingStuck, pending, toast]);
const postMove = async (move, label, actionId) => {
const enabledAction = actionId ? enabledActionById(actionId) : null;
- if (!move || !canAct || pending || (actionId && !enabledAction)) {
- toast({ kind: "danger", title: "Action unavailable", body: pending ? "The Dungeon Master is still narrating — one move at a time." : readOnlyReason });
+ if (!move || !canAct || pendingActive || (actionId && !enabledAction)) {
+ toast({ kind: "danger", title: "Action unavailable", body: pendingActive ? "The Dungeon Master is still narrating — one move at a time." : readOnlyReason });
return;
}
- const text = label || move.text || move.name || "declares an action";
+ // #342: neutralize any markup in a free-text move (kind "do"/"say"/etc. carry the player's words
+ // in move.text) BEFORE it is sent to the engine OR echoed — so an injection-y turn can't choke
+ // the DM or ride along in the chronicle as raw markup. Structured moves (no free text) pass through.
+ const cleanMove = (typeof move.text === "string" && move.text)
+ ? { ...move, text: window.neutralizeMarkup(move.text) }
+ : move;
+ const rawLabel = label || cleanMove.text || cleanMove.name || "declares an action";
+ const text = window.neutralizeMarkup(String(rawLabel)) || "declares an action";
try {
const response = await fetch(writeLane.endpoint || "/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ...move, campaign: surface?.campaign_id || campaignId }),
+ body: JSON.stringify({ ...cleanMove, campaign: surface?.campaign_id || campaignId }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload.ok === false) {
throw new Error(payload.reason || `move ${response.status}`);
}
- setLog((l) => [...l, { kind: "action", who: hero.name, text }]);
+ recordPlayerEcho(hero.name, text);
armPending(text);
loadSurface();
} catch (error) {
@@ -277,7 +260,7 @@ function ScreenTable({ onNavigate, state, setState }) {
};
const sendAction = async () => {
- if (pending) return;
+ if (pendingActive) return;
const text = input.trim();
if (!text) return;
const action = actionById("do");
@@ -299,7 +282,7 @@ function ScreenTable({ onNavigate, state, setState }) {
};
const invokeAction = (action) => {
- if (pending) {
+ if (pendingActive) {
toast({ kind: "danger", title: "Action unavailable", body: "The Dungeon Master is still narrating — one move at a time." });
return;
}
@@ -396,7 +379,8 @@ function ScreenTable({ onNavigate, state, setState }) {
{visibleLog.length ? visibleLog.map((entry, i) => (
)) :
No moves yet
}
- {pending && }
+ {pendingActive && }
+ {pendingStuck && }
{/* Action bar */}
@@ -408,10 +392,10 @@ function ScreenTable({ onNavigate, state, setState }) {
{/* #337: dice buttons explain themselves on hover — a newbie didn't know d20/d12/d8/d6 ask the DM for a check. */}
-
-
-
-
+
+
+
+
{/* #337: one-line hint under the action bar so a first-timer knows free-text + Declare is the core loop, distinct from the quick-action buttons. */}