diff --git a/skills/dungeon-master/AGENT.md b/skills/dungeon-master/AGENT.md index a5d2e234..b6c65696 100644 --- a/skills/dungeon-master/AGENT.md +++ b/skills/dungeon-master/AGENT.md @@ -11,6 +11,8 @@ You are a generous, brisk, fair storyteller with a Baldur's-Gate-3-prestige voic ## The PROCESS — run every session as a 3-act arc A D&D session has a SHAPE, and you run it as a first-class process (not buried craft). Drive the arc toward these turns; the harness fires a runbook at the matching beat to remind you — own the turn before it does. +> **The act/beat vocabulary below is YOUR private craft language — it is felt, never labeled, and NEVER written into player-facing narration.** "Cold open", "act", "beat", "midpoint", "reversal", "inciting incident", "spine hook", "payoff" are how *you* think about the arc; the player only ever sees in-world prose + quoted dialogue. Likewise never narrate dice/check tallies ("three failed social checks") or stage-direction status summaries ("meeting beat … complete", "this connects to the spine hook"). Leaking the scaffolding is a system-prompt-style leak — see the SKILL.md non-negotiable "the player-facing narration is FICTION ONLY". + - **Act 1 — inciting incident + human-scale hook.** Open a grounded, personal scene (the 4-beat cold open for a brand-new campaign). Establish tone, a real inciting incident, and a hook that matters to a PERSON, not the world yet. Grandeur is texture here — the vast glimpsed at the edges — not world-saving stakes dumped in the cold open. - **Act 2 — rising action + a MANDATORY midpoint reversal + a cost.** Escalate with friction that STICKS (a real attempt fails, a choice exacts a price). At the midpoint, deliver a genuine REVERSAL — a *turn* to absorb, not merely "harder": the ally is the informant, the prize is already gone, the safe path was the trap, and the cost lands on the HERO personally (their own skin, bond, or secret). This is the single lever the story score most often docks; do not smooth it over or re-roll it away. - **Act 3 — climax + payoff.** Converge the threads into a decisive, dramatized confrontation, and PAY OFF what Act 1 set up and what the midpoint cost. The climax is **co-authored**: hand the player the discovery and let THEM react — confrontations come as interruptible exchanges, never a single block of villain monologue or DM-narrated revelation (see `reference/storycraft.md`). Let the price already paid matter. Close clean and resonant — and in the denouement, **signal every live named thread** (a foe's fate, an NPC's stance) so nothing important just vanishes; no new sub-plots in the final beats. diff --git a/skills/dungeon-master/SKILL.md b/skills/dungeon-master/SKILL.md index 06a85d37..788420cc 100644 --- a/skills/dungeon-master/SKILL.md +++ b/skills/dungeon-master/SKILL.md @@ -44,6 +44,12 @@ The mechanics above are the floor; these make it a *scene* and not a log. They a - **The world PUSHES BACK — and choices COST.** NPCs can refuse, stall, lie, demand more, or counter-offer; do **not** auto-grant every player ask (an unearned concession reads as a railroad). If a declaration assumes a result ("I cross unseen", "I clock the signet ring"), resolve it with the dice and narrate what *actually* happens. **And momentum needs friction that STICKS:** at least once a scene a real attempt FAILS, a choice exacts a price, or a reversal flips the situation — a botched check *changes* the scene, it isn't smoothed over or re-rolled away. A session where every clever move just works and nothing is lost is **flat no matter how good the dialogue** (it's the #1 thing the story score docks). Before any climax, land one genuine complication the player has to absorb. - **Resolve only what the player declared — never play their part.** Voice the *world* and adjudicate with the dice; **never put words in the player's mouth, take an action they didn't declare, or decide their next move.** Don't ask "what do you do?" and then answer it yourself. Make every choice you offer **real, not illusory** — it must visibly *bend* the scene, or it isn't a choice. - **Dice live inside the tools.** The player reads the *felt outcome* ("the near one's boots are too clean for this room"), never a bare "Perception 16 / nat 1" label dropped into the prose. +- **The player-facing narration is FICTION ONLY — your craft scaffolding is PRIVATE and NEVER leaks into it.** Everything the player reads is in-world prose + quoted dialogue. The act/beat structure, the dice math, and the "what the scene is doing" bookkeeping are *your* private reasoning — keep them entirely out of the narration. Three things that must NEVER appear in a player-facing beat (this is a system-prompt-style leak, the same class as echoing a Director nudge or a tool name): + - **Dice / check tallies** — never "three failed social checks", "after two missed Perception rolls", "she's failed every Insight check so far". The player feels the *outcome* in the fiction ("Zevlor's jaw stays set; whatever you've tried, the door in his face hasn't opened"), never the count. + - **Plot-structure / craft jargon** — never name the machinery: *spine hook*, *rib*, *cold open*, *act* / *act one/two/three*, *beat*, *midpoint*, *reversal*, *setup*, *payoff*, *inciting incident*, *scene N*, *the Threshold/the Call* (as labels). The structure is **felt, never labeled** (see `reference/storycraft.md`) — narrate the moment, not the moment's role in the arc. + - **Stage-directions / status summaries** — never a meta line announcing the scene's state: "Meeting beat of the cold open complete", "beat complete", "Act 1 wraps", "this connects to the spine hook", "X of the Y complete", "scene resolved — advancing". When a beat lands, you simply move into the next beat of fiction; you do not stamp it done. + + Verbatim example of the leak to NEVER produce: *"Zevlor held silence after three failed social checks; … connecting directly to the spine hook. Meeting beat of the cold open complete."* That is your scratchpad bleeding onto the page. Hold the scaffolding in your head; give the player only the world. - **End each beat on a live, open moment dramatized IN the scene** — the situation in front of them and the choice it forces ("his hand drifts toward his coat; the back door is six feet behind you"). **Never** end on a bare sign-off tag on its own line — "*Your move.*" / "*What do you do?*" repeated every beat is dead air masquerading as agency. Let the open question live in the concrete detail, then stop and let the player act. - **The WORLD MOVES — don't run a whole session in one room at one hour** (session-scope, not per-beat, but non-negotiable across the arc). A living world *progresses*: (1) **the clock advances** — see step 7; a session still at *morning* in the *opening location* is frozen; (2) **the party travels to ≥2 locations** — `travel_to` along connections (or `add_location(make_current=True)` for somewhere new), with `advance_time=True` when it's a real journey; (3) **new named faces enter** — `create_character` an NPC (give them a name + a line; mark `met=True` when the party meets them on-screen). The seeded roster is a *starting cast*, not the whole world — keep peopling it. A session frozen in the opening scene, in one place, with no one new is a FAILED session no matter how good the prose, and the QA gate now flips it RED. diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index 98680565..e9d41df4 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -10,6 +10,9 @@ // `sanitizeNarration(text)` returns a cleaned narration string, or "" when the WHOLE // beat was internal (caller drops it). It is line-oriented so a single stray advisory // line inside an otherwise-real beat is removed without nuking the prose around it. +// #347 extends it with a SENTENCE-level pass that strips story-craft scaffolding (dice +// tallies, plot-structure jargon, "beat complete" stage-directions) embedded mid-line — +// see the `_isScaffoldingSentence` block below for the HIGH-CONFIDENCE-only patterns. const DM_ENGINE_TOOLS = [ "remember", "recall", "recall_decisions", "log_event", "add_quest", "update_decision", "record_decision", "add_consequence", "check_consequences", @@ -52,10 +55,101 @@ function _isInternalLine(line) { || _ADVISORY_DIRECTIVE.test(t) || _BARE_TOOL_LINE.test(t); } + +// #347: the SECOND class of DM-internal leak — the DM agent's STORY-CRAFT scaffolding +// (its private act/beat plan, dice tallies, and "beat complete" stage-directions) +// bleeding into player prose. Unlike the #335 advisory/tool leaks (which arrive as their +// OWN line), scaffolding tends to land as a trailing CLAUSE/SENTENCE inside an otherwise +// real narration line ("Zevlor held silence after three failed social checks; … the spine +// hook. Meeting beat of the cold open complete."). So this guard works at two grains: +// (a) a dice/check TALLY is excised as a sub-clause IN PLACE — "Zevlor held silence after +// three failed social checks" → "Zevlor held silence." — so the real prose survives; +// (b) plot-craft JARGON + "beat complete" STAGE-DIRECTIONS drop the whole sentence (these +// sentences are scaffolding through-and-through), keeping the prose around them. +// +// CRITICAL: HIGH-CONFIDENCE phrasings ONLY — never bare common words. A war-drum's "beat", +// a tavern "scene", or "the second act of the play" are legitimate fiction and MUST survive. +// "beat"/"scene"/"act" alone never trigger; they only do inside a fixed scaffolding frame. + +// Spelled-out + digit small numbers, for "three failed social checks" / "3 missed saves". +const _NUM_WORD = "(?:\\d+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve)"; +// (1) Dice / CHECK tallies: a count + a result word + a check/roll/save noun. Anchored on +// the check/roll/save noun so "three failed assaults" / "two broken oaths" (real prose) DON'T match. +// `_TALLY` (global) is used for in-place clause excision; it eats a leading connective +// preposition ("after"/"following"/"with"/…) when present so no dangling "after ." is left. +const _TALLY = new RegExp( + "\\s*(?:after|following|despite|past|on|with)?\\s*\\b" + _NUM_WORD + + "\\s+(?:failed|successful|missed|botched|passed|blown)\\s+" + + "(?:\\w+\\s+){0,2}(?:checks?|rolls?|saves?|saving throws?)\\b", + "gi", +); +// A non-global twin for boolean detection (a global regex carries lastIndex state). +const _TALLY_TEST = new RegExp(_TALLY.source, "i"); +// (2) Unambiguous plot-CRAFT terms — multi-word jargon that never occurs in fiction prose. +const _CRAFT_JARGON = /\b(?:spine[- ]hook|inciting incident|midpoint reversal)\b/i; +// (3) Stage-directions / status summaries — a meta line stamping the scene's structural state. +// - " beat complete", "beat complete", "cold open complete", "Act 2 complete/wraps" +// - "meeting beat" / " beat of the cold open" (the verbatim #347 form) +// - "connects/connecting … to the spine hook", "this is the beat" +// - "X of the Y complete" where Y is a structural word (beat/act/scene/cold open) +const _STAGE_DIRECTION = new RegExp( + "(?:" + + // a structural unit being marked done/wrapped (optional copula: "cold open IS complete") + "\\b(?:cold open|(?:\\w+\\s+)?beat|act(?:\\s+(?:one|two|three|\\d+))?|scene\\s+\\d+|sequence)\\s+" + + "(?:is\\s+|was\\s+|now\\s+)?(?:complete|completed|done|over|wraps?|wrapped|resolved)\\b|" + + // "meeting/arrival/threshold beat" — naming a beat by its craft role + "\\b(?:meeting|arrival|threshold|inciting|setup|payoff|climax|midpoint|opening|closing)\\s+beat\\b|" + + // explicitly wiring the moment to the arc machinery + "\\bconnect(?:s|ing)?\\b[^.]{0,30}\\b(?:spine[- ]hook|the spine|main arc)\\b|" + + // labeling THIS moment as a named structural beat + "\\bthis (?:is|completes|closes) the\\b[^.]{0,30}\\b(?:setup|payoff|midpoint|reversal|beat|act|cold open)\\b|" + + // " of the complete" (e.g. "Meeting beat of the cold open complete") + "\\bof the\\b[^.]{0,30}\\b(?:cold open|beat|act|scene)\\b[^.]{0,30}\\bcomplete\\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); +} +function _hasScaffolding(text) { + return _TALLY_TEST.test(text) || _CRAFT_JARGON.test(text) || _STAGE_DIRECTION.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); +// 2. split into sentences (on . ! ? ;, terminator kept) and DROP any sentence that is +// wholly plot-craft jargon / a stage-direction. +// Returns the cleaned line (possibly ""). +function _stripScaffoldingSentences(line) { + if (typeof line !== "string" || !line) return line; + // Fast path: nothing scaffolding-shaped here, don't touch the line at all. + if (!_hasScaffolding(line)) return line; + // (1) In-place tally excision (global; reset lastIndex defensively). + _TALLY.lastIndex = 0; + let cleaned = line.replace(_TALLY, ""); + // (2) Whole-sentence drop for jargon / stage-directions. + const parts = cleaned.match(/[^.!?;]+[.!?;]+|[^.!?;]+$/g); + if (parts) cleaned = parts.filter((p) => !_isScaffoldingSentence(p)).join(""); + // Tidy punctuation/space the excisions may have left (" ." / " " / leading "; " / + // a clause separator now dangling at the end, e.g. "Zevlor held silence;" → "…silence."). + return cleaned + .replace(/\s+([.!?;,])/g, "$1") + .replace(/\s{2,}/g, " ") + .replace(/^[\s;,.]+/, "") + .replace(/\s*[;,]\s*$/, ".") + .trim(); +} function sanitizeNarration(text) { if (typeof text !== "string" || !text) return ""; const kept = text .split(/\r?\n/) + // #347: first excise any scaffolding sentences embedded in a line… + .map((line) => _stripScaffoldingSentences(line)) + // …then drop any line that is wholly a #335 advisory/tool-name internal line (or was + // emptied by the scaffolding strip above — _isInternalLine returns false on "", so an + // emptied line survives as "" and is harmlessly collapsed by the blank-run join below). .filter((line) => !_isInternalLine(line)); // Collapse the blank-line runs an excised directive may leave behind. return kept.join("\n").replace(/\n{3,}/g, "\n\n").trim(); diff --git a/viewer/tests/test_sanitize_narration.py b/viewer/tests/test_sanitize_narration.py new file mode 100644 index 00000000..17766b02 --- /dev/null +++ b/viewer/tests/test_sanitize_narration.py @@ -0,0 +1,160 @@ +"""Behavior tests for the player-Chronicle narration guard `sanitizeNarration`. + +`sanitizeNarration` (viewer/openworlds/screen-table.jsx) is the read/projection +filter that keeps DM-INTERNAL text out of the player-facing story scroll. It runs +in the browser under the bundled Babel-standalone; these tests exercise the REAL +function by transpiling the actual `.jsx` with that SAME bundled Babel and running +it under Node, so the test tracks the shipped behavior, not a reimplementation. + +#335 added the guard for GM-advisory directives + bare engine-tool names. +#347 extends it to story-craft SCAFFOLDING: dice/check tallies ("three failed +social checks"), plot-structure jargon ("spine hook"/"cold open"), and +"beat complete" stage-directions — while NEVER false-positiving on legitimate +fiction that uses "beat"/"scene"/"act"/"hook" as ordinary words. +""" + +import json +import shutil +import subprocess +import unittest +from pathlib import Path + + +_OPENWORLDS = Path(__file__).resolve().parents[1] / "openworlds" +_SCREEN_TABLE = _OPENWORLDS / "screen-table.jsx" +_BABEL = _OPENWORLDS / "vendor" / "babel-standalone-7.29.0.min.js" + + +@unittest.skipIf(shutil.which("node") is None, "node is required to transpile + run the JSX guard") +class SanitizeNarrationTests(unittest.TestCase): + NODE_BIN = shutil.which("node") + + @classmethod + def setUpClass(cls): + cls.assertTrue(_SCREEN_TABLE.exists(), f"missing {_SCREEN_TABLE}") + cls.assertTrue(_BABEL.exists(), f"missing bundled babel at {_BABEL}") + + def _sanitize_many(self, inputs: dict) -> dict: + """Transpile screen-table.jsx with the bundled Babel and run sanitizeNarration + over each input under Node. Returns {key: cleaned_string}.""" + program = ( + "const fs = require('fs');\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" + # Minimal window/React stub so the module body runs headless; the file + # exports sanitizeNarration onto window via Object.assign(window, {...}). + + "const sb = { React: { useState: () => [null, () => {}], useRef: () => ({})," + + " useCallback: (f) => f, useEffect: () => {}, createElement: () => null, Fragment: 'F' } };\n" + + "sb.window = sb;\n" + + "const vm = require('vm'); vm.createContext(sb); vm.runInContext(code, sb);\n" + + "const fn = sb.window.sanitizeNarration;\n" + + "if (typeof fn !== 'function') { throw new Error('sanitizeNarration not exported'); }\n" + + "const inputs = " + json.dumps(inputs) + ";\n" + + "const out = Object.fromEntries(Object.entries(inputs).map(([k, v]) => [k, fn(v)]));\n" + + "process.stdout.write(JSON.stringify(out));\n" + ) + proc = subprocess.run( + [self.NODE_BIN, "--input-type=commonjs"], + input=program, + text=True, + capture_output=True, + check=True, + ) + return json.loads(proc.stdout) + + # The verbatim #347 leak (from the #324 narrative persona): all three scaffolding + # types in one beat — a dice tally, plot-structure jargon, and a stage-direction. + _VERBATIM_LEAK = ( + "Zevlor held silence after three failed social checks; the moment " + "connecting directly to the spine hook. Meeting beat of the cold open complete." + ) + + def test_strips_the_verbatim_347_scaffolding_leak(self): + out = self._sanitize_many({"leak": self._VERBATIM_LEAK})["leak"] + low = out.lower() + # All three scaffolding fingerprints are gone… + self.assertNotIn("failed social checks", low) + self.assertNotIn("spine hook", low) + self.assertNotIn("cold open", low) + self.assertNotIn("meeting beat", low) + self.assertNotIn("complete", low) + # …and the real in-world prose survives (this is the value of the surgical strip). + self.assertIn("Zevlor held silence", out) + + def test_strips_each_scaffolding_class(self): + cases = { + "tally_words": "The lock holds after three failed social checks.", + "tally_digits": "She fails after 2 missed saves.", + "jargon_inciting": "Good — the inciting incident has landed.", + "jargon_midpoint": "That was the midpoint reversal.", + "stage_beat_complete": "The bell tolls once. Beat complete.", + "stage_cold_open_is_complete": "The bell rings. Cold open is complete.", + "stage_act_wraps": "Act 2 wraps here.", + "stage_connects_spine": "This connects directly to the spine hook.", + "stage_setup_beat": "This is the setup beat for the betrayal.", + } + out = self._sanitize_many(cases) + self.assertNotIn("social checks", out["tally_words"].lower()) + self.assertNotIn("missed saves", out["tally_digits"].lower()) + self.assertNotIn("inciting incident", out["jargon_inciting"].lower()) + self.assertNotIn("midpoint reversal", out["jargon_midpoint"].lower()) + self.assertNotIn("beat complete", out["stage_beat_complete"].lower()) + self.assertIn("The bell tolls once", out["stage_beat_complete"]) # prose kept + self.assertNotIn("cold open", out["stage_cold_open_is_complete"].lower()) + self.assertIn("The bell rings", out["stage_cold_open_is_complete"]) # prose kept + self.assertNotIn("wraps", out["stage_act_wraps"].lower()) + self.assertNotIn("spine hook", out["stage_connects_spine"].lower()) + self.assertNotIn("setup beat", out["stage_setup_beat"].lower()) + + def test_preserves_legitimate_prose_using_craft_words(self): + # The false-positive guard: "beat"/"scene"/"act"/"hook"/"complete" as ORDINARY + # words, and counts that are NOT check/roll/save tallies, must pass through verbatim. + legit = { + "war_drum_beat": "A war-drum's beat rolled across the field as the orcs advanced.", + "heart_beat": "Her heart skipped a beat when the door creaked open.", + "tavern_scene": "The tavern scene was warm; a bard tuned his lute in the corner.", + "act_of_betrayal": "The act of betrayal still stung, weeks later.", + "second_act_play": "They watched the second act of the play from the balcony.", + "cold_open_road": "The cold open road stretched north under a bruised sky.", + "missed_the_mark": "Three of his arrows missed the mark and clattered off stone.", + "failed_assaults": "After two failed assaults, the gate still held.", + "ritual_complete": "The ritual is complete; the candles gutter out one by one.", + "fishing_hook": "The fish took the hook and the line went taut.", + "scene_of_crime": "The scene of the crime was scrubbed clean before dawn.", + } + out = self._sanitize_many(legit) + for key, original in legit.items(): + with self.subTest(case=key): + self.assertEqual(out[key], original) + + def test_335_advisory_and_tool_guards_still_apply(self): + # Regression: the #335 line-oriented guards (GM-advisory header, bare tool line) + # must keep working after the #347 sentence-level extension. + cases = { + "advisory": ( + "GM Advisory: an NPC has been introduced but hasn't spoken — " + "record their first memory with `remember`." + ), + "bare_tool": "The gate groans open.\nremember(hero, 'found the sigil')", + } + out = self._sanitize_many(cases) + self.assertNotIn("GM Advisory", out["advisory"]) + self.assertIn("The gate groans open", out["bare_tool"]) + self.assertNotIn("remember(", out["bare_tool"]) + + def test_scaffolding_line_inside_a_multiline_beat_is_dropped(self): + beat = ( + "Rain hammers the cobbles outside the Elfsong.\n" + "Meeting beat of the cold open complete.\n" + "The bard strikes a minor chord." + ) + out = self._sanitize_many({"beat": beat})["beat"] + self.assertIn("Rain hammers the cobbles", out) + self.assertIn("The bard strikes a minor chord", out) + self.assertNotIn("cold open", out.lower()) + self.assertNotIn("meeting beat", out.lower()) + + +if __name__ == "__main__": + unittest.main()