/** * Generate the card tables inside `docs/home-deck.md` and `docs/mainline-deck.md`. * * WHY GENERATED RATHER THAN WRITTEN. Nothing fails when a hand-written table falls behind a * constant, so the tables are 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 docs * match. Change a card face and the suite goes red until the docs are regenerated. * * EACH TABLE LANDS UNDER THE SECTION IT BELONGS TO, between a marker pair the deck documents carry: * * …generated… * * Everything around the markers is hand-written and is never touched. Only the Mainline card table * goes to `mainline-deck.md`; every other table belongs to the Home Office deck. * * `npm run build:cards` writes them. Nothing at runtime reads them; they are for people. */ import { readFileSync, 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)} |`; /** Generated blocks, keyed by the marker name the deck documents wrap them in. */ const blocks = new Map(); let current: string[] = []; /** Start a new generated block. Everything `w` writes lands here until the next `section`. */ const section = (key: string): void => { current = []; blocks.set(key, current); }; const w = (s = ''): void => void current.push(s); section('trains'); 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(); section('mainline'); w('| Card | Regions | Default entry | Fast / slow entry | Trains may pass | Extra may start |'); 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(); section('office'); 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(); section('facilities'); 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(); section('modifiers'); 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(); section('track'); 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(); section('enhancements'); 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(); section('opponent'); 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(); /** * Splice each block into its document, between the markers that name it. * * Strict on purpose: a block with nowhere to go, or a marker pair with no block, is a mistake that * would otherwise show up as a silently missing table. Both throw. */ const WHERE: Record = { mainline: 'docs/mainline-deck.md', }; const DEFAULT_DOC = 'docs/home-deck.md'; const edited = new Map(); for (const [key, body] of blocks) { const rel = WHERE[key] ?? DEFAULT_DOC; const text = edited.get(rel) ?? readFileSync(join(root, rel), 'utf8'); const begin = ``; const finish = ``; const from = text.indexOf(begin); const to = text.indexOf(finish); if (from < 0 || to < 0) throw new Error(`${rel} has no markers for "${key}" — expected ${begin} … ${finish}`); if (to < from) throw new Error(`${rel}: markers for "${key}" are the wrong way round`); // Trailing blank lines are trimmed so the block sits the same way however the section ends. const inner = body.join('\n').replace(/\n+$/, ''); edited.set(rel, `${text.slice(0, from + begin.length)}\n${inner}\n${text.slice(to)}`); } for (const [rel, text] of edited) { writeFileSync(join(root, rel), text); console.log(`built -> ${rel}`); }