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