Files
station-master/scripts/build-card-reference.ts
T
Jesse.MarkowitzandClaude Opus 5 e734481d65 v0.7.9.3 — the reference says what only the code knows, and saves get a rule
The generated card reference now carries the half TODO #15a said was the
point of generating it: every Enhancement's `live` / `dormantSolo` /
`unbuilt` status — whether its printed effect actually resolves yet — and
the opponent-directed Action and Space-use cards, none of which is dealt
in any deck. A transcription cannot carry either fact.

Card counts are removed throughout, as ruled: they move with play balance
so a document printing them is stale on the next retune. Where a count
matters it is a yes/no "is this dealt at all", which is a fact about the
design rather than the current tuning. #88 closes with it — it asked
whether `card-reference.md`'s industry rows were stale, deliberately
without rewriting them since Laborer counts are a balance decision. They
are; nothing in the engine changed; that file is simply no longer where
anyone looks. The balance question it guarded is #70.

Save compatibility becomes a general rule in `README.md` § Design notes
rather than a fact restated per version: a save is a list of moves and
reopens by being re-played through the CURRENT rules, so any change that
makes a once-legal move illegal stops an older one there — a deck change
being the likeliest breaker. It fails safe every time. #40 generalised,
#32's version-specific note dropped, #52 carries the ruling that
versioned replays are a post-1.0 question.

#94 opened: a Red Flag set out at an Office's Limits holds the next train
from that side and is drawn nowhere. `DivisionView`'s office node has no
`redFlag` field and `board-svg.ts` never mentions one, so after the single
log line announcing it there is nothing on screen. Same class as Gitea#21
and #22, and already on the common board's step 1 list.

No engine change. 897 tests pass, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E3Qk7uresKCHksdZajXCLg
2026-09-07 14:50:11 -04:00

269 lines
12 KiB
TypeScript

