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>
202 lines
9.6 KiB
JavaScript
202 lines
9.6 KiB
JavaScript
/**
|
||
* 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(/—/g, "—").replace(/–/g, "–")
|
||
.replace(/−/g, "−").replace(/&/g, "&")
|
||
.replace(/½/g, "").replace(/“|”/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`);
|
||
}
|