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
89 changes: 74 additions & 15 deletions viewer/openworlds/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -161,25 +161,58 @@ function useLiveSession(state) {
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());
// #393/#405: dedup key sets across BOTH narration sources. There are TWO key spaces and the
// distinction is the whole fix:
// • `seenSeq` — the STABLE session-log line index (`seq`) the server now stamps on every /events
// entry (and on the recentEvents history band). This is the engine's sole-writer per-beat
// identity; it does NOT depend on the prose, so a re-ingest (windowing / a session-rotation
// cursor rewind) of the same line collapses to one row, and it is the chronological sort key.
// • `seenText` — a normalized-TEXT fallback, used ONLY for narration that has NO seq (a /chat-only
// beat: a terse turn that streamed nothing via /events, or the human/native path where /chat is
// the sole source). Text keys are fragile (a reworded reply, or a whole-turn /chat blob vs the
// per-paragraph /events rows, hash differently) — which is exactly why /chat narration is now a
// FALLBACK only and the canonical live source is the seq-keyed /events stream.
// #405 root-cause: previously a single text-keyed set reconciled both sources, so the DM rewording
// its turn-END reply — or /chat carrying the whole beat as one blob while /events carried N
// paragraphs — defeated the dedup and the chronicle showed each beat 3-4× and out of order.
const seenSeq = React.useRef(new Set());
const seenText = React.useRef(new Set());
// #405: did the CURRENT in-flight turn stream any narration via the canonical /events source? When
// true, the turn-END /chat DM line is a pure turn-RESOLUTION signal (it clears the pending
// indicator) and adds NO narration row — the session log already carried that beat's canonical,
// seq-keyed, per-paragraph prose. When the turn streamed NOTHING via /events (a terse turn that
// logged no narration, or the human/native path where /chat is the sole source) the /chat copy is
// the only source and IS rendered (text-keyed). This is reset each time a /chat DM line resolves a
// turn, so the decision is per-TURN, not per-run: a streamed turn 1 followed by a terse turn 2 still
// shows turn 2's /chat-only prose. The /events poll always lands a turn's paragraphs before its
// turn-END /chat blob (prose is logged DURING the turn; /chat is written only at turn-end, and
// /events polls faster), so this flag is reliably set by the time the resolving /chat line arrives.
const eventsStreamedThisTurnRef = React.useRef(false);
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.
// #405: claim a narration beat by its STABLE session-log `seq` (the server-stamped absolute line
// index). First-seen returns true (show it + record the id); any later arrival of the same line —
// a windowing re-mount, a session-rotation cursor rewind, or the recentEvents history band
// overlapping the live tail — returns false and is suppressed. Keyed by id, so it is immune to the
// DM rewording the prose between its streamed copy and its reply.
const claimNarrationSeq = React.useCallback((seq) => {
if (typeof seq !== "number" || !Number.isFinite(seq)) return false;
if (seenSeq.current.has(seq)) return false;
seenSeq.current.add(seq);
return true;
}, []);
// #393/#405: the TEXT-key fallback for narration with no seq (a /chat-only beat). Whitespace-
// collapsed + lowercased. First-seen returns true; a repeat returns false. Used only when no seq is
// available — seq-keyed beats never consult this set, so a reworded /chat copy can't double a beat
// that already streamed (that path is gated by eventsStreamedThisTurnRef, below).
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);
if (seenText.current.has(key)) return false;
seenText.current.add(key);
return true;
}, []);

