From 8262e929130222614102537767c38f425cecd451 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 15 Jun 2026 05:30:26 +0700 Subject: [PATCH 1/2] fix(viewer): bound chronicle a11y footprint + suppress inter-beat transition meta-text (#752) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-06-15 confirm sweep flagged #752 MAJOR by 3 of 5 personas (newbie, adversarial, narrative). Verbatim: "Chronicle log grows into one massive block — later beats invisible in a11y tree" / "Oversized chronicle log pushes Actions section out of a11y tree entirely" / "buries action buttons — player can't tell if DM is done" / "hiding all action controls from screen reader". Plus adversarial: "DUPLICATE DM narration for the opening beat" and "Engine META-TEXT transition phrases LEAK into the chronicle between player beats". FELT MECHANISM: the screen-reader / QA blind-player reads the page via a LINEAR, length-capped accessibility snapshot (qa/playwright/palette_server.js does ariaSnapshot().slice(0,9000)) rendered in DOM order. The Chronicle (role="log") renders BEFORE the action palette + composer, so a long run of multi-paragraph DM beats fills the whole snapshot budget and the action controls are sliced off the snapshot ENTIRELY. #402 anchored the bar VISUALLY (a sticky DOM sibling) but a linear capped snapshot can't be fixed by visual positioning. FIX (viewer-only, READ-ONLY — engine stays sole writer): 1. a11y bound: cap the chronicle's ACCESSIBILITY footprint independently of its visual row count — only the most-recent CHRONICLE_A11Y_TAIL (8) rendered rows stay in the a11y tree; older rendered rows are aria-hidden (still fully VISIBLE for sighted scroll-back, full history in the Quest Journal). The latest beat is always exposed; the bound only engages once the rendered list exceeds the tail. Plus a named role="region" aria-label="Actions" so AT can target the controls. New pure exported chronicleRowAriaHidden(i,total) + CHRONICLE_A11Y_TAIL. 2. opening-beat / player-echo DEDUP: pinned the exactly-once contract (the existing #727/#740/#405 dedup already collapses the opening + the Continue/free-text echo; tests lock it so it can't regress). 3. META-TEXT leak: new high-confidence _BEAT_TRANSITION pattern in sanitizeNarration suppresses inter-beat transition stage-directions ("Moving on to the next beat", "Transitioning to the next scene", "Scene transition", "End of beat", "Beginning the next beat", "between the beats", "move to the next part of the story") while preserving real fiction ("heart skips a beat", "tavern scene", "smooth transition from the parapet to the rope", "between the beats of the drum"). Tests (JSX harness, real .jsx transpiled under Node): - viewer/tests/test_chronicle_a11y_bound.py (3): tail constant tight; action controls stay in the a11y tree after 30 rows (only the tail exposed); short chronicle exposes every row. - viewer/tests/test_chronicle_dedup_opening.py (13): opening once across bands; Continue/free-text no double-echo; wrapper + inter-beat transition meta-text suppressed; surrounding real prose preserved; in-world beat/scene/transition prose survives. fast_gate.sh PASS (215 engine). Full viewer suite 596 passed / 1 skipped. --- viewer/openworlds/screen-table.jsx | 92 +++++++++- viewer/tests/test_chronicle_a11y_bound.py | 139 ++++++++++++++ viewer/tests/test_chronicle_dedup_opening.py | 184 +++++++++++++++++++ 3 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 viewer/tests/test_chronicle_a11y_bound.py create mode 100644 viewer/tests/test_chronicle_dedup_opening.py diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index ab3b54b4..c998a624 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -122,15 +122,53 @@ 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)"; +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) " / "beginning/start of the next " + "\\b(?:end|close|beginning|start) of (?:the |this )?(?:next |current )?" + _STRUCT + "\\b|" + + // "beginning/starting the next " + "\\b(?:begin(?:ning)?|start(?:ing)?) the (?:next |following )?" + _STRUCT + "\\b|" + + // "(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 +326,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 +1216,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 +1261,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..1da01017 --- /dev/null +++ b/viewer/tests/test_chronicle_dedup_opening.py @@ -0,0 +1,184 @@ +"""#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.", + ): + out = _sanitize(prose) + assert out.strip() == prose.strip(), f"real in-world prose was wrongly stripped: {prose!r} -> {out!r}" From 261ca0c93fdcbcc9ca69888e89ce0e9aa6fb92c9 Mon Sep 17 00:00:00 2001 From: Eva Date: Mon, 15 Jun 2026 05:49:39 +0700 Subject: [PATCH 2/2] fix(viewer): tighten _BEAT_TRANSITION to stop over-stripping real fiction (review fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review found the meta-text regex silently deleted real fiction sentences ('the end of the act left them breathless', 'by the close of the scene, three lay dead', 'beginning the act of contrition'). Root: the end-of/beginning-the arms used act/chapter/part (real-fiction words) + an optional forward qualifier, so they matched descriptive prose. FIX: those two arms now use only the engine's own struct terms (beat|scene) AND require the phrase to be TERMINAL (the meta note IS the short sentence, ending on the struct) — descriptive fiction embeds the phrase mid-sentence with trailing prose, so it's spared. The leak cases ('End of beat.', 'Beginning the next beat.') still suppress. +5 survive-case tests (the exact over-strip examples). Story quality is the north star — no silent fiction deletion. --- viewer/openworlds/screen-table.jsx | 18 ++++++++++++++---- viewer/tests/test_chronicle_dedup_opening.py | 8 ++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index c998a624..6e6dc94a 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -139,6 +139,13 @@ const _STAGE_DIRECTION = new RegExp( // `_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) @@ -148,10 +155,13 @@ const _BEAT_TRANSITION = new RegExp( "\\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) " / "beginning/start of the next " - "\\b(?:end|close|beginning|start) of (?:the |this )?(?:next |current )?" + _STRUCT + "\\b|" + - // "beginning/starting the next " - "\\b(?:begin(?:ning)?|start(?:ing)?) the (?:next |following )?" + _STRUCT + "\\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)|" + diff --git a/viewer/tests/test_chronicle_dedup_opening.py b/viewer/tests/test_chronicle_dedup_opening.py index 1da01017..9de6b6b1 100644 --- a/viewer/tests/test_chronicle_dedup_opening.py +++ b/viewer/tests/test_chronicle_dedup_opening.py @@ -179,6 +179,14 @@ def test_real_prose_mentioning_a_beat_or_transition_survives(): "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}"