From 67e958c4bb72f7e510a264ac9e65dedf6c0c9fa7 Mon Sep 17 00:00:00 2001 From: Eva Date: Sat, 30 May 2026 18:53:58 +0700 Subject: [PATCH] fix(dm,viewer): cold-open delivers a real 2nd-person opening scene, not the setup brief MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (nb3, bb1870d): the DM never wrote the 2nd-person opening scene as its reply text (final result text = 0 chars). It logged only a 3rd-person game-system setup brief via log_event(kind=narration) ('COLD OPEN — ARRIVAL: Rolan (tiefling wizard, PC) walks toward...'), then ended its turn. The #360 empty-reply fallback recovered that 3rd-person brief and pushed it to /chat, so a first-timer saw developer/GM notation instead of an opening scene. A GM Advisory bookkeeping panel also leaked to the player's live-play sidebar. Fixes both ends + the leak (engine stays sole writer — prompt/skill/resolver/ sanitizer only): - qa/dm_narration_fallback.py: never recover a 3rd-person setup brief / game-system notation (high-confidence shapes: a leading ALLCAPS label header or a (... PC)/(... NPC)/(level N ...) sheet tag) — blank beats notation. - scripts/play.sh (both opener branches) + scripts/play_party.sh: the cold-open opener prompt now carries the same 'FINAL reply text MUST be the 2nd-person scene, never a tool call / 3rd-person brief' contract the beat loop has. - skills/dungeon-master/reference/quest-generation.md: explicit cold-open reply-text contract. - viewer/openworlds/screen-table.jsx: sanitizeNarration strips the leaked scene-debt KIND label line ('npc introduced silent ...'); the GM Advisory panel is removed from the player live-play table screen (still on the journal). - tests: +4 resolver tests, +1 sanitizer test. Addresses #357 --- qa/dm_narration_fallback.py | 48 ++++++++++++++ scripts/play.sh | 4 ++ scripts/play_party.sh | 2 + .../tests/test_dm_narration_fallback.py | 62 +++++++++++++++++++ .../reference/quest-generation.md | 15 +++++ viewer/openworlds/screen-table.jsx | 60 ++++++++---------- viewer/tests/test_sanitize_narration.py | 28 +++++++++ 7 files changed, 185 insertions(+), 34 deletions(-) diff --git a/qa/dm_narration_fallback.py b/qa/dm_narration_fallback.py index 84d1716f..b29bf4f4 100755 --- a/qa/dm_narration_fallback.py +++ b/qa/dm_narration_fallback.py @@ -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. @@ -26,6 +35,7 @@ """ import json import os +import re import sys # Engine session-log kinds (SessionLogEntry.kind): narration | dialogue | roll | system | combat. @@ -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)") +# 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: @@ -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 diff --git a/scripts/play.sh b/scripts/play.sh index 09b9b9ac..7d98c228 100755 --- a/scripts/play.sh +++ b/scripts/play.sh @@ -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). @@ -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: (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 diff --git a/scripts/play_party.sh b/scripts/play_party.sh index 6dca1803..8fa2f3d5 100755 --- a/scripts/play_party.sh +++ b/scripts/play_party.sh @@ -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: (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. diff --git a/servers/engine/tests/test_dm_narration_fallback.py b/servers/engine/tests/test_dm_narration_fallback.py index 498edc27..62fc68ba 100644 --- a/servers/engine/tests/test_dm_narration_fallback.py +++ b/servers/engine/tests/test_dm_narration_fallback.py @@ -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 diff --git a/skills/dungeon-master/reference/quest-generation.md b/skills/dungeon-master/reference/quest-generation.md index 859f9e6d..d6ed7878 100644 --- a/skills/dungeon-master/reference/quest-generation.md +++ b/skills/dungeon-master/reference/quest-generation.md @@ -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. diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index b2258bc5..831c0b14 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -26,10 +26,25 @@ const _TOOLS_ALT = DM_ENGINE_TOOLS.join("|"); // Header line of the right-panel Director advisory if it ever bleeds into prose. const _GM_ADVISORY_HEADER = /^\s*(?:#{1,6}\s*)?(?:\**\s*)?GM\s+Advisory\b/i; const _ADVISORY_SUBTITLE = /^\s*what the campaign owes the story\b/i; +// The scene-debt KIND labels (mirrors servers/engine/director.py debt kinds; the GM-Advisory +// panel renders them with underscores→spaces, e.g. "npc_introduced_silent" → "npc introduced +// silent"). #357 (nb3): the WHOLE advisory panel string leaked to the player — +// "npc introduced silent NPC 'Vanos' has been introduced but hasn't spoken — give them a line +// or record their first memory with remember." A line that LEADS with one of these debt-kind +// labels is GM bookkeeping, never fiction. +// HIGH-CONFIDENCE only: the space-rendered form is limited to "npc introduced silent" (the +// nb3 leak; never fiction) — the other kinds' nudge BODIES are already caught below, and their +// space-forms ("quest stalled", "due consequence") could brush legitimate prose. The raw +// underscore tokens never occur in prose, so all of those are safe to list verbatim. +const _DEBT_KIND_LABEL = + "(?:npc introduced silent|hook_untracked|npc_introduced_silent|quest_stalled|" + + "choice_without_outcome|due_consequence|thread_pressure)"; // The debt-nudge family (mirrors servers/engine/director.py::_nudge) — DM-facing // imperatives that name an engine tool / structural-debt action. const _ADVISORY_DIRECTIVE = new RegExp( "(?:" + + // #357: a line LED by a scene-debt kind label (optionally back-ticked) — the panel leak. + "^\\s*[`'\"]?\\s*" + _DEBT_KIND_LABEL + "\\b|" + "\\b(?:has been introduced but hasn'?t spoken)\\b|" + "\\b(?:untracked hook)\\b.*\\bcall\\b|" + "\\bquest\\b.*\\bhas stalled\\b|" + @@ -181,7 +196,6 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { {}; const campaignId = activeCampaign.campaign_id || state?.activeCampaign || activeCampaign.id || ""; const [surface, setSurface] = React.useState(null); - const [advisory, setAdvisory] = React.useState(null); const [surfaceStatus, setSurfaceStatus] = React.useState("loading"); const demoLog = []; const [input, setInput] = React.useState(""); @@ -249,19 +263,13 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { if (isCancelled()) return; setSurfaceStatus(error?.message || "unavailable"); } - // GM Advisory (Campaign Director #72): a separate, best-effort fetch so a journal - // hiccup never blocks the table. Surfaces the top structural debt during play. - try { - const advResp = await fetch(`/journal-surface${query}`, { cache: "no-store" }); - if (advResp.ok) { - const advPayload = await advResp.json(); - if (!isCancelled()) setAdvisory(advPayload?.directorAdvisory || null); - } - } catch (error) { /* advisory is non-critical; keep last good */ } + // #357 (nb3): the GM Advisory (Campaign Director #72) fetch was removed here — its only + // consumer was the GM-bookkeeping panel that leaked into the player's live-play sidebar + // (see the RIGHT column below). The director advisory still loads on the journal surface. // NOTE (#340): the live DM-narration /chat tail used to be polled HERE, but it's now owned by // the app-level useLiveSession hook (app.jsx) so a beat that lands while the player is on // another screen still gets ingested and the narrating indicator clears correctly. ScreenTable - // only loads its own surface + advisory; the chronicle's chat beats arrive via the `liveSession` + // only loads its own surface; the chronicle's chat beats arrive via the `liveSession` // prop. (Engine stays sole writer — this is purely where the read-poll lives.) }, [campaignId, activeCampaign.source, activeCampaign.runId]); @@ -564,30 +572,14 @@ function ScreenTable({ onNavigate, state, setState, liveSession }) { - {/* RIGHT — Quests + Quick stash + GM tools */} + {/* RIGHT — Quests + Quick stash + Encounter (GM Advisory removed #357 — see below) */}
- {advisory && Array.isArray(advisory.debts) && advisory.debts.length > 0 && ( - {/* #320: tighter panel padding */} - {advisory.total_debts} : null}>GM Advisory -
What the campaign owes the story.
-
-
- {(advisory.debts[0].kind || "debt").replace(/_/g, " ")} -
-
- {advisory.debts[0].nudge} -
-
-
- -
-
- )} + {/* #357 (nb3): the "GM Advisory" panel (Campaign Director debts — "what the campaign + OWES the story", with the raw debt-kind label + a tool-naming nudge like "…record + their first memory with remember") is GM/director bookkeeping, NOT player-facing + content. It leaked into a newbie's live-play sidebar here on the PLAYER table screen. + The advisory still renders on the journal/Director surface (screen-journal.jsx) where + a director framing is appropriate; it is removed from the player's live-play view. */} {/* #320: tighter panel padding */} Quests{/* #320: "Active Quests" → "Quests" */} diff --git a/viewer/tests/test_sanitize_narration.py b/viewer/tests/test_sanitize_narration.py index 17766b02..2db59b0d 100644 --- a/viewer/tests/test_sanitize_narration.py +++ b/viewer/tests/test_sanitize_narration.py @@ -143,6 +143,34 @@ def test_335_advisory_and_tool_guards_still_apply(self): self.assertIn("The gate groans open", out["bare_tool"]) self.assertNotIn("remember(", out["bare_tool"]) + def test_357_gm_advisory_panel_leak_is_stripped(self): + # #357 (nb3): the WHOLE GM-Advisory panel string (the rendered debt-kind label + + # the tool-naming nudge) leaked into the player's live-play view. A narration line + # led by a scene-debt KIND label (space-rendered "npc introduced silent" or the raw + # underscore tokens) is GM bookkeeping and must be stripped; legitimate prose using + # the words "silent"/"consequence"/"npc" as ordinary language must survive. + cases = { + "panel_leak": ( + "npc introduced silent NPC 'Vanos' has been introduced but hasn't spoken — " + "give them a line or record their first memory with remember." + ), + "raw_kind_npc": "npc_introduced_silent: Vanos has not spoken yet.", + "raw_kind_consequence": "due_consequence is overdue — call check_consequences.", + "raw_kind_quest": "quest_stalled — weave an advancement beat.", + # false-positive guards: ordinary fiction using these words must pass verbatim + "legit_silent": "The hall falls silent as the duke rises.", + "legit_silent_figure": "A silent figure waits in the doorway, hood drawn.", + "legit_consequence": "The consequence of his oath weighed on him as he climbed.", + } + out = self._sanitize_many(cases) + self.assertEqual(out["panel_leak"], "") + self.assertEqual(out["raw_kind_npc"], "") + self.assertEqual(out["raw_kind_consequence"], "") + self.assertEqual(out["raw_kind_quest"], "") + self.assertEqual(out["legit_silent"], cases["legit_silent"]) + self.assertEqual(out["legit_silent_figure"], cases["legit_silent_figure"]) + self.assertEqual(out["legit_consequence"], cases["legit_consequence"]) + def test_scaffolding_line_inside_a_multiline_beat_is_dropped(self): beat = ( "Rain hammers the cobbles outside the Elfsong.\n"