Files
RingBRP/tools/check-scenarios.mjs
T
slaguru666andClaude Opus 5 973b4a53aa THROUGH TRAIN rewritten into five acts
Act One is now an induction at the Bureau, Act Two puts the players through the
crossing and makes them find the train, Act Three is a town-side obstacle, Act
Four is the boarding, Act Five is the four minutes. 4h00 in five acts, was 3h30
in three.

The restructure earns one thing the old shape could not: THE CROSSING OPENS WHERE
THE RECORD SAYS THE LINE IS, which is an empty bench of scrub on the wrong side of
the wash. The agents arrive where the paperwork put the railway, not where the
railway is, so the fraud is the ground under their feet and finding the train is
real work. Registry's file has this wrong and Bewley believes the file.

- Act Three: Rance Doyle and five unpaid miners take the agents for company
  detectives, and they are not wrong to. Resolves on what the players SAY - no
  roll makes it go away, no roll makes it a fight. Losing costs them the 1881
  issue, not their lives. His brother Tom is aboard and dies at minute two of
  every pass, which is the wiring between the acts
- new cast: Bewley, Prosser, Rance Doyle, Shard, Tom Doyle, Cordera man
- new handouts: the company notice, and the six names in Ambrose's hand
- 8 GM pages, 10 handouts, 15 actors, 13 plates

MEASURED, 400 runs each: the Act Three standoff is 0.4/4 hurt from cover and
1.25/4 in the open, both ending early - but FOUGHT TO A FINISH it puts 1.75 of 4
down and wipes the party in 24% of runs. The early stop is what makes the act
survivable, not the miners being weak. My first draft of that table was guessed
and wrong in all three rows.

FOUND AND FIXED: there is no Ride skill in this game and never has been. The
boarding set-piece has asked for "[CUS: Ride, then Athletics]" since it was
written, through a playtest, which is an instruction to roll a thing nobody has.
Horses are now handled by a local and the agent rolls Athletics.

NEW GUARD: check-scenarios resolves every [CUS: Skill] tag in every scenario
against SKILL_CATALOGUE and fails the build on a skill that does not exist.
Verified it fails on the real bug before wiring it in. Sixth guard.

NOTE: the three new plates (Bureau, north bench, standoff) are placeholders in
this commit - Midjourney is still rendering them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 19:25:20 +01:00

109 lines
4.4 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 } 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"]
*/
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));
}
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;
}
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, '"');
}
const files = [
...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)])
];
const problems = [];
let tagCount = 0;
for (const [label, path] of files) {
const raw = readFileSync(path, "utf8");
// A line that explicitly says a skill does NOT exist is documentation, not an instruction.
const text = stripHtml(raw)
.split("\n")
.filter(l => !/there is no .* skill|NO RIDE SKILL/i.test(l))
.join("\n");
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`);
}
}
}
}
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`);