/** * 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 { ALL_TRAINS, EXTRA_TRAINS, INDUSTRY_PROFILES, LIMITS_SUPPLY, MAINLINE_DECK, MAINLINE_PROFILES, MODIFIER_PROFILES, OFFICE_PROFILES, TIMETABLED_TRAINS, TRACK_CARDS, TRACK_IN_DECK, WHISTLE_POST_SUPPLY, consistSize, mainlineDescription, } from '../src/engine/content.ts'; import type { ConsistSpec, 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('---'); 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 | In deck |'); w('| --- | :---: | :---: | ---: | ---: | ---: | ---: | ---: |'); for (const o of OFFICE_PROFILES) { w(`| ${o.name} | ${o.isControlPoint ? 'yes' : '—'} | ${o.isPassengerFacility ? 'yes' : '—'} | ` + `${o.adTracks} | ${o.porters} | ${o.passengerOut} | ${o.passengerIn} | ${o.copiesInDeck || '—'} |`); } 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 | Copies |'); 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} | ${f.copies} |`); } 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 | Copies |'); 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 || '—'} | ${m.copies} |`); } 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 | In deck |'); w('| --- | --- | --- | :---: | ---: | ---: |'); for (const t of TRACK_CARDS) { w(`| ${t.name} | ${t.geometry} | ${t.hand} | ${t.isOperationalRail ? 'yes' : '—'} | ${t.moveCost} | ${t.copiesInDeck || '—'} |`); } w(); w(`${TRACK_IN_DECK} track cards are dealt in total. Rows showing no copies are shapes the engine`); w('understands but the deck does not currently print.'); w(); writeFileSync(join(root, 'docs/rules/as-built.md'), `${lines.join('\n')}\n`); console.log(`built -> docs/rules/as-built.md (${lines.length} lines)`);