/** * 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)`);