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
48 changes: 48 additions & 0 deletions qa/dm_narration_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@
writer); on a missing snapshot / session log / narration it prints nothing and the caller keeps
today's behavior (no regression).

#357 RE-SCOPE (nb3 cold-open): the recovered prose must be a PLAYER-FACING 2nd-person scene,
never a 3rd-person setup brief / game-system notation. On the cold open the DM did its setup
silently via tools and logged only a 3rd-person brief ("COLD OPEN — ARRIVAL: Rolan (tiefling
wizard, PC) walks toward…") via log_event(kind="narration"), then ended its turn with EMPTY
reply text -- so this fallback (then) recovered that brief and the player saw developer/GM
notation instead of an opening scene. We now REJECT the high-confidence setup-brief shapes (a
leading ALLCAPS label header; a (… PC)/(… NPC)/(level N …) character-sheet tag) so a brief is
treated like bookkeeping and never reaches the chat; a real 2nd-person scene always survives.

It lives as a standalone file (not a heredoc inside `$(...)`) because the macOS system bash
(3.2.57) mis-parses a quoted heredoc nested in command substitution -- it is invoked by path
from `clawdnd_dm_narration_or_fallback` in qa/lib_beat_driver.sh.
Expand All @@ -26,6 +35,7 @@
"""
import json
import os
import re
import sys

# Engine session-log kinds (SessionLogEntry.kind): narration | dialogue | roll | system | combat.
Expand All @@ -35,6 +45,37 @@
# Cap the recovered block so one fat multi-paragraph beat can't dump the whole log into the chat.
MAX_PROSE_ROWS = 6

# #357 re-scope (nb3): a narration row can ALSO be a 3rd-person SETUP BRIEF in game-system
# notation that the DM logged via log_event during silent cold-open setup -- e.g.
# "COLD OPEN — ARRIVAL: Rolan (tiefling wizard, PC) walks toward Sorcerous Sundries…"
# That is the DM's scratchpad, NOT a scene the player can read and respond to. Recovering it
# (what #360 did) is WORSE than recovering nothing: the player sees developer/GM notation
# instead of prose. So before a narration row qualifies as recoverable player-facing prose we
# REJECT the high-confidence setup-brief / system-notation shapes. HIGH-CONFIDENCE ONLY -- a
# real 2nd-person scene ("You step into the Heapside warren…") must always survive.

# A leading ALLCAPS structural LABEL followed by ':' or ' — ' — the chronicle/brief header the
# DM writes for itself ("COLD OPEN — ARRIVAL:", "SETUP:", "BRIEF —", "CHRONICLE:"). Two+ caps
# words so an in-fiction shout ("HELP!") or a single proper noun never trips it.
_SETUP_LABEL = re.compile(r"^\s*[A-Z][A-Z'’]+(?:[ \-—–][A-Z][A-Z'’]+){0,5}\s*(?::|—|–|-\s)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Replace ambiguous Unicode characters with ASCII equivalents.

The regex pattern uses Unicode RIGHT SINGLE QUOTATION MARK (') and EN DASH (, ) characters. These can cause encoding issues and matching failures when the input text uses standard ASCII apostrophes and hyphens. Use ASCII equivalents for robustness.

🔧 Proposed fix
-_SETUP_LABEL = re.compile(r"^\s*[A-Z][A-Z'']+(?:[ \-—–][A-Z][A-Z'']+){0,5}\s*(?::|—|–|-\s)")
+_SETUP_LABEL = re.compile(r"^\s*[A-Z][A-Z']+(?:[ \-][A-Z][A-Z']+){0,5}\s*(?::|-\s)")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_SETUP_LABEL = re.compile(r"^\s*[A-Z][A-Z']+(?:[ \-—–][A-Z][A-Z']+){0,5}\s*(?::|—|–|-\s)")
_SETUP_LABEL = re.compile(r"^\s*[A-Z][A-Z']+(?:[ \-][A-Z][A-Z']+){0,5}\s*(?::|-\s)")
🧰 Tools
🪛 Ruff (0.15.14)

[warning] 60-60: String contains ambiguous (RIGHT SINGLE QUOTATION MARK). Did you mean ``` (GRAVE ACCENT)?

(RUF001)


[warning] 60-60: String contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF001)


[warning] 60-60: String contains ambiguous (RIGHT SINGLE QUOTATION MARK). Did you mean ``` (GRAVE ACCENT)?

(RUF001)


[warning] 60-60: String contains ambiguous (EN DASH). Did you mean - (HYPHEN-MINUS)?

(RUF001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@qa/dm_narration_fallback.py` at line 60, The _SETUP_LABEL regex contains
Unicode RIGHT SINGLE QUOTATION MARK and EN DASH/EN DASH characters which can
fail on ASCII inputs; update the pattern in the _SETUP_LABEL constant to use
ASCII equivalents (apostrophe ' and hyphen/minus -) or include both ASCII and
Unicode variants in the character classes (e.g., replace the Unicode ’ with '
and replace —/– with - or a class like [\-\u2013\u2014]) so matching is robust
across encodings.

# A parenthetical CHARACTER-SHEET tag — "(tiefling wizard, PC)", "(PC)", "(NPC)",
# "(level 3 fighter)". The PC/NPC/level/class role annotation is pure game-system notation;
# in-fiction parentheticals ("(or so the rumor went)") don't carry these tokens.
_SHEET_TAG = re.compile(
r"\((?:[^)]*\b(?:PC|NPC)\b[^)]*|[^)]*\blevel\s*\d+[^)]*)\)",
re.IGNORECASE,
)


def _is_system_notation(text):
"""True when a narration row is a 3rd-person setup brief / game-system notation rather than
a player-facing scene (#357). Conservative: only the high-confidence shapes -- a leading
ALLCAPS label header, or a (… PC)/(… NPC)/(level N …) character-sheet tag."""
t = (text or "").strip()
if not t:
return False
return bool(_SETUP_LABEL.match(t) or _SHEET_TAG.search(t))


def _recover(snap_path):
try:
Expand Down Expand Up @@ -78,6 +119,13 @@ def _recover(snap_path):
continue
kind = str(row.get("kind") or "narration").strip().lower()
text = str(row.get("text") or "").strip()
# A 3rd-person setup brief / system-notation NARRATION row (#357) is the DM's
# scratchpad, not a scene — treat it like bookkeeping: it breaks the trailing
# block and is never recovered (showing the player notation is worse than blank).
# Dialogue rows are always a quoted character line, so they're never system-notation.
if kind == "narration" and text and _is_system_notation(text):
block = []
continue
if kind in PROSE_KINDS and text:
speaker = str(row.get("speaker") or "").strip()
# A dialogue row keeps its speaker tag so a quoted line still reads as the
Expand Down
4 changes: 4 additions & 0 deletions scripts/play.sh
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ Begin a SOLO session in a living world for a single human player who will act th
- Open a human-scale, personal scene grounded in the world's canon, built around the EXISTING player character $HERO_PC_NAME, with real quoted dialogue, and hand the player an open moment + a clear, real choice.
- A companion should ENTER as part of that opening scene — a roster legend (recruit_companion / load_canon_character) or an original — someone the player MEETS on-screen (voiced, with a real wound and a reason they fall in together), NOT a name that silently appears in the party. Recruit them into the party as that meeting lands.

CRITICAL — your FINAL output THIS turn MUST BE the opening SCENE itself, written as 2nd-person player-facing prose (addressed to \"you\"): where $HERO_PC_NAME IS, what they see/hear/smell, who is present and a real quoted line from them, ending on a clear open moment + choice. The player reads ONLY your final reply text as the scene — so the opening prose MUST be IN it. Re-ground via the tools FIRST, then CLOSE the turn by writing the scene. NEVER end this turn on a tool call, and NEVER let your reply be a 3rd-person setup brief or game-system notation (e.g. \"COLD OPEN — ARRIVAL: $HERO_PC_NAME (tiefling wizard, PC) walks toward…\") — that is your private scratchpad, not the player's scene. If you logged a setup note via log_event, you must STILL write the 2nd-person scene as your reply text.

Their actions will arrive as tagged moves — [say] (their dialogue), [do] (an attempt), [check] (roll that skill), [cast]/[use]/[attack] (resolve via the engine) — one per turn from the dashboard.")"
else
DMSG="$(dm_turn 1 "You are the Dungeon Master for a solo ClawDnD adventure. Activate and follow your \`dungeon-master\` skill — run its \"Generating a world live\" mode and hold its craft bar (mechanics sourced from the engine, NPCs speak, the world pushes back, scenes played not logged).
Expand All @@ -269,6 +271,8 @@ Begin a SOLO session in a living world for a single human player who will act th
- Open a human-scale, personal scene grounded in the world's canon, with real quoted dialogue, and hand the player an open moment + a clear, real choice.
- A companion should ENTER as part of that opening scene — a roster legend (recruit_companion / load_canon_character) or an original — someone the player MEETS on-screen (voiced, with a real wound and a reason they fall in together), NOT a name that silently appears in the party. Recruit them into the party as that meeting lands.

CRITICAL — your FINAL output THIS turn MUST BE the opening SCENE itself, written as 2nd-person player-facing prose (addressed to \"you\"): where the player IS, what they see/hear/smell, who is present and a real quoted line from them, ending on a clear open moment + choice. The player reads ONLY your final reply text as the scene — so the opening prose MUST be IN it. Do your world/character/log setup with the tools FIRST, then CLOSE the turn by writing the scene. NEVER end this turn on a tool call, and NEVER let your reply be a 3rd-person setup brief or game-system notation (e.g. \"COLD OPEN — ARRIVAL: <Name> (tiefling wizard, PC) walks toward…\") — that is your private scratchpad, not the player's scene. If you logged a setup note via log_event, you must STILL write the 2nd-person scene as your reply text.

Their actions will arrive as tagged moves — [say] (their dialogue), [do] (an attempt), [check] (roll that skill), [cast]/[use]/[attack] (resolve via the engine) — one per turn from the dashboard.")"
fi
# #357: same empty-reply fallback as the beat loop — recover the engine's logged opening
Expand Down
2 changes: 2 additions & 0 deletions scripts/play_party.sh
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ Begin a session in a living world for a single human player (who acts through th
- Create a level-3 player character for the HUMAN (generate_ability_scores + create_character, apply_srd_defaults, sensible skills/spells). Pick a fitting concept and tell the player who they are. This is the ONLY character you create.
- Open a human-scale, personal scene grounded in the world's canon, with real quoted dialogue, that includes the human's PC AND their companions, and hand the player an open moment + a clear, real choice.

CRITICAL — your FINAL output THIS turn MUST BE the opening SCENE itself, written as 2nd-person player-facing prose (addressed to \"you\"): where the player IS, what they see/hear/smell, who is present and a real quoted line from them, ending on a clear open moment + choice. The player reads ONLY your final reply text as the scene — so the opening prose MUST be IN it. Do your setup with the tools FIRST, then CLOSE the turn by writing the scene. NEVER end this turn on a tool call, and NEVER let your reply be a 3rd-person setup brief or game-system notation (e.g. \"COLD OPEN — ARRIVAL: <Name> (tiefling wizard, PC) walks toward…\") — that is your private scratchpad, not the player's scene. If you logged a setup note via log_event, you must STILL write the 2nd-person scene as your reply text.

Each beat, declarations arrive as tagged moves — [say] (dialogue), [do] (an attempt), [check] (roll that skill), [cast]/[use]/[attack] (resolve via the engine) — from the HUMAN (their PC) and from each companion (banner-tagged with the companion's name). Resolve EACH actor's moves through the engine.")"
# #357: recover the engine's logged opening narration if the DM's first turn ended on a tool
# call rather than prose — BEFORE the abort check, so a tool-final-but-narrated opener stands.
Expand Down
62 changes: 62 additions & 0 deletions servers/engine/tests/test_dm_narration_fallback.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,65 @@ def test_malformed_snapshot_recovers_nothing(tmp_path, monkeypatch):
snap = camp_dir / "snapshot.json"
snap.write_text("{not valid json", encoding="utf-8")
assert _run(snap) == ""


# ── #357 re-scope (nb3): never recover a 3rd-person setup brief / game-system notation ──────

def test_coldopen_setup_brief_is_not_recovered(tmp_path, monkeypatch):
# The EXACT nb3 failure: on the cold open the DM logged ONLY a 3rd-person setup brief in
# game-system notation (a leading ALLCAPS label + a "(tiefling wizard, PC)" sheet tag) and
# ended its turn with empty reply text. Recovering that brief showed the player developer
# notation, not a scene. The fallback must now recover NOTHING here (blank > notation).
entries = [
SessionLogEntry(t=1.0, kind="system", text="Session 1 began: Winter After the War"),
SessionLogEntry(
t=2.0, kind="narration",
text=("COLD OPEN — ARRIVAL: Rolan (tiefling wizard, PC) walks toward Sorcerous "
"Sundries to pick up reagents. A new Flaming Fist checkpoint blocks the lane "
"near Siltwharf Rise. Rolan joins the back of the queue."),
),
]
snap = _seed(tmp_path, monkeypatch, campaign_id="camp_h", session_id="sess_h", entries=entries)
out = _run(snap)
assert out == ""
assert "COLD OPEN" not in out
assert "(tiefling wizard, PC)" not in out


def test_setup_brief_breaks_block_but_real_scene_after_survives(tmp_path, monkeypatch):
# A setup brief followed by the REAL 2nd-person opening scene: the brief is excluded
# (treated like bookkeeping that breaks the trailing block); only the real scene is recovered.
entries = [
SessionLogEntry(t=1.0, kind="narration",
text="SETUP: Mara (PC) is a Harper agent newly arrived in the Lower City."),
SessionLogEntry(
t=2.0, kind="narration",
text=("You stand at the mouth of Siltwharf Rise, the morning fog clinging to the "
"cobblestones, a Flaming Fist checkpoint blocking the lane ahead."),
),
SessionLogEntry(t=3.0, kind="dialogue", text="Papers. Now.", speaker="Fist Sergeant"),
]
snap = _seed(tmp_path, monkeypatch, campaign_id="camp_i", session_id="sess_i", entries=entries)
out = _run(snap)
assert out == (
"You stand at the mouth of Siltwharf Rise, the morning fog clinging to the "
"cobblestones, a Flaming Fist checkpoint blocking the lane ahead.\n\n"
"Fist Sergeant: Papers. Now."
)
assert "SETUP:" not in out and "(PC)" not in out


def test_real_2nd_person_prose_with_innocent_parens_survives(tmp_path, monkeypatch):
# GUARD against over-matching: real 2nd-person prose with an in-fiction parenthetical that
# does NOT carry a PC/NPC/level token must be recovered untouched (no false positive).
entries = [
SessionLogEntry(
t=1.0, kind="narration",
text=("You duck beneath the awning (or what's left of it) as the rain hammers the "
"tin roofs of Heapside, and a hooded figure waits by the well."),
),
]
snap = _seed(tmp_path, monkeypatch, campaign_id="camp_j", session_id="sess_j", entries=entries)
out = _run(snap)
assert out.startswith("You duck beneath the awning")
assert "hooded figure" in out
15 changes: 15 additions & 0 deletions skills/dungeon-master/reference/quest-generation.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ NOT a rail and NOT a read-aloud list.**
> the SKILL.md non-negotiable "never play their part" at its sharpest — the cold open is where
> it's most tempting to break.)

> **The cold open's FINAL output is the opening SCENE itself — as your reply text, in 2nd
> person.** This is the same non-negotiable as every beat (SKILL.md: "Your turn's FINAL output
> is ALWAYS 2nd-person player-facing narration"), and the cold open is where it's most often
> dropped: you do all the silent setup with tools — `start_world`/`get_state`, seat the PC,
> recruit the companion, `look_around`, `generate_image`, `log_event`, `remember` — and then
> *end the turn on a tool call or a 3rd-person setup note instead of writing the scene*. The
> player reads **only your reply text** as their opening; an empty reply or a 3rd-person brief
> means a first-timer sees no scene at all. So: do the setup FIRST, then **close the turn by
> writing the Arrival as 2nd-person prose addressed to "you"** — where you are, what you see/
> hear/smell, who's present and a real quoted line, ending on the open moment + choice.
> **NEVER** let your reply be a game-system setup brief like *"COLD OPEN — ARRIVAL: Rolan
> (tiefling wizard, PC) walks toward Sorcerous Sundries…"* — that 3rd-person, sheet-tagged
> notation is your private scratchpad, never the player's scene. If you `log_event` a setup
> note, you must STILL write the 2nd-person scene as your reply text.

Spend real scene-time here. A strong cold open is the difference between "a session that started"
and "a session someone wants to keep playing." Then enter the normal beat cycle.

Expand Down
Loading
Loading