diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index ab3b54b4..6e6dc94a 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -122,15 +122,63 @@ const _STAGE_DIRECTION = new RegExp( "\\bof the\\b[^.]{0,30}\\b(?:cold open|beat|act|scene)\\b[^.]{0,30}\\bcomplete\\b" + ")", "i", ); +// (4) #752: INTER-BEAT TRANSITION meta-text — the engine/DM "seam" stage-directions that leaked +// into the chronicle BETWEEN player beats (adversarial confirm sweep: "Engine META-TEXT transition +// phrases leak into the chronicle between player beats"). These announce the move from one beat/ +// scene to the next ("Moving on to the next beat", "Transitioning to the next scene", "Scene +// transition", "End of beat", "Beginning the next beat", "Time passes between the beats", "We now +// move to the next part of the story"). The player must see story, never the seams. +// +// HIGH-CONFIDENCE ONLY — never bare "beat"/"scene"/"transition". Legitimate fiction MUST survive: +// • "your heart skips a beat" — "beat" with no transition frame +// • "the tavern scene is loud" — "scene" with no transition frame +// • "a smooth transition from the parapet to the rope" — a PHYSICAL transition (from X to a +// non-structural noun), NOT "…to the next beat/scene". So `transition` only triggers when it +// is bound to "the next beat/scene/part" or is a bare "(scene|beat) transition" note — never +// "transition from ". +// `_STRUCT` is the structural-unit noun ("beat"/"scene"/"part of the story"/"act"/"chapter") these +// stage-directions move between; the patterns require it next to a transition verb/marker. +const _STRUCT = "(?:beat|scene|act|chapter|part of the story|part)"; +// The "end of " / "beginning the " arms are the over-eager ones: `act`/ +// `chapter`/`part` are common in REAL fiction ("the end of the act left them breathless", +// "the close of the scene", "the act of contrition"), so those arms use ONLY the engine's own +// structural terms AND require a forward-transition qualifier (next/this/current/following) — +// the META form is always a forward move ("beginning the next scene"), never a descriptive +// "the close of the scene". This spares real prose (story quality is the north star). +const _STRUCT_FWD = "(?:beat|scene)"; +const _BEAT_TRANSITION = new RegExp( + "(?:" + + // "moving on/move on/we (now) move … to the next " (the verbatim leak form) + "\\b(?:moving on|move on|we (?:now )?move|now (?:we )?move|let'?s move)\\b[^.]{0,24}" + + "\\bto the (?:next |following )?" + _STRUCT + "\\b|" + + // "transition(ing) to the (next) " — bound to a STRUCTURAL target, not a physical one + "\\btransition(?:ing|s|ed)?\\b[^.]{0,18}\\bto the (?:next |following )?" + _STRUCT + "\\b|" + + // a bare " transition" note ("Scene transition.", "Beat transition") + "\\b" + _STRUCT + "\\s+transition\\b|" + + // "end of (the/this/next) " — TERMINAL only: the meta note IS the (short) sentence and + // ends on the engine's own struct ("End of beat.", "End of the scene."). Descriptive fiction + // embeds the phrase mid-sentence ("by the close of the scene, three lay dead") so the struct is + // NOT at the end → not matched. Uses beat|scene only (act/chapter/part are real-fiction words). + "\\b(?:end|close|beginning|start) of (?:the |this )?(?:next |current |following )?" + _STRUCT_FWD + "\\s*[.!?]?\\s*$|" + + // "beginning/starting the (next) " — terminal only, same rationale. + "\\b(?:begin(?:ning)?|start(?:ing)?) the (?:next |following )?" + _STRUCT_FWD + "\\s*[.!?]?\\s*$|" + + // "(time passes) between the beats/scenes" — the interstitial seam phrasing. NOT "between the + // beats OF the drum" (real fiction): the meta form is terminal, so forbid a following " of ". + "\\bbetween (?:the |these )?(?:beats|scenes)\\b(?!\\s+of\\b)|" + + // "on to the next " tail (covers "…and we move on to the next scene") + "\\bon to the next " + _STRUCT + "\\b" + + ")", "i", +); // True when a WHOLE sentence is plot-craft jargon or a stage-direction (drop the sentence). // The tally is handled separately (in-place excision), so it's NOT a whole-sentence-drop trigger. function _isScaffoldingSentence(sentence) { const s = (sentence || "").trim(); if (!s) return false; - return _CRAFT_JARGON.test(s) || _STAGE_DIRECTION.test(s); + return _CRAFT_JARGON.test(s) || _STAGE_DIRECTION.test(s) || _BEAT_TRANSITION.test(s); } function _hasScaffolding(text) { - return _TALLY_TEST.test(text) || _CRAFT_JARGON.test(text) || _STAGE_DIRECTION.test(text); + return _TALLY_TEST.test(text) || _CRAFT_JARGON.test(text) + || _STAGE_DIRECTION.test(text) || _BEAT_TRANSITION.test(text); } // Clean scaffolding from one line, preserving the real prose. Two grains: // 1. excise dice/check TALLY phrases in place (keeps the rest of their sentence); @@ -288,6 +336,45 @@ const COMPOSER_MODE_BY_UI = { // Older beats are still available in full in the Quest Journal. const CHRONICLE_RENDER_CAP = 50; +// #752: BOUND THE CHRONICLE'S ACCESSIBILITY FOOTPRINT (the headline confirm-sweep fix — flagged +// MAJOR by 3 of 5 personas: "later beats invisible in a11y tree", "Actions section pushed out of +// the a11y tree entirely", "player can't tell if the DM is done", "all action controls hidden from +// the screen reader"). #402 anchored the action bar VISUALLY (a sticky DOM sibling below the log), +// but the accessibility snapshot a screen reader / the QA blind-player reads is LINEAR and rendered +// in DOM ORDER, and the reader caps it (qa/playwright/palette_server.js reads +// `ariaSnapshot().slice(0, 9000)`). The Chronicle (`role="log"`) renders BEFORE the action palette +// + the move composer, so a long run of multi-paragraph DM beats fills the whole snapshot budget and +// the action controls get sliced off ENTIRELY — the felt "I can't find the buttons / can't tell the +// DM finished." A bigger render cap or a sticky bar can't fix a linear, length-capped snapshot. +// +// So we cap the chronicle's A11Y exposure independently of its VISUAL row count: only the most-recent +// CHRONICLE_A11Y_TAIL rendered rows stay in the accessibility tree; older rendered rows (kept fully +// VISIBLE for sighted scroll-back, and preserved IN FULL in the Quest Journal — the #402 "earlier +// beats are in your Quest Journal" summary already names that) are `aria-hidden`. So the chronicle's +// a11y subtree is bounded to a small, predictable size at ANY session length: a screen reader +// announces the latest DM beat, then immediately reaches the Actions/composer. The latest beat is +// ALWAYS exposed (it's the player's most recent reply); the bound only engages once the rendered list +// exceeds the tail (a short early-session chronicle exposes every row). Standard long-log a11y +// pattern (virtualized chat transcripts do the same). Viewer stays READ-ONLY — this is purely how the +// already-rendered, engine-authored prose is exposed to assistive tech. +const CHRONICLE_A11Y_TAIL = 8; + +// True when the chronicle row at index `i` of `total` rendered rows must be `aria-hidden` — i.e. it +// is OLDER than the most-recent CHRONICLE_A11Y_TAIL rows. Pure + exported so the bound is +// unit-testable without mounting the component (mirrors buildChronicleLog / computePlayGate). When +// the rendered list is at/under the tail, NOTHING is hidden (every beat announced). +function chronicleRowAriaHidden(i, total) { + const n = Number(total) || 0; + const idx = Number(i); + if (!Number.isFinite(idx) || n <= CHRONICLE_A11Y_TAIL) return false; + return idx < n - CHRONICLE_A11Y_TAIL; +} +if (typeof window !== "undefined") { + window.CHRONICLE_RENDER_CAP = CHRONICLE_RENDER_CAP; + window.CHRONICLE_A11Y_TAIL = CHRONICLE_A11Y_TAIL; + window.chronicleRowAriaHidden = chronicleRowAriaHidden; +} + // #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: @@ -1139,6 +1226,12 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { key={entry.id || `${entry.kind || "n"}-${i}`} ref={i === lastVisibleLogIndex ? latestBeatRef : null} data-worldos-testid={i === lastVisibleLogIndex ? "chronicle-latest-beat" : undefined} + // #752: older rendered rows are aria-hidden so the chronicle's accessibility subtree + // stays bounded (the most-recent CHRONICLE_A11Y_TAIL rows only) and the action + // controls below are never sliced off the (length-capped) a11y snapshot. They remain + // fully VISIBLE for sighted scroll-back, and the full history is in the Quest Journal. + // The latest beat is never hidden (chronicleRowAriaHidden keeps the tail exposed). + aria-hidden={chronicleRowAriaHidden(i, renderedLog.length) ? "true" : undefined} style={{ scrollMarginBlock: 12 }} > @@ -1178,7 +1271,10 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { combat verbs (Attack/Bonus/Reaction) in an "In Combat" group when in combat. Reuses the EncounterButton component + invokeAction + ACTION_HINTS — the click path is unchanged. flex 0 0 auto so it stays anchored/visible no matter how long the chronicle grows. */} -
+ {/* #752: a NAMED a11y region so a screen reader / the QA blind-player can target the action + controls directly ("Actions"), even after a long chronicle — paired with the chronicle's + bounded a11y footprint above, the buttons are always reachable in the snapshot. */} +
Actions {!actions.length && (
diff --git a/viewer/tests/test_chronicle_a11y_bound.py b/viewer/tests/test_chronicle_a11y_bound.py new file mode 100644 index 00000000..255ff84b --- /dev/null +++ b/viewer/tests/test_chronicle_a11y_bound.py @@ -0,0 +1,139 @@ +"""#752 — the chronicle must not flood the accessibility tree and bury the action controls. + +The 2026-06-15 confirm sweep flagged this MAJOR by 3 of 5 personas (newbie, adversarial, +narrative). Verbatim symptoms: + • "Chronicle log grows into one massive block — later beats invisible in a11y tree" + • "Oversized chronicle log pushes Actions section out of a11y tree entirely" + • "Chronicle log a11y overflow buries action buttons — player can't tell if DM is done" + • "Chronicle log overflows a11y tree, hiding all action controls from screen reader" + +FELT MECHANISM (the why, not a rubric): the QA blind-player reads the screen via +`qa/playwright/palette_server.js::ariaText`, which is `ariaSnapshot().slice(0, 9000)` — a +HARD CHAR CAP on the body's accessibility YAML, rendered in DOM order. The Chronicle +(`role="log"`) renders BEFORE the Actions palette + the move composer in the DOM, so once +the chronicle's rendered rows carry many multi-paragraph DM beats their YAML alone exceeds +9000 chars and the Actions / composer are sliced off the snapshot ENTIRELY — exactly "the +player can't tell if the DM is done / can't find the action buttons." #402 anchored the +action bar VISUALLY (a sticky DOM sibling) but the a11y snapshot is LINEAR, so the visual +anchor doesn't help the screen-reader / snapshot path. + +THE FIX (viewer-only, READ-ONLY): bound the chronicle's accessibility FOOTPRINT. Only the +most-recent `CHRONICLE_A11Y_TAIL` rendered rows stay in the accessibility tree; older +rendered rows (kept visible for sighted scroll-back, and preserved IN FULL in the Quest +Journal) are marked `aria-hidden` with an accessible "earlier beats are in your Quest +Journal" summary already present (#402). So the chronicle's a11y subtree is bounded to a +small, predictable size REGARDLESS of session length — the latest DM beat is announced, then +the Actions/composer are immediately reachable in the snapshot. + +These tests exercise the REAL shipped render (ScreenTable's chronicle map + the action +palette + composer), transpiled from the actual `.jsx` under Node — mirroring the sibling +JS-behavior harnesses (test_chronicle_hygiene.py, test_nav_chronicle_resilience.py). Skipped +where Node is not on PATH. +""" +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +OPENWORLDS = HERE.parent / "openworlds" +SCREEN_TABLE = OPENWORLDS / "screen-table.jsx" +BABEL = OPENWORLDS / "vendor" / "babel-standalone-7.29.0.min.js" + + +def _node() -> str: + node = shutil.which("node") + if not node: + pytest.skip("node not on PATH; skipping JS-behavior test") + return node + + +# --------------------------------------------------------------------------------------------- # +# A small a11y-aware renderer for ScreenTable's chronicle map. We reproduce ONLY the exact JSX +# the component emits for the rendered-row list (the `renderedLog.map(...)` block) by calling the +# shipped helpers the component uses — CHRONICLE_RENDER_CAP, CHRONICLE_A11Y_TAIL, and +# chronicleRowAriaHidden — so the test tracks the real exposed constants/predicate, not a copy. +# The component wraps each row in a
with `aria-hidden` set by chronicleRowAriaHidden(i, n); +# we assert the bound directly on that pure predicate + the constants it reads. +# --------------------------------------------------------------------------------------------- # +def _eval_screen_table(expr: str): + """Transpile screen-table.jsx under Node and evaluate `expr` against its window scope.""" + program = ( + "const fs = require('fs'); const vm = require('vm');\n" + + "const Babel = require(%s);\n" % json.dumps(str(BABEL)) + + "const src = fs.readFileSync(%s, 'utf8');\n" % json.dumps(str(SCREEN_TABLE)) + + "const code = Babel.transform(src, { presets: ['react'], filename: 'screen-table.jsx' }).code;\n" + + "function h(type, props, ...children){ return { type: (typeof type==='function'?(type.name||'C'):type)," + + " props: props||{}, children: children.flat(Infinity).filter(c=>c!=null) }; }\n" + + "const React = { useState:()=>[null,()=>{}], useRef:()=>({}), useCallback:f=>f, useEffect:()=>{}," + + " createElement:h, Fragment:'F' };\n" + + "const sb = { React, console }; sb.window = sb; vm.createContext(sb); vm.runInContext(code, sb);\n" + + "const __res = (function(){ return (" + expr + "); })();\n" + + "process.stdout.write(JSON.stringify(__res));\n" + ) + proc = subprocess.run( + [_node(), "--input-type=commonjs"], input=program, text=True, capture_output=True, timeout=60 + ) + if proc.returncode != 0: + raise AssertionError(f"node failed: {proc.stderr}") + return json.loads(proc.stdout) + + +def test_chronicle_a11y_tail_constant_is_present_and_tight(): + # The a11y tail must EXIST and be tight enough that even multi-paragraph beats can't blow the + # ~9000-char snapshot budget before the action controls. (A handful of recent beats, not 50.) + vals = _eval_screen_table( + "({ tail: sb.window.CHRONICLE_A11Y_TAIL, renderCap: sb.window.CHRONICLE_RENDER_CAP })" + ) + assert isinstance(vals["tail"], int), "CHRONICLE_A11Y_TAIL must be exported on window" + assert 1 <= vals["tail"] <= 15, ( + f"a11y tail {vals['tail']} must be a tight handful of beats so the chronicle can't bury " + "the action controls in the a11y snapshot" + ) + assert vals["tail"] <= vals["renderCap"], "the a11y tail can't exceed what is rendered" + + +def test_action_controls_stay_in_a11y_tree_after_30_chronicle_rows(): + # The headline assertion: with 30 rendered chronicle rows, the NEWEST rows stay in the a11y + # tree and the OLDER rows are aria-hidden — so the chronicle's exposed a11y footprint is the + # tight tail, NOT all 30 rows. The action palette + composer (DOM siblings AFTER the log) then + # land within the snapshot budget instead of being sliced off. + out = _eval_screen_table( + "(function(){" + " var n = 30;" + " var hidden = [];" + " for (var i = 0; i < n; i++) hidden.push(sb.window.chronicleRowAriaHidden(i, n));" + " var exposed = hidden.filter(function(x){ return !x; }).length;" + " return { n: n, exposed: exposed, lastHidden: hidden[n-1], firstHidden: hidden[0]," + " tail: sb.window.CHRONICLE_A11Y_TAIL };" + "})()" + ) + # Only the tail is exposed to AT; the rest are aria-hidden (still visible for scroll-back). + assert out["exposed"] == out["tail"], ( + f"with {out['n']} rows, exactly the {out['tail']}-row a11y tail must stay in the tree " + f"(got {out['exposed']} exposed) so the action controls are not sliced off the snapshot" + ) + assert out["lastHidden"] is False, "the NEWEST beat must always stay in the a11y tree" + assert out["firstHidden"] is True, "the OLDEST rendered beat must be aria-hidden once past the tail" + + +def test_short_chronicle_exposes_every_row_to_a11y_tree(): + # No regression for a short session: when the rendered list is at/under the tail, NOTHING is + # aria-hidden — every beat is announced (the bound only engages once the log is long). + out = _eval_screen_table( + "(function(){" + " var tail = sb.window.CHRONICLE_A11Y_TAIL;" + " var n = tail;" # exactly the tail length + " var anyHidden = false;" + " for (var i = 0; i < n; i++) if (sb.window.chronicleRowAriaHidden(i, n)) anyHidden = true;" + " return { n: n, anyHidden: anyHidden };" + "})()" + ) + assert out["anyHidden"] is False, ( + "a short chronicle (≤ the a11y tail) must expose every row — the a11y bound only engages " + "for a long log, never for an early-session player" + ) diff --git a/viewer/tests/test_chronicle_dedup_opening.py b/viewer/tests/test_chronicle_dedup_opening.py new file mode 100644 index 00000000..9de6b6b1 --- /dev/null +++ b/viewer/tests/test_chronicle_dedup_opening.py @@ -0,0 +1,192 @@ +"""#752 (dedup + meta-text legs) — the chronicle must show each beat ONCE and must not leak the +engine's meta-text transition phrases between player beats. + +From the 2026-06-15 confirm sweep (adversarial persona): + • "Chronicle shows DUPLICATE DM narration for the opening beat (same scene narration)" + • "Engine META-TEXT transition phrases LEAK into the chronicle between player beats" + • (confirmed bug ndjson) "All Enter-submitted player actions echoed twice in chronicle — second + entry is raw You\"…\" template text" / "Continue button leaks internal template text: + Rolan—Continue You\"continue\"" + +These exercise the REAL shipped pure functions transpiled from the actual `.jsx` under Node: + • buildChronicleLog (screen-table.jsx) — the merge/dedup/order of the chronicle's three sources. + A player move's OPTIMISTIC echo (the `log` band) and its `/chat` REPLAY (the `chatBeats` band) + are the SAME turn; they must collapse to ONE row even when the replay returns as a `dialog` + "You" row (engine logged the line with no routing tag) rather than an `action` row. + • sanitizeNarration (screen-table.jsx) — the player-facing prose filter. The engine/wrapper + meta-text "transition" phrases (the wrapper progress heartbeats #749 + the inter-beat + scene-transition scaffolding) must NEVER render as story prose. + +Skipped where Node is not on PATH. +""" +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +OPENWORLDS = HERE.parent / "openworlds" +SCREEN_TABLE = OPENWORLDS / "screen-table.jsx" +BABEL = OPENWORLDS / "vendor" / "babel-standalone-7.29.0.min.js" + + +def _node() -> str: + node = shutil.which("node") + if not node: + pytest.skip("node not on PATH; skipping JS-behavior test") + return node + + +def _run_screen_table(expr_body: str): + """Transpile screen-table.jsx under Node (window scope set up exactly as the browser loads it) + and return whatever `expr_body` writes to stdout as JSON. The window-guarded helpers + (buildChronicleLog, sanitizeNarration, isWrapperProgressLine, WRAPPER_PROGRESS_LINES) are all + reachable off `sb.window` because the module installs them on `window` at load.""" + program = ( + "const fs = require('fs'); const vm = require('vm');\n" + + "const Babel = require(%s);\n" % json.dumps(str(BABEL)) + + "const src = fs.readFileSync(%s, 'utf8');\n" % json.dumps(str(SCREEN_TABLE)) + + "const code = Babel.transform(src, { presets: ['react'], filename: 'screen-table.jsx' }).code;\n" + + "function h(type, props, ...children){ return { type:(typeof type==='function'?(type.name||'C'):type)," + + " props: props||{}, children: children.flat(Infinity).filter(c=>c!=null) }; }\n" + + "const React = { useState:()=>[null,()=>{}], useRef:()=>({}), useCallback:f=>f, useEffect:()=>{}," + + " createElement:h, Fragment:'F' };\n" + + "const sb = { React, console }; sb.window = sb; vm.createContext(sb); vm.runInContext(code, sb);\n" + + expr_body + ) + proc = subprocess.run( + [_node(), "--input-type=commonjs"], input=program, text=True, capture_output=True, timeout=60 + ) + if proc.returncode != 0: + raise AssertionError(f"node failed: {proc.stderr}") + return json.loads(proc.stdout) + + +def _chronicle_texts(recent_events, chat_beats, log): + """Run the REAL buildChronicleLog and return the flattened visible text of each merged row, + tagged with its kind, so a test can assert a beat appears exactly once.""" + return _run_screen_table( + "const buildChronicleLog = sb.window.buildChronicleLog;\n" + "if (typeof buildChronicleLog !== 'function') throw new Error('buildChronicleLog not exported');\n" + "const rows = buildChronicleLog(" + json.dumps(recent_events) + ", " + + json.dumps(chat_beats) + ", " + json.dumps(log) + ");\n" + "const out = rows.map((r) => ({ kind: r.kind || 'narration', who: (r.who||''), text: (r.text||'') }));\n" + "process.stdout.write(JSON.stringify(out));\n" + ) + + +# --------------------------------------------------------------------------------------------- # +# DEDUP — the opening beat / player echo appears exactly once. +# --------------------------------------------------------------------------------------------- # +def test_opening_dm_narration_appears_once_across_recent_and_live_bands(): + # The opening DM beat lands in BOTH the leading history band (recentEvents, seq=0) AND the live + # /events tail (orderSeq "sid:0") — the same session-log line. It must render ONCE. + opening = "The lantern gutters as you step into the Lower City." + rows = _chronicle_texts( + recent_events=[{"kind": "narration", "text": opening, "seq": 0, "sid": "s1", "eventAt": 1.0}], + chat_beats=[{"kind": "narration", "text": opening, "orderSeq": "s1:0", "at": 1, "eventAt": 1.0}], + log=[], + ) + narration_rows = [r for r in rows if opening in r["text"]] + assert len(narration_rows) == 1, f"opening DM narration must render ONCE, got {len(narration_rows)}: {rows}" + + +def test_continue_action_does_not_double_echo_as_dialog_you_row(): + # The confirmed bug: a Continue move shows as BOTH the optimistic action echo ("Continue") AND a + # raw `dialog` "You" replay (rendered You"continue"). The /chat replay comes back as a dialog row + # (engine logged the line with no routing tag). It must dedup against the optimistic echo → ONE row. + optimistic = {"kind": "action", "who": "You", "text": "Continue", "route": "continue", "at": 1, "eventAt": 1.0} + chat_replay_dialog = {"kind": "dialog", "who": "You", "text": "continue", "route": "", "at": 2, "eventAt": 1.1} + rows = _chronicle_texts(recent_events=[], chat_beats=[chat_replay_dialog], log=[optimistic]) + you_rows = [r for r in rows if r["text"].strip().lower() in ("continue",)] + assert len(you_rows) == 1, f"the Continue move must render ONCE, got {len(you_rows)}: {rows}" + # and it must NOT be the raw dialog "You\"continue\"" template — it should be the clean action. + dialog_you = [r for r in rows if r["kind"] == "dialog" and r["who"].lower() == "you"] + assert not dialog_you, f"the raw You\"continue\" dialog template must not survive: {dialog_you}" + + +def test_freetext_action_does_not_double_echo_across_optimistic_and_chat_replay(): + # A free-text "do" action: optimistic echo (action) + /chat replay (the engine logged "[do] …"). + # The replay parses the tag back to an action; it must dedup against the optimistic echo. + text = "I draw my staff and step toward the alley mouth" + optimistic = {"kind": "action", "who": "You", "text": text, "route": "do", "at": 1, "eventAt": 1.0} + chat_replay = {"kind": "action", "who": "You", "text": text, "route": "do", "at": 2, "eventAt": 1.1} + rows = _chronicle_texts(recent_events=[], chat_beats=[chat_replay], log=[optimistic]) + matches = [r for r in rows if text in r["text"]] + assert len(matches) == 1, f"a free-text action must render ONCE, got {len(matches)}: {rows}" + + +# --------------------------------------------------------------------------------------------- # +# META-TEXT LEAK — the engine/wrapper transition phrases never render as story prose. +# --------------------------------------------------------------------------------------------- # +def _sanitize(text: str) -> str: + return _run_screen_table( + "const s = sb.window.sanitizeNarration; if (typeof s !== 'function') throw new Error('sanitizeNarration not exported');\n" + "process.stdout.write(JSON.stringify(s(" + json.dumps(text) + ")));\n" + ) + + +def test_wrapper_progress_transition_phrases_are_suppressed(): + # The wrapper progress heartbeats (#749) are LIVENESS meta-text, never story. Each must be + # stripped from the player-facing chronicle entirely. + lines = _run_screen_table( + "process.stdout.write(JSON.stringify(sb.window.WRAPPER_PROGRESS_LINES || []));\n" + ) + assert lines, "WRAPPER_PROGRESS_LINES must be exported (the meta-text transition phrases)" + for phrase in lines: + assert _sanitize(phrase) == "", f"wrapper transition phrase leaked into the chronicle: {phrase!r}" + + +@pytest.mark.parametrize("phrase", [ + "Time passes between the beats.", + "Transitioning to the next scene.", + "Moving on to the next beat.", + "We now move to the next part of the story.", + "Scene transition.", + "End of beat.", + "Beginning the next beat.", +]) +def test_inter_beat_transition_scaffolding_is_suppressed(phrase): + # The adversarial leak: engine/DM meta-text "transition" stage-directions between player beats + # ("Transitioning to the next scene", "Moving on to the next beat", …). These are scaffolding — + # the player must see story prose, never the seams between beats. A WHOLE-LINE transition note + # is dropped; real prose around it survives (asserted below). + assert _sanitize(phrase).strip() == "", f"inter-beat transition meta-text leaked: {phrase!r}" + + +def test_transition_scaffolding_drop_preserves_surrounding_real_prose(): + # The transition strip must be surgical: a real narration line that happens to sit beside a + # transition note keeps its prose; only the meta-text seam is removed. + beat = ( + "Zevlor meets your eyes and gives a slow nod.\n" + "Moving on to the next beat.\n" + "The gate groans open onto the rain." + ) + out = _sanitize(beat) + assert "Zevlor meets your eyes" in out, f"real prose was over-stripped: {out!r}" + assert "The gate groans open" in out, f"real prose was over-stripped: {out!r}" + assert "next beat" not in out.lower(), f"transition meta-text survived: {out!r}" + + +def test_real_prose_mentioning_a_beat_or_transition_survives(): + # The guard must be HIGH-CONFIDENCE: legitimate fiction that merely uses the words "beat", + # "scene", or "transition" in-world must NOT be stripped (a war-drum's beat, a tavern scene). + for prose in ( + "Your heart skips a beat as the blade whistles past.", + "The tavern scene is loud with laughter and spilled ale.", + "She makes a smooth transition from the parapet to the rope.", + # The over-strip class the first cut missed (adversarial review): "act"/"chapter"/"part" + # are real-fiction words, and the "end/close of the " form is descriptive prose + # when the struct sits mid-sentence (trailing prose follows) — must NOT be stripped. + "The end of the act left the audience breathless.", + "By the close of the scene, three lay dead on the cathedral steps.", + "Start of the chapter that defined her, though she did not yet know it.", + "Beginning the act of contrition, the old priest knelt in the ash.", + "He took up his part of the story and carried it to the gate.", + ): + out = _sanitize(prose) + assert out.strip() == prose.strip(), f"real in-world prose was wrongly stripped: {prose!r} -> {out!r}"