Files
RingBRP/tools/check-scenarios.mjs
T
slaguru666andClaude Opus 5 555fa761c0 check-rollable: guard eleven, because a real skill is not the same as a route
check-scenarios resolves every skill a scenario NAMES against the catalogue, which
is a different question from whether anybody present can roll it. CLEAN GROUND
shipped four commits with three clues gated on Track (base 10), Navigate (base 10)
and Science (Botany) — base 1, so the 1% floor was the whole of it — and every
guard passed, because all three are perfectly real skills. A desk playtest found
it by auditing the acts against the sheets the scenario casts. That audit is
mechanical, so it belongs in the build.

Two tiers. Corpus-wide, some roster agent must reach VIABLE for every skill any
scenario names. Per scenario, a document that DECLARES its cast is held to that
cast instead, and that is the tier that catches this defect class.

The cast is declared rather than inferred, and that is the interesting part. The
first version scraped pc_ keys out of the prose and swept up the substitutes named
in CLEAN GROUND's player-count scaling — a cast of nine instead of six, which put
Sandoval and his Track 35 in scope and made the guard pass the very bug it was
written for. A guard that guesses the cast is worse than none, because it reports
success. CLEAN GROUND now carries a cast comment and the guard reads it off the raw
text, since scenarioText strips HTML comments.

Verified load-bearing rather than assumed: re-injecting the original Track tag into
the real CLEAN_GROUND.md fails the guard, naming the skill, the base chance and the
declared cast. Worth recording that the corpus-wide tier would never have caught
it — Lindqvist trains Botany at 40, so it is rollable by the roster and simply not
by the six who were cast. The first fixture test passed for that reason and misled
me; only the declared-cast tier finds this.

VIABLE is 25 and is justified, not picked: it is the commonest base chance in the
catalogue, what an untrained agent brings to Spot, Listen or Brawl, so it is the
level the game itself treats as worth attempting. Below it a clue is not gated, it
is buried.

check-scenarios exports its tag parser rather than growing a second copy, behind
the invokedDirectly pattern simulate.mjs already uses; a duplicated parser is
exactly what this repository's one standing law forbids. update-readme then caught
me fairly — it cross-checks the advertised guard list against npm run check — so
the guard is registered there too and the README advertises eleven in all three
places.

No REVIEW_LOG entry: the log is clean at R-260 and is being appended to every few
minutes by concurrent work, so the end of that file is the likeliest place to
collide. Left for whoever next touches it.

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

131 lines
5.0 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));
}
export function tagsIn(text) {
// [CUS: ...] as written in the markdown, and the HTML-escaped form in the generators
const out = [];
for (const m of text.matchAll(/\[CUS:\s*([^\]]+)\]/g)) 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)])
];
}
/** 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");
}
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`);
}