Expand Down Expand Up @@ -272,7 +305,9 @@ function useLiveSession(state) {
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
seenSeq.current = new Set(); // #405: a fresh run shares no seq dedup keys with the last
seenText.current = new Set(); // #405: …nor any text-key fallback keys
eventsStreamedThisTurnRef.current = false; // #405: no /events narration streamed for any turn yet
setChatBeats([]);
setLog([]);
clearPending();
Expand Down Expand Up @@ -307,9 +342,18 @@ function useLiveSession(state) {
// 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;
// #405: a /chat DM line is the turn-RESOLUTION signal (it clears the pending indicator
// below). It is NOT a second narration row when this run is streaming its prose via the
// canonical seq-keyed /events source: the /chat reply is the SAME beat, but as the whole
// turn's reply text — often a single blob, and sometimes REWORDED — so rendering it would
// duplicate (and mis-order) what already streamed per-paragraph. The session log is the
// canonical chronicle; the /chat copy is suppressed here. We ONLY render a /chat DM line
// as narration when nothing streamed via /events for this run (a terse turn that logged
// no narration, or the human/native path where /chat is the sole source) — text-keyed,
// since a chat-only beat has no session-log seq, and there is no /events stream to
// collide with in that case.
if (eventsStreamedThisTurnRef.current) return null;
const clean = sanitize(it.text);
// #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);
Expand All @@ -321,6 +365,10 @@ function useLiveSession(state) {
if (dmLineArrived) {
dmBeatCountRef.current += beats.filter((b) => b.kind === "narration").length;
clearPending();
// #405: the turn is over → reset the per-turn "/events streamed" flag so the NEXT turn is
// judged on ITS OWN streaming. Without this, a streamed turn would wrongly suppress a
// later TERSE turn's /chat-only prose (the flag would stay stuck true for the whole run).
eventsStreamedThisTurnRef.current = false;
}
}
if (!cancelled && typeof payload.next === "number") chatCursor.current = payload.next;
Expand Down Expand Up @@ -370,7 +418,18 @@ function useLiveSession(state) {
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;
if (!clean) return null;
// #405: dedup by the STABLE session-log `seq` the server stamps on each entry — NOT by
// prose. So a paragraph re-ingested by a windowing re-mount or a session-rotation cursor
// rewind collapses to one row, and the dedup can't be defeated by a reworded copy. A
// legacy entry with no seq (older server) falls back to the text key. The seq doubles as
// the chronological order key: `orderSeq` keeps the engine's session-log line order so
// live narration can never interleave out of order with the (now-removed) /chat source.
const seq = (e && typeof e.seq === "number") ? e.seq : null;
const fresh = (seq !== null) ? claimNarrationSeq(seq) : claimNarration(clean);
if (!fresh) return null;
eventsStreamedThisTurnRef.current = true; // the current turn HAS streamed live narration
return { kind: "narration", text: clean, at: nextLogSeq(), orderSeq: seq };
})
.filter(Boolean);
if (beats.length) {
Expand Down
81 changes: 54 additions & 27 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,54 @@ const DECLARE_HINT = "Type what your hero does in your own words, then Declare t
// Older beats are still available in full in the Quest Journal.
const CHRONICLE_RENDER_CAP = 50;

// #405: assemble the chronicle's full ordered, de-duplicated row list from its three sources. Pure
// (no React, no DOM) so the exactly-once + chronological-order contract is unit-testable. The whole
// narration-duplication fix lives here + in app.jsx's useLiveSession dedup:
// • ORDER by the STABLE session-log sequence (`orderSeq` on a live /events beat / `seq` on a
// recentEvents row — both = the engine's absolute session-log line index) when present, so the
// live tail can never interleave out of chronological order; fall back to the client-side ingest
// counter `.at` for rows with no seq (player echoes, a chat-only beat). Array.prototype.sort is
// stable (ES2019+), so equal-key rows keep insertion order.
// • DEDUP recentEvents (the server's trailing window of the SAME session log the live /events
// stream reads) against the live tail so a paragraph never shows in BOTH bands. Prefer the
// stable `seq` (a row in both bands shares it → the match is immune to the prose AND to a
// windowing re-mount); fall back to a normalized TEXT key only for rows lacking a seq (legacy
// server / a chat-only beat), keyed identically to app.jsx's text fallback. Non-narration
// history rows (rolls/system/combat) are always kept.
// recentEvents stay the leading (oldest) band: they are the session log's trailing lines, all at or
// before the live tail's lines, and the dedup guarantees no overlap — so a plain concat is in order.
function buildChronicleLog(recentEvents, chatBeats, log) {
const recent = Array.isArray(recentEvents) ? recentEvents : [];
const beats = Array.isArray(chatBeats) ? chatBeats : [];
const echoes = Array.isArray(log) ? log : [];
const sanitize = (t) => (typeof window !== "undefined" && typeof window.sanitizeNarration === "function")
? window.sanitizeNarration(t || "") : (t || "");
const narrationKey = (t) => sanitize(t || "").replace(/\s+/g, " ").trim().toLowerCase();
const orderOf = (e) => (e && typeof e.orderSeq === "number") ? e.orderSeq : null;
const mergedTail = [...beats, ...echoes].sort((a, b) => {
const sa = orderOf(a), sb = orderOf(b);
if (sa !== null && sb !== null) return sa - sb; // both from the session log → true beat order
return (a?.at || 0) - (b?.at || 0); // else fall back to client ingest order
});
const liveSeqs = new Set(
mergedTail.filter((b) => b && b.kind === "narration" && typeof b.orderSeq === "number").map((b) => b.orderSeq),
);
const liveNarrationKeys = new Set(
mergedTail.filter((b) => b && b.kind === "narration").map((b) => narrationKey(b.text)).filter(Boolean),
);
const dedupedRecent = recent.filter((row) => {
const kind = (row && (row.kind || row.type)) || "narration";
if (kind !== "narration" && kind !== "dialogue") return true; // mechanics rows always kept
const seq = row && row.seq;
if (typeof seq === "number") return !liveSeqs.has(seq); // stable-id match (prose-independent)
const key = narrationKey(row && (row.text || row.detail));
return !key || !liveNarrationKeys.has(key);
});
return [...dedupedRecent, ...mergedTail];
}
// Exposed for tests/devtools introspection (additive — the component calls the local fn directly).
if (typeof window !== "undefined") window.buildChronicleLog = buildChronicleLog;

function ScreenTable({ onNavigate, state, setState, liveSession }) {
const campaigns = Array.isArray(state?.campaigns) ? state.campaigns : [];
const activeCampaign =
Expand Down Expand Up @@ -247,33 +295,12 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) {
const visibleQuests = quests.filter((q) => !q.status || q.status === "active" || q.status === "open");
const canAct = Boolean(surface?.can_act);
const readOnlyReason = blockedActions.find((a) => a.disabled_reason)?.disabled_reason || "read-only surface";
// #274: the chronicle merges three sources — recentEvents (engine history), chatBeats (the live
// DM/player tail) and log (local optimistic player echoes). A plain concat let a just-typed action
// (in `log`) sort ABOVE the older DM prose it answered (in `chatBeats`), because the two live in
// separate arrays appended at different times. chatBeats + log carry a shared monotonic `.at`
// (stamped client-side in app.jsx at creation/ingest), so a STABLE sort by `.at` restores true
// chronological order across both. recentEvents have no `.at` (server history with no client
// 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));
// #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];
// #274/#393/#405: the chronicle merges three sources — recentEvents (the server's trailing window
// of the session log), chatBeats (the live DM/player tail), and log (local optimistic player
// echoes) — into one ordered, de-duplicated list. The merge/dedup/order is a PURE function
// (buildChronicleLog, below) so the exactly-once + chronological-order contract is unit-testable
// without mounting the component. See its doc-comment for the stable-`seq`-keyed reconciliation.
const visibleLog = surface ? buildChronicleLog(recentEvents, chatBeats, log) : [...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
Expand Down
Loading
Loading