Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions skills/dungeon-master/AGENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions skills/dungeon-master/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
94 changes: 94 additions & 0 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.
// - "<word> beat complete", "beat complete", "cold open complete", "Act 2 complete/wraps"
// - "meeting beat" / "<word> beat of the cold open" (the verbatim #347 form)
// - "connects/connecting … to the spine hook", "this is the <setup|payoff|midpoint> 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|" +
// "<anything> of the <structural-unit> 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();
Expand Down
Loading
Loading