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
23 changes: 18 additions & 5 deletions viewer/openworlds/screen-table.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand All @@ -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|" +
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions viewer/tests/test_sanitize_narration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading