Files
RingBRP/tools/check-scenarios.mjs
T
slaguru666andClaude Opus 5 daf77f4aac R-293: a desk playtest is a record, not a scenario with rolls in it
Counting the readers that had broken on their own format turned up one nothing had
caught. docs/scenarios holds scenarios, eight playtest records and two art prompt
sheets, and every guard treated all three as scenarios -- harmless for citations
and skill spellings, false for reachability. Six quotations across passes 4, 6 and
7 were checked as live rolls, so a record of a session already played could fail
the build over a skill nobody can reach. Planting Science (Physics) in pass 4 fails
before the split and passes after; the same skill in CLEAN_GROUND still fails.

Classified by the document's own H1, not its filename, because tools/scenario-*
naming is what swept a tools file into this corpus in R-268. An unclassified
document is fatal: an allowlist that silently drops what it does not recognise
would take a new scenario out of reachability checking on the day it was written.

89 rolls across 18 files becomes 83 across 8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:05:03 +01:00

202 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* check-scenarios — every skill a scenario tells the GM to roll must exist.
*
* THROUGH TRAIN asked for "[CUS: Ride, then Athletics]" through several drafts and a
* playtest. There is no Ride skill in this game and there never has been, so that whole
* boarding route was an instruction to roll a thing nobody has. Nothing caught it,
* because the four other guards check rules, kits, language and templates — and a
* scenario is prose.
*
* This reads every scenario source and GM document, pulls out the bracketed mechanic
* tags, and resolves each named skill against SKILL_CATALOGUE. A scenario that names a
* skill the catalogue does not carry fails the build.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { SKILL_CATALOGUE } from "./content.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const KNOWN = new Set(SKILL_CATALOGUE.map(r => (r[4] || r[0]).toLowerCase()));
// A tag may name a family without its specialisation — "Firearm", "Melee Weapon" — which
// is how a GM speaks and is not an error.
for (const r of SKILL_CATALOGUE) {
const name = r[4] || r[0];
const bare = name.replace(/\s*\([^)]*\)\s*$/, "").trim().toLowerCase();
if (bare) KNOWN.add(bare);
}
/** Words that appear inside a tag but are never the skill being rolled. */
const NOISE = new Set([
"average", "easy", "difficult", "hard", "formidable", "impossible",
"and", "then", "or", "at", "on", "per", "each", "the", "a", "an",
"only", "a fumble is a fall", "no roll", "automatic", "opposed",
"with", "against", "for", "to", "vs", "versus", "first", "second",
// the scenario template's own placeholder, and the catch-all phrasing
"skill", "any applicable skill", "applicable skill", "any skill"
]);
/**
* Pull the candidate skill names out of one tag body.
* "Athletics — average; only a fumble is a fall" -> ["Athletics"]
* "Spot or Engineering" -> ["Spot", "Engineering"]
* "Ride, then Athletics" -> ["Ride", "Athletics"]
* "Persuade at −30% on the first pass" -> ["Persuade"]
*/
export function skillsIn(body) {
// everything before the first descriptive break belongs to the roll itself
const head = body.split(/[—–—;:]|\bat\b|\bwhen\b|\bif\b|\bfor\b|\bagainst\b/u)[0];
return head
.split(/\bor\b|\bthen\b|\band\b|[,/]/u)
.map(x => x.replace(/[.…]+$/, "").replace(/\s+/g, " ").trim())
.filter(x => x && !NOISE.has(x.toLowerCase()) && !/^\d/.test(x));
}
/* [CUS: ...] as written in the markdown, and the HTML-escaped form in the generators.
Case and spacing are tolerated. The literal `\[CUS:` was measured against the obvious
near-misses and `[cus: Spot]`, `[Cus: Spot]`, `[CUS : Spot]` and `[ CUS: Spot]` all read as
nothing — and reading as nothing is the bad direction here, because this function is what
check-scenarios validates skills through and what check-rollable checks reachability
through. A beat spelled any of those four ways was checked by neither guard while both
printed OK. Same fail-open as R-289's `<!-- cast : -->` and check-cited's three spellings of
`cite:`; found the same way, by putting the spellings to the reader instead of to the tree. */
/* THE OPENING OF A TAG, AS ONE STRING, because that is the half that drifted. Two guards read
`[CUS: ...]` and their BODIES differ for good reasons — this one captures the whole tag and
lets skillsIn split it, outcome-coverage stops at the em-dash because the outcome text
follows. Sharing the whole pattern would be wrong. Sharing where a tag STARTS is not: the
literal `\[CUS:` was copied into both files, R-290 widened this one, and for three spellings
the two guards then disagreed about whether a tag existed at all — one reading it, the other
calling it unparseable. Compose your own body onto this and that cannot happen again.
R-291's cross-reader test still stands behind it; this is the prevention, that is the check. */
export const TAG_OPEN = String.raw`\[\s*CUS\s*:\s*`;
/** A fresh matcher each call — a shared /g regex carries `lastIndex` between callers. */
export const tagRe = (body = String.raw`([^\]]+)\]`) => new RegExp(TAG_OPEN + body, "gi");
export function tagsIn(text) {
const out = [];
for (const m of text.matchAll(tagRe())) out.push(m[1]);
return out;
}
export function stripHtml(s) {
// The generators build these strings by concatenation, so a single tag is routinely
// split across two source lines. Rejoin before anything tries to read it.
return s.replace(/"\s*\+\s*"/g, "")
.replace(/<[^>]+>/g, " ")
.replace(/&mdash;/g, "—").replace(/&ndash;/g, "–")
.replace(/&minus;/g, "−").replace(/&amp;/g, "&")
.replace(/&frac12;/g, "").replace(/&ldquo;|&rdquo;/g, '"');
}
/**
* Every scenario source and GM document, as [label, absolute path].
*
* Exported because check-rollable reads exactly the same corpus, and the corpus rule is
* a definition like any other — two guards disagreeing about what counts as a scenario
* is how a scenario stops being checked without anybody noticing.
*/
export function scenarioFiles() {
return [
...readdirSync(join(ROOT, "tools")).filter(f => f.startsWith("scenario-"))
.map(f => ["tools/" + f, join(ROOT, "tools", f)]),
...readdirSync(join(ROOT, "docs", "scenarios")).filter(f => f.endsWith(".md"))
.map(f => ["docs/scenarios/" + f, join(ROOT, "docs", "scenarios", f)])
];
}
/* WHAT A DOCUMENT IS, not what it is named. The corpus above holds three kinds and only one
of them is a thing a GM runs: the scenarios, the desk playtest records, and the art prompt
sheets. Every guard reading the corpus has treated all three as scenarios, which was
harmless for citations and skill spellings and false for reachability — a playtest report
quoting `[CUS: Anomaly Lore — what a peg is]` to say which beat a roll hung off had that
quotation checked as a live roll, and would have failed the build over a skill nobody can
reach in a document that is a record of a session already played.
Classified by the document's own H1 rather than its filename, because `tools/scenario-*`
naming is what swept a tools file into this corpus in R-268. A generator has no H1 and is
a scenario by construction.
AN UNCLASSIFIED FILE IS FATAL. An allowlist that silently drops what it does not recognise
would take a new scenario out of reachability checking on the day it was added, which is
the fail-open this suite has spent the session removing. Same rule as powers.mjs: a thing
the tools read must be classified, and the build says so if it is not. */
const PLAYABLE_H1 = /^(Crossing case|Crossing starter|Prologue)\b/;
const RECORD_H1 = /desk playtest \d|art(work)? prompt/i;
/** [label, path, kind] for every corpus file; kind is "playable" or "record". */
export function classifiedFiles() {
const unknown = [];
const out = scenarioFiles().map(([label, abs]) => {
if (label.startsWith("tools/scenario-")) return [label, abs, "playable"];
const h1 = (readFileSync(abs, "utf8").match(/^#\s+(.+)$/m) ?? [, ""])[1].trim();
if (PLAYABLE_H1.test(h1)) return [label, abs, "playable"];
if (RECORD_H1.test(h1)) return [label, abs, "record"];
unknown.push(`${label} — its heading reads "${h1.slice(0, 70)}"`);
return [label, abs, null];
});
if (unknown.length) {
/* Named for the corpus, not for a guard: this runs inside whichever guard asked first,
and a message saying "check-scenarios" while check-rollable is on screen sends the
reader to the wrong file. */
console.error(`scenario corpus: FAILED — ${unknown.length} document(s) in docs/scenarios are `
+ `neither a scenario nor a record, so nothing knows whether their rolls are live:`);
unknown.forEach(u => console.error(` ${u}`));
console.error(` A scenario's heading starts "Crossing case", "Crossing starter" or `
+ `"Prologue"; a record's says "desk playtest N" or "art prompt". Guessing would put a `
+ `new scenario outside reachability checking on the day it was written.`);
process.exit(1);
}
return out;
}
/** The corpus files a GM actually runs — the only ones whose rolls must be reachable. */
export function playableFiles() {
return classifiedFiles().filter(([, , kind]) => kind === "playable").map(([l, a]) => [l, a]);
}
/** Lines that say a skill does NOT exist are documentation, not an instruction. */
export function scenarioText(raw) {
return stripHtml(raw)
.split("\n")
.filter(l => !/there is no .* skill|NO RIDE SKILL/i.test(l))
.join("\n");
}
classifiedFiles(); // every corpus document must be a scenario or a record; fatal if not
const files = scenarioFiles();
const problems = [];
let tagCount = 0;
for (const [label, path] of files) {
const raw = readFileSync(path, "utf8");
const text = scenarioText(raw);
for (const body of tagsIn(text)) {
tagCount++;
for (const cand of skillsIn(body)) {
if (!KNOWN.has(cand.toLowerCase())) {
problems.push(`${label}: [CUS: ${body.trim()}] — "${cand}" is not a skill`);
}
}
}
}
const invokedDirectly = process.argv[1]
&& import.meta.url === pathToFileURL(process.argv[1]).href;
if (invokedDirectly) run();
function run() {
if (problems.length) {
console.error("check-scenarios: FAILED");
for (const p of [...new Set(problems)]) console.error(" " + p);
process.exit(1);
}
console.log(`check-scenarios: OK — ${files.length} scenario files, ${tagCount} mechanic `
+ `tags, every skill named resolves against ${SKILL_CATALOGUE.length} catalogue entries`);
}