From ecd7f90bc6a15014f0a35c25345cee59d087c307 Mon Sep 17 00:00:00 2001 From: Eva Date: Wed, 3 Jun 2026 08:20:25 +0700 Subject: [PATCH] Clean inline markdown from chronicle narration --- viewer/openworlds/screen-table.jsx | 23 +++++++++--- viewer/tests/test_sanitize_narration.py | 47 +++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/viewer/openworlds/screen-table.jsx b/viewer/openworlds/screen-table.jsx index 02f842f1..2d83bb06 100644 --- a/viewer/openworlds/screen-table.jsx +++ b/viewer/openworlds/screen-table.jsx @@ -24,8 +24,8 @@ const DM_ENGINE_TOOLS = [ // A line whose ENTIRE content is a GM-advisory directive or a bare tool reference. 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; +const _GM_ADVISORY_HEADER = /^\s*(?:#{1,6}\s*)?(?:[*_`'"]+\s*)?GM\s+Advisory\b/i; +const _ADVISORY_SUBTITLE = /^\s*(?:[*_`'"]+\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 — @@ -44,7 +44,7 @@ const _DEBT_KIND_LABEL = 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|" + + "^\\s*[`'\"*_]*\\s*" + _DEBT_KIND_LABEL + "(?:\\b|[`'\"*_]+\\s*[:;,.!?-]?)|" + "\\b(?:has been introduced but hasn'?t spoken)\\b|" + "\\b(?:untracked hook)\\b.*\\bcall\\b|" + "\\bquest\\b.*\\bhas stalled\\b|" + @@ -59,7 +59,7 @@ const _ADVISORY_DIRECTIVE = new RegExp( // A line that is ESSENTIALLY just an engine-tool token (optionally back-ticked, // optionally with a trivial call signature) — e.g. "`remember`", "remember(...)". const _BARE_TOOL_LINE = new RegExp( - "^\\s*[`'\"(]*\\s*(?:" + _TOOLS_ALT + ")\\s*(?:\\([^)]*\\))?\\s*[`'\")]*\\s*[.;:]?\\s*$", + "^\\s*[`'\"(_*]*\\s*(?:" + _TOOLS_ALT + ")\\s*(?:\\([^)]*\\))?\\s*[`'\")_*]*\\s*[.;:]?\\s*$", "i", ); function _isInternalLine(line) { @@ -156,11 +156,24 @@ function _stripScaffoldingSentences(line) { .replace(/\s*[;,]\s*$/, ".") .trim(); } +function _stripInlineMarkdown(line) { + if (typeof line !== "string" || !line) return line; + return line + .replace(/`([^`\n]+)`/g, "$1") + .replace(/\*\*([^*\n]+)\*\*/g, "$1") + .replace(/__([^\n]+?)__/g, "$1") + .replace(/\*([^*\n]+)\*/g, "$1") + .replace(/(^|[^A-Za-z0-9])_([^_\n]+)_([^A-Za-z0-9]|$)/g, "$1$2$3"); +} 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… + // The Chronicle renders text, not Markdown. Keep useful provider content like roll + // numbers while dropping inline emphasis/code markers that otherwise show as raw syntax. + .map((line) => _stripInlineMarkdown(line)) + // #347: then excise any scaffolding sentences embedded in a line after removing + // formatting markers that could otherwise split a tally or stage-direction phrase. .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 diff --git a/viewer/tests/test_sanitize_narration.py b/viewer/tests/test_sanitize_narration.py index 2db59b0d..20a048be 100644 --- a/viewer/tests/test_sanitize_narration.py +++ b/viewer/tests/test_sanitize_narration.py @@ -183,6 +183,53 @@ def test_scaffolding_line_inside_a_multiline_beat_is_dropped(self): self.assertNotIn("cold open", out.lower()) self.assertNotIn("meeting beat", out.lower()) + def test_inline_markdown_emphasis_is_plain_text_for_players(self): + # Live Codex-provider proof on 43e62e5 produced a roll beat that rendered + # the raw Markdown marker in the Chronicle: "settles on **11**." The + # player-facing projection should keep the number while dropping the + # formatting syntax; React will not render Markdown for us. + cases = { + "bold_roll": "The die settles on **11**.", + "italic_note": "The lute gives one *uneasy* hum.", + "underscore_italic_note": "The lute gives one _uneasy_ hum.", + "inline_code": "The clue is marked `violet wax` near your boot.", + } + out = self._sanitize_many(cases) + self.assertEqual(out["bold_roll"], "The die settles on 11.") + self.assertEqual(out["italic_note"], "The lute gives one uneasy hum.") + self.assertEqual(out["underscore_italic_note"], "The lute gives one uneasy hum.") + self.assertEqual(out["inline_code"], "The clue is marked violet wax near your boot.") + + def test_markdown_wrapped_internal_lines_still_do_not_render(self): + cases = { + "bold_tool": "**remember**", + "underscore_tool": "_remember_", + "italic_subtitle": "*What the campaign owes the story*", + "bold_advisory": "**GM Advisory:** call remember for the silent NPC.", + "underscore_advisory": "_GM Advisory:_ call remember for the silent NPC.", + "underscore_kind": "_npc_introduced_silent_: Vanos has not spoken yet.", + "dunder_kind": "__npc_introduced_silent__: Vanos has not spoken yet.", + } + out = self._sanitize_many(cases) + self.assertEqual(out["bold_tool"], "") + self.assertEqual(out["underscore_tool"], "") + self.assertEqual(out["italic_subtitle"], "") + self.assertEqual(out["bold_advisory"], "") + self.assertEqual(out["underscore_advisory"], "") + self.assertEqual(out["underscore_kind"], "") + self.assertEqual(out["dunder_kind"], "") + + def test_markdown_wrapped_scaffolding_tallies_are_still_stripped(self): + cases = { + "wrapped_count": "The lock holds after **three** failed social checks.", + "wrapped_result": "The lock holds after three **failed** social checks.", + "wrapped_noun": "The lock holds after three failed **social checks**.", + } + out = self._sanitize_many(cases) + for value in out.values(): + with self.subTest(value=value): + self.assertEqual(value, "The lock holds.") + if __name__ == "__main__": unittest.main()