/**
* Generate `docs/rules/as-built.md` — what the cards say, as the code actually has them.
*
* WHY THIS IS GENERATED RATHER THAN WRITTEN.
*
* Every other file in `docs/rules/` is a historical record and says so: `rules-v0.1.md` is a
* faithful transcription of the prototype PDFs, `open-questions.md` is the gap tracker,
* `rules-v0.2.md` and `card-reference.md` both carry SUPERSEDED banners. None of them describes the
* game as built, and none of them should be edited to — the record is worth more intact than
* patched.
*
* So there was no current reference at all, and `content.ts` spent several releases pointing at
* `card-reference.md` as "the place that now carries what the cards say" while that file's own
* banner said "do not use its numbers". A reader following the code's advice landed on the v0.4.5
* deck: twelve numbered trains, "3 / 4 Mail-Express, 3 coaches", against a `content.ts` whose train
* 3 is the Express with two freight cars and a per-location freight rule.
*
* A HAND-WRITTEN REPLACEMENT WOULD HAVE DRIFTED THE SAME WAY, and for the same reason: nothing
* fails when a table falls behind a constant. So the reference is emitted from the same exported
* catalogues the engine instantiates from, and `test/card-reference.test.ts` re-runs this generator
* and asserts the checked-in file matches byte for byte. Change a card face and the suite goes red
* until the doc is regenerated — which is the only mechanism this project has found that keeps a
* document honest.
*
* `npm run build:cards` writes it. Nothing at runtime reads it; it is for people.
*/
import { writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
ACTION_CARDS, ALL_TRAINS, ENHANCEMENT_CARDS, ENHANCEMENT_RULES, EXTRA_TRAINS, INDUSTRY_PROFILES,
LIMITS_SUPPLY, MAINLINE_DECK, MAINLINE_MODIFIER_CARDS, MAINLINE_PROFILES, MANEUVER_CARDS,
MODIFIER_PROFILES, OFFICE_PROFILES, SPACE_USE_CARDS, TIMETABLED_TRAINS, TRACK_CARDS,
WHISTLE_POST_SUPPLY, consistSize, isOpponentOnly, mainlineDescription,
} from '../src/engine/content.ts';
import type { ConsistSpec, SimpleCard, TrainProfile, TrainRules } from '../src/engine/content.ts';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
/** Title Case a camelCase key, so `oneFreightPerLocation` reads as a rule rather than an identifier. */
const words = (k: string): string => k.replace(/([A-Z])/g, ' $1').toLowerCase().trim();
const consistOf = (c: ConsistSpec): string => {
const parts: string[] = [];
if (c.freight > 0) {
const kinds = c.freightTypes ? c.freightTypes.join(' or ') : 'freight';
parts.push(`${c.freight} ${kinds}${c.freight === 1 ? '' : c.freightTypes ? 's' : ''}`);
}
if (c.coach > 0) parts.push(`${c.coach} coach${c.coach === 1 ? '' : 'es'}`);
if (c.caboose > 0) parts.push(`${c.caboose} caboose`);
if (!parts.length) return 'engine only';
const n = consistSize(c);
// "Empties only" qualifies the whole consist rather than adding to it, so it reads after the count.
return `${parts.join(' + ')} (${n} piece${n === 1 ? '' : 's'})${c.emptiesOnly ? ', empties only' : ''}`;
};
const rulesOf = (r: TrainRules): string => {
const out: string[] = [];
for (const [k, v] of Object.entries(r)) {
if (k === 'note' || v === false || v === undefined) continue;
out.push(words(k));
}
if (typeof r.note === 'string') out.push(`*"${r.note}"*`);
return out.length ? out.join('; ') : '—';
};
const trainRow = (t: TrainProfile): string =>
`| ${t.isExtra ? 'X' : ''}${t.number} | ${t.name} | ${t.speed} | ` +
`${t.direction === 'playerChoice' ? "player's choice" : t.direction} | ${consistOf(t.consist)} | ${rulesOf(t.rules)} |`;
const lines: string[] = [];
const w = (s = ''): void => void lines.push(s);
w('# Station Master — the cards as built');
w();
w('> **Generated from `src/engine/content.ts` by `scripts/build-card-reference.ts`. Do not edit by');
w('> hand** — `npm run build:cards` rewrites it, and `test/card-reference.test.ts` fails if this file');
w('> and the code disagree.');
w();
w('This is the one file in `docs/rules/` that describes the game **as it currently is**. Everything');
w('else in this directory is a historical record and marked as such: [`rules-v0.1.md`](rules-v0.1.md)');
w('transcribes the prototype PDFs, [`open-questions.md`](open-questions.md) tracks how the gaps in');
w('them were decided, and [`rules-v0.2.md`](rules-v0.2.md) and');
w('[`card-reference.md`](card-reference.md) both describe superseded card sets. Read those for the');
w('reasoning; read this for the numbers.');
w();
w('The engine instantiates from the same constants this is emitted from, so a disagreement between');
w('this page and the game is a bug in the generator, not a stale table.');
w();
w('**No card counts appear here, deliberately** (TODO #15a, Jesse 2026-08-22): the counts move with');
w('play balance, so a document that prints them is answering a question that will have a different');
w('answer next retune. Where a count matters it is a yes/no — whether the deck deals the card at');
w('all — which is a fact about the design rather than about the current tuning.');
w();
w('---');
w();
w('## Trains');
w();
w(`${TIMETABLED_TRAINS.length} timetabled and ${EXTRA_TRAINS.length} Extras, ${ALL_TRAINS.length} in all.`);
w('Odd numbers run west, even run east; a pair shares a class and is the same card face in two');
w('directions. A Crew Tray holds four pieces and a caboose counts toward the four, which is why no');
w('train with a caboose carries more than three revenue cars.');
w();
w('### Timetabled');
w();
w('| # | Class | Speed | Runs | Consist | Printed rules |');
w('| ---: | --- | --- | --- | --- | --- |');
for (const t of TIMETABLED_TRAINS) w(trainRow(t));
w();
w('### Extras');
w();
w('| # | Class | Speed | Runs | Consist | Printed rules |');
w('| ---: | --- | --- | --- | --- | --- |');
for (const t of EXTRA_TRAINS) w(trainRow(t));
w();
w('---');
w();
w('## Mainline cards');
w();
w('A card is divided into **regions**, and a train advances one region per Stage — so the regions a');
w('card prints are what it costs to cross. Where a train *enters* is what the rules move: a fast');
w('train on Hilly, a Heavy Grade with Helpers, or a card whose back region is a siding rather than');
w('part of the road all change the entry point rather than the card\'s length.');
w();
w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Sorts cars |');
w('| --- | ---: | ---: | --- | :---: | :---: |');
for (const m of MAINLINE_PROFILES) {
const ss = m.speedStarts ? `${m.speedStarts.fast} / ${m.speedStarts.slow}` : '—';
w(`| ${m.name} | ${m.regions} | ${m.defaultStart} | ${ss} | ${m.trainsMayPass ? 'yes' : '—'} | ${m.sortsCars ? 'yes' : '—'} |`);
}
w();
w(`The Mainline deck is ${MAINLINE_DECK.length} cards; the two Division Points are the fixed ends of`);
w('the Division and are not dealt. What each card does, in the words the game uses on screen:');
w();
for (const m of MAINLINE_PROFILES) w(`- **${m.name}** — ${mainlineDescription(m.kind)}`);
w();
w('---');
w();
w('## Office cards');
w();
w('Upgrades are strictly sequential — no skipping a tier — so a Terminal needs all three cards in');
w('order. Green slots are outbound passengers, red are inbound, and the design gives slots **equal**');
w('to Porters rather than one more.');
w();
w('| Office | Control point | Passenger facility | A/D tracks | Porters | Green | Red |');
w('| --- | :---: | :---: | ---: | ---: | ---: | ---: |');
for (const o of OFFICE_PROFILES) {
w(`| ${o.name} | ${o.isControlPoint ? 'yes' : '—'} | ${o.isPassengerFacility ? 'yes' : '—'} | ` +
`${o.adTracks} | ${o.porters} | ${o.passengerOut} | ${o.passengerIn} |`);
}
w();
w(`Whistle Posts are a fixed supply of ${WHISTLE_POST_SUPPLY} outside the deck, and Limits signs a`);
w(`supply of ${LIMITS_SUPPLY}.`);
w();
w('---');
w();
w('## Freight facilities');
w();
w('Each lists the car types it works, which way its traffic flows, and the industries it may not sit');
w('beside. **Every lockout pair is a producer and the consumer of the same commodity** — you may');
w('build one end of a chain or the other, never both, which is what forces traffic to run between');
w('districts rather than in circles inside one. No two of the same industry may share an Office Area,');
w('and that rule is enforced for every kind rather than repeated in each row.');
w();
w('| Industry | Cars | Flow | Green | Red | Laborers | Locked out with |');
w('| --- | --- | --- | ---: | ---: | ---: | --- |');
for (const f of INDUSTRY_PROFILES) {
const lo = f.lockouts.length
? f.lockouts.map((k) => INDUSTRY_PROFILES.find((p) => p.kind === k)?.name ?? k).join(', ')
: '—';
w(`| ${f.name} | ${f.carTypes.join(', ')} | ${f.flow} | ${f.baseOut} | ${f.baseIn} | ${f.baseLoaders} | ${lo} |`);
}
w();
w('---');
w();
w('## Modifier cards');
w();
w('Each sits beside a host and raises one of its capacities. `office` as a host means any Passenger');
w('Facility — so a modifier that adds a green slot to an Office adds nothing to a Whistle Post, which');
w('is not one.');
w();
w('| Modifier | Hosts | +Green | +Red | +Laborers | +Porters |');
w('| --- | --- | ---: | ---: | ---: | ---: |');
for (const m of MODIFIER_PROFILES) {
const hosts = m.hosts
.map((h) => (h === 'office' ? 'any Passenger Facility' : INDUSTRY_PROFILES.find((p) => p.kind === h)?.name ?? h))
.join(', ');
w(`| ${m.name} | ${hosts} | ${m.addOut || '—'} | ${m.addIn || '—'} | ${m.addLoaders || '—'} | ${m.addPorters || '—'} |`);
}
w();
w('---');
w();
w('## Track cards');
w();
w('Track is **in the Home Office deck** and is drawn and played like any other card — not a separate');
w('per-player supply. Operational Rail is the flag that says a train may stop on the card; a turnout');
w('may be run through but not stopped on.');
w();
w('| Track | Geometry | Hand | Operational rail | Move cost | Dealt |');
w('| --- | --- | --- | :---: | ---: | :---: |');
for (const t of TRACK_CARDS) {
w(`| ${t.name} | ${t.geometry} | ${t.hand} | ${t.isOperationalRail ? 'yes' : '—'} | ${t.moveCost} | ${t.copiesInDeck ? 'yes' : 'no'} |`);
}
w();
w('A row marked "no" is a shape the engine understands but the deck does not currently print.');
w();
w('---');
w();
w('## Enhancements');
w();
w('The column that only the implementation can fill in: **whether the printed effect actually');
w('resolves yet.** `live` is read during play; `dormantSolo` is implemented at the point of attack');
w('but the attack is an opponent-directed card held out of every deck, so nothing reaches it in a');
w('solitaire game; `unbuilt` means the effect is recorded and nothing reads it. A transcription');
w('cannot carry this column, which is the argument for generating the page rather than writing it.');
w();
w('| Enhancement | Placement | Requires | Effect resolves |');
w('| --- | --- | --- | :---: |');
for (const r of ENHANCEMENT_RULES) {
const card = ENHANCEMENT_CARDS.find((c) => c.key === r.key);
const needs = r.requiresOnSameCard
? `${r.requiresOnSameCard} on the same card`
: r.requiresInDistrict
? `${r.requiresInDistrict} in the district`
: '—';
w(`| ${card?.name ?? r.key} | ${r.placement} | ${needs} | **${r.effect}** |`);
}
w();
w('---');
w();
w('## Opponent-directed cards, and what answers them');
w();
w('**None of these is dealt in any deck today.** A card that can only be played at another player');
w('has no legal target in a solitaire game, and a defence with nothing to defend against is as dead');
w('a draw as the attack — so both halves are held out until the attacks are implemented. They are');
w('listed because they are the design, and because what a defence answers is the only record of why');
w('it exists.');
w();
const pvp = (title: string, cards: readonly SimpleCard[], category: string): void => {
w(`### ${title}${isOpponentOnly(category) ? ' — opponent-directed' : ''}`);
w();
w('| Card | Played on | Effect | Answers |');
w('| --- | --- | --- | --- |');
for (const c of cards) w(`| ${c.name} | ${c.placement} | ${c.effect} | ${c.answers ?? '—'} |`);
w();
};
pvp('Action cards', ACTION_CARDS, 'action');
pvp('Space-use cards', SPACE_USE_CARDS, 'spaceUse');
pvp('Maneuver cards', MANEUVER_CARDS, 'maneuver');
w('Mainline modifier cards, for completeness — these ARE dealt:');
w();
w('| Card | Played on | Effect | Answers |');
w('| --- | --- | --- | --- |');
for (const c of MAINLINE_MODIFIER_CARDS) w(`| ${c.name} | ${c.placement} | ${c.effect} | ${c.answers ?? '—'} |`);
w();
writeFileSync(join(root, 'docs/rules/as-built.md'), `${lines.join('\n')}\n`);
console.log(`built -> docs/rules/as-built.md (${lines.length} lines)`);