diff --git a/viewer/openworlds/app.jsx b/viewer/openworlds/app.jsx index d8889595..96b2df79 100644 --- a/viewer/openworlds/app.jsx +++ b/viewer/openworlds/app.jsx @@ -302,15 +302,22 @@ function useLiveSession(state) { // 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 || "")); - // #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); + // #405/BUG2: claim a narration beat by its STABLE, SESSION-SCOPED key — the composite + // `${sid}:${seq}` (the server-stamped session id + the absolute session-log 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. + // BUG2 root cause: the bare `seq` is only a PER-SESSION-LOG line index — it carries NO session + // scope. When the engine ROTATES the session log (cold-open start_session + the DM-turn-retry + // re-mint, 5e71f77) the new session's narration restarts at seq 0,1,2 — the SAME values the + // cold-open already claimed — so the post-move reply was wrongly suppressed here (and dropped by + // buildChronicleLog's matching seq set). Composing the session id makes the key globally unique + // across rotations, while preserving within-session monotonicity for the order tiebreak. + const claimNarrationSeq = React.useCallback((key) => { + if (typeof key !== "string" || !key) return false; + if (seenSeq.current.has(key)) return false; + seenSeq.current.add(key); return true; }, []); // #393/#405: the TEXT-key fallback for narration with no seq (a /chat-only beat). Whitespace- @@ -559,6 +566,12 @@ function useLiveSession(state) { if (!resp.ok) return; const payload = await resp.json(); const entries = Array.isArray(payload.entries) ? payload.entries : []; + // BUG2: the server now stamps the resolved session id on the /events response so the client + // can SESSION-SCOPE each `seq`. The composite `${sid}:${seq}` is globally unique across a + // session rotation (where the bare line index restarts at 0,1,2 and collided with the prior + // session's cold-open). Empty sid (legacy server) degrades to ":${seq}", still unique within + // the single session it serves. + const sid = (payload && typeof payload.sid === "string") ? payload.sid : ""; 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 @@ -569,17 +582,19 @@ function useLiveSession(state) { if (kind !== "narration" && kind !== "dialogue") return null; const clean = sanitize(e && (e.text || e.detail)); if (!clean) return null; - // #405: dedup by the STABLE session-log `seq` the server stamps on each entry — NOT by + // #405/BUG2: dedup by the STABLE, SESSION-SCOPED composite key `${sid}:${seq}` — 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. + // rewind collapses to one row, the dedup can't be defeated by a reworded copy, AND a + // post-rotation beat (a fresh session's seq 0,1,2) is no longer suppressed by collision + // with a prior session's seq 0,1,2 (BUG2). A legacy entry with no seq (older server) + // falls back to the text key. `orderSeq` carries the SAME composite as the chronological + // order key; compareChronicle parses its numeric tail for the within-session tiebreak. const seq = (e && typeof e.seq === "number") ? e.seq : null; - const fresh = (seq !== null) ? claimNarrationSeq(seq) : claimNarration(clean); + const seqKey = (seq !== null) ? `${sid}:${seq}` : null; + const fresh = (seqKey !== null) ? claimNarrationSeq(seqKey) : 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, eventAt: e && e.t }; + return { kind: "narration", text: clean, at: nextLogSeq(), orderSeq: seqKey, eventAt: e && e.t }; }) .filter(Boolean); if (beats.length) { diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index 18a558dc..8c8f95b7 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -323,17 +323,40 @@ function buildChronicleLog(recentEvents, chatBeats, log) { const label = QUICK_ACTION_REPLAY_LABELS[key]; return label ? { ...entry, kind: "action", text: label } : entry; }; - const orderOf = (e) => { - if (e && typeof e.orderSeq === "number") return e.orderSeq; - if (e && typeof e.seq === "number") return e.seq; + // #405/BUG2: the STABLE, SESSION-SCOPED beat identity — the composite `${sid}:${seq}`. Live beats + // carry it on `orderSeq` (app.jsx composed it from the /events response sid). A recentEvents history + // row carries `seq` (the absolute session-log line index) + `sid` separately; compose the SAME key + // here so the two bands de-dup against ONE id space. A bare line index is NOT unique across a + // session ROTATION (the new session restarts at 0,1,2), which is BUG2 — the composite fixes it. + // Tolerates a legacy live beat that already carries a numeric orderSeq (older client) by coercing + // it to the same ":N" shape an empty-sid server produces. + const seqKeyOf = (e) => { + if (e && typeof e.orderSeq === "string" && e.orderSeq) return e.orderSeq; + if (e && typeof e.orderSeq === "number") return `:${e.orderSeq}`; + if (e && typeof e.seq === "number") return `${(typeof e.sid === "string" && e.sid) ? e.sid : ""}:${e.seq}`; + if (e && typeof e.seq === "string" && e.seq) return e.seq; return null; }; + // Parse a composite key into { sid, num } for the ORDER tiebreak. The seq tiebreak only needs + // WITHIN-session monotonicity (eventAt is the primary cross-session sort), so we compare the + // numeric tail ONLY when two rows share a session id; across sessions (or an unparseable key) we + // defer to the monotonic creation-order `.at`. Splitting on the LAST ':' keeps a sid that itself + // contains ':' intact. + const orderOf = (e) => { + const key = seqKeyOf(e); + if (key === null) return null; + const idx = key.lastIndexOf(":"); + const sidPart = idx >= 0 ? key.slice(0, idx) : ""; + const num = Number(idx >= 0 ? key.slice(idx + 1) : key); + return Number.isFinite(num) ? { sid: sidPart, num } : null; + }; const timeOf = (e) => (e && typeof e.eventAt === "number") ? e.eventAt : null; const compareChronicle = (a, b) => { const ta = timeOf(a), tb = timeOf(b); if (ta !== null && tb !== null && ta !== tb) return ta - tb; const sa = orderOf(a), sb = orderOf(b); - if (sa !== null && sb !== null && sa !== sb) return sa - sb; + // Within-session numeric tiebreak (monotonic by construction); cross-session falls to `.at`. + if (sa !== null && sb !== null && sa.sid === sb.sid && sa.num !== sb.num) return sa.num - sb.num; return (a?.at || 0) - (b?.at || 0); }; // The live tail can carry the player's move twice: once as the optimistic local echo @@ -350,8 +373,12 @@ function buildChronicleLog(recentEvents, chatBeats, log) { const mergedTail = [...dedupedBeats.map(projectPlayerReplay), ...echoes].sort((a, b) => { return compareChronicle(a, b); }); + // #405/BUG2: the set of live narration beats keyed by the SESSION-SCOPED composite `${sid}:${seq}` + // (via seqKeyOf). A recentEvents row that shares a live beat's composite is the same session-log + // line and must be dropped — prose-independent. (Previously a bare int, which collided across a + // session rotation; the composite is globally unique.) const liveSeqs = new Set( - mergedTail.filter((b) => b && b.kind === "narration" && typeof b.orderSeq === "number").map((b) => b.orderSeq), + mergedTail.filter((b) => b && b.kind === "narration").map((b) => seqKeyOf(b)).filter((k) => k !== null), ); const liveNarrationKeys = new Set( mergedTail.filter((b) => b && b.kind === "narration").map((b) => narrationKey(b.text)).filter(Boolean), @@ -360,8 +387,8 @@ function buildChronicleLog(recentEvents, chatBeats, log) { const kind = (row && (row.kind || row.type)) || "narration"; if (kind === "system") return false; // engine bookkeeping; never player-facing if (kind !== "narration" && kind !== "dialogue") return true; // gameplay mechanic rows kept - const seq = row && row.seq; - if (typeof seq === "number") return !liveSeqs.has(seq); // stable-id match (prose-independent) + const seqKey = seqKeyOf(row); // composes `${sid}:${seq}` for the history-band row (BUG2) + if (seqKey !== null) return !liveSeqs.has(seqKey); // stable-id match (prose-independent) const key = narrationKey(row && (row.text || row.detail)); return !key || !liveNarrationKeys.has(key); }); diff --git a/viewer/server.py b/viewer/server.py index e4bfe848..d93bfe25 100644 --- a/viewer/server.py +++ b/viewer/server.py @@ -1514,9 +1514,17 @@ def _session_recent_events(raw_events: list[dict] | None) -> list[dict]: # viewer's leading history band (recentEvents) de-dups against the live /events tail by ID, # not by prose. _session_event_tail_from_dir stamps it; an older snapshot/path without it # simply omits the key and the viewer falls back to its text-key dedup for that row. + # BUG2: a bare line index is NOT unique across a session ROTATION (cold-open start_session + + # the DM-turn-retry re-mint, 5e71f77) — the new session's log restarts at 0,1,2, the same + # indices the prior session already claimed. So carry the resolved session id (`sid`) too; the + # viewer composes `${sid}:${seq}` as the globally-unique dedup/order key, so a fresh session's + # narration is no longer suppressed by collision with a prior session's seq 0,1,2. seq = row.get("seq") if isinstance(seq, int) and not isinstance(seq, bool): item["seq"] = seq + sid = row.get("sid") + if isinstance(sid, str) and sid: + item["sid"] = sid event_at = row.get("t") if isinstance(event_at, (int, float)) and not isinstance(event_at, bool): item["eventAt"] = event_at @@ -1596,6 +1604,9 @@ def _session_event_tail_from_dir(campaign_dir: Path, snapshot: dict, limit: int continue if isinstance(row, dict): row.setdefault("seq", base + offset) + # BUG2: stamp the resolved session id so recentEvents composes the SAME `${sid}:${seq}` + # key the live /events tail does — a bare line index collides across a session rotation. + row.setdefault("sid", sid) out.append(row) return out @@ -5623,6 +5634,16 @@ def _app_status_payload(*, port: int, attached_campaign_id: str, viewed_campaign } +def _active_session_id(campaign_id: str) -> str: + """Resolve the campaign's current session id (the session-log basename the /events feed tails). + BUG2: this is the namespace the viewer composes onto each `seq` (`${sid}:${seq}`) so the dedup/ + order key is globally unique across a session ROTATION — without it a fresh session's narration + (which restarts at line 0,1,2) collides with the prior session's seq 0,1,2 and is suppressed.""" + snap = _read_snapshot(campaign_id) + sid = snap.get("active_session_id") or (snap.get("session_ids") or [None])[-1] + return sid if isinstance(sid, str) else "" + + def _read_events(campaign_id: str, since: int) -> tuple[list[dict], int]: """Return (new story entries after line `since`, new line count). Drops a trailing partial line defensively (append-only writes can exceed PIPE_BUF).""" @@ -6576,8 +6597,13 @@ def do_GET(self) -> None: # noqa: N802 elif route == "/events": qs = parse_qs(parsed.query) since = int((qs.get("since") or ["0"])[0]) - entries, nxt = _read_events(self._view_campaign(qs), since) - self._json({"entries": entries, "next": nxt}) + view_cid = self._view_campaign(qs) + entries, nxt = _read_events(view_cid, since) + # BUG2: include the resolved session id so the client composes a globally-unique + # `${sid}:${seq}` dedup/order key — a bare per-session line index collides across a + # session rotation (cold-open + DM-turn-retry re-mint), suppressing the new session's + # post-move narration (seq 0,1,2 already claimed by the prior session's cold-open). + self._json({"entries": entries, "next": nxt, "sid": _active_session_id(view_cid)}) elif route == "/activity": qs = parse_qs(parsed.query) since = int((qs.get("since") or ["0"])[0]) diff --git a/viewer/tests/test_live_narration_stream.py b/viewer/tests/test_live_narration_stream.py index 64a3202d..f809b80e 100644 --- a/viewer/tests/test_live_narration_stream.py +++ b/viewer/tests/test_live_narration_stream.py @@ -609,6 +609,62 @@ def test_same_seq_reingested_is_shown_once(self): self.assertEqual(out["second"], ["Lightning splits the sky."], "a re-ingested session-log line (same seq) must collapse to one row, immune to its prose (#405)") + # --- BUG2 (critical): a NEW session's narration after a session ROTATION must RENDER, not be + # suppressed by a `seq` COLLISION with a prior session's seq 0,1,2. ----------------------------- + # The engine rotates the session log on a cold-open start_session AND on a DM-turn-retry re-mint + # (5e71f77): the new session's log restarts at line 0,1,2 — the SAME bare indices the cold-open + # already claimed. The bare-seq dedup (server + client) had NO session scope, so post-move + # narration from the new session was BOTH suppressed by claimNarrationSeq AND dropped by + # buildChronicleLog's seq match — the player saw "Composing…" then nothing. The server now stamps + # the resolved session id on the /events response, and the client composes a SESSION-SCOPED key + # `${sid}:${seq}`; "session_a:0" and "session_b:0" are distinct, so the rotated session renders. + # This is the cross-session twin of test_same_seq_reingested_is_shown_once (same sid:seq STILL + # collapses; a DIFFERENT sid with the same seq does NOT). + def test_new_session_seq_collision_after_rotation_still_renders(self): + out = self._run( + # Session A (the cold-open) streams its opening beats at seq 0,1,2 — claimed under sid A. + "h.enqueue('/events', { sid: 'session_a', entries: [" + " { kind: 'narration', text: 'You wake in a cold cell.', seq: 0 }," + " { kind: 'narration', text: 'A torch gutters in the corridor.', seq: 1 }," + " { kind: 'narration', text: 'Footsteps approach.', seq: 2 }" + "], next: 3 });" + "await h.tick();" + "var coldOpen = h.narrationTexts();" + # The engine ROTATES the session (DM-turn-retry re-mint). The viewer's cursor is re-read + # from the new file's top, so the post-move reply arrives as a FRESH session's seq 0,1,2 — + # the SAME bare indices session A already claimed. Under the bug these were suppressed. + "h.enqueue('/events', { sid: 'session_b', entries: [" + " { kind: 'narration', text: 'The guard hauls you to your feet.', seq: 0 }," + " { kind: 'narration', text: 'The cell door clangs open.', seq: 1 }" + "], next: 2 });" + "await h.tick();" + "return ({ coldOpen: coldOpen, afterRotation: h.narrationTexts(), chronicle: h.chronicleNarration() });" + ) + self.assertEqual( + out["coldOpen"], + ["You wake in a cold cell.", "A torch gutters in the corridor.", "Footsteps approach."], + "session A's cold-open beats stream live", + ) + self.assertEqual( + out["afterRotation"], + [ + "You wake in a cold cell.", "A torch gutters in the corridor.", "Footsteps approach.", + "The guard hauls you to your feet.", "The cell door clangs open.", + ], + "post-rotation narration (a NEW session's seq 0,1) must NOT be suppressed by collision with " + "session A's seq 0,1 — the session-scoped `${sid}:${seq}` key keeps them distinct (BUG2)", + ) + # And the ASSEMBLED chronicle (the other half of BUG2 — buildChronicleLog dropped it too) must + # render all five beats in order, the two sessions' identical seq numbers no longer colliding. + self.assertEqual( + out["chronicle"], + [ + "You wake in a cold cell.", "A torch gutters in the corridor.", "Footsteps approach.", + "The guard hauls you to your feet.", "The cell door clangs open.", + ], + "buildChronicleLog must render the rotated session's beats too (no cross-session seq drop) (BUG2)", + ) + # --- #405: CHRONOLOGICAL ORDER — beats that stream out of arrival order still render in session-log # (seq) order in the assembled chronicle. ------------------------------------------------------- def test_chronicle_orders_by_session_log_seq(self):