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>
131 lines
5.0 KiB
JavaScript
131 lines
5.0 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));
|
||
}
|
||
|
||
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(/—/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)])
|
||
];
|
||
}
|
||
|
||
/** 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`);
|
||
}
|