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>
This commit is contained in:
slaguru666
2026-08-19 19:25:20 +01:00
co-authored by Claude Opus 5
parent 24b1aaa682
commit 973b4a53aa
154 changed files with 1370 additions and 772 deletions
+24 -12
View File
@@ -547,6 +547,10 @@ async function writePack(packName, entries) {
// guards cannot tell whether a rule is READ; these tests can.
execFileSync(process.execPath, [new URL("check-behaviour.mjs", import.meta.url).pathname],
{ stdio: "inherit" });
// And refuse a scenario that tells the GM to roll a skill nobody has. THROUGH TRAIN
// asked for Ride through several drafts and a playtest; there is no Ride skill.
execFileSync(process.execPath, [new URL("check-scenarios.mjs", import.meta.url).pathname],
{ stdio: "inherit" });
} catch { console.error("build aborted: pre-build checks failed"); process.exit(1); }
}
@@ -918,16 +922,19 @@ function cullandScene() {
for (const pg of handouts.pages) pg.ownership = { default: 2 };
const showJournal = imageJournal("THROUGH TRAIN — Show the players", "tt_show", [
{ name: "1 · Mercy Draw, now", src: "art/scenes/tt_01_draw.webp" },
{ name: "1 · The Bureau", src: "art/scenes/tt_11_bureau.webp" },
{ name: "2 · Stores, and the 1881 issue", src: "art/scenes/tt_02_stores.webp" },
{ name: "3 · The grade on the south bench", src: "art/scenes/tt_03_bench.webp" },
{ name: "4 · Cordera, 1881", src: "art/scenes/tt_04_cordera.webp" },
{ name: "5 · The water tower — boarding", src: "art/scenes/tt_05_boarding.webp" },
{ name: "6 · Inside car three, between passes", src: "art/scenes/tt_06_car.webp" },
{ name: "7 · The four minutes", src: "art/scenes/tt_08_gunfight.webp" },
{ name: "8 · The payroll box, car four", src: "art/scenes/tt_09_payroll.webp" },
{ name: "9 · The gap between the cars", src: "art/scenes/tt_10_couple.webp" },
{ name: "10 · Afterwards, the register", src: "art/scenes/tt_07_after.webp" }
{ name: "3 · Mercy Draw, now", src: "art/scenes/tt_01_draw.webp" },
{ name: "4 · The north bench, 1881", src: "art/scenes/tt_12_northbench.webp" },
{ name: "5 · Cordera, 1881", src: "art/scenes/tt_04_cordera.webp" },
{ name: "6 · Six men across the street", src: "art/scenes/tt_13_standoff.webp" },
{ name: "7 · The grade on the south bench", src: "art/scenes/tt_03_bench.webp" },
{ name: "8 · The water tower — boarding", src: "art/scenes/tt_05_boarding.webp" },
{ name: "9 · Inside car three, between passes", src: "art/scenes/tt_06_car.webp" },
{ name: "10 · The four minutes", src: "art/scenes/tt_08_gunfight.webp" },
{ name: "11 · The payroll box, car four", src: "art/scenes/tt_09_payroll.webp" },
{ name: "12 · The gap between the cars", src: "art/scenes/tt_10_couple.webp" },
{ name: "13 · Afterwards, the register", src: "art/scenes/tt_07_after.webp" }
]);
const advId = idFor("adventure:through_train");
@@ -935,7 +942,7 @@ function cullandScene() {
_id: advId,
name: "THROUGH TRAIN — The Mercy Draw Crossing",
img: IC("actor_case") ?? ICON.caseFile ?? ICON.actor,
caption: "The crossing starter. Three and a half hours, and nothing modern goes through.",
caption: "The crossing starter. Four hours in five acts, and nothing modern goes through.",
description:
"<p><em>A train has been running the same four miles of track since the third of "
+ "November, 1881. It has never stopped. There are sixty-one people on it and they "
@@ -947,9 +954,14 @@ function cullandScene() {
+ "<p>The running is what holds the crossing open. The agents have to board a train "
+ "that cannot stop, get sixty-one people off it, and decide what to do about the "
+ "arithmetic when they find they cannot.</p>"
+ "<p><strong>Five acts, four hours.</strong> An induction at the Bureau; a crossing "
+ "that puts them where the paperwork says the railway is, which is an empty bench; "
+ "an investigation to find the line at all; six unpaid miners in a Cordera street "
+ "who think they are company detectives; forty seconds of slow track at a water "
+ "tower; and four minutes that have run about two million times.</p>"
+ "<p>Also the case that teaches Standing Order 9: <strong>nothing modern goes "
+ "through</strong>. Seven GM pages, six handouts including the survey that started "
+ "it, the case file, four of the people aboard, and seven plates to show.</p>",
+ "through</strong>. Eight GM pages, ten handouts including the survey that started "
+ "it, the case file, fourteen of the cast, and thirteen plates to show.</p>",
actors: castDocs,
items: [],
journal: [
+108
View File
@@ -0,0 +1,108 @@
/**
* 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`);
File diff suppressed because it is too large Load Diff