Files
station-master/scripts/build-card-reference.ts
T
Jesse.MarkowitzandClaude Opus 5 819996faa2 v0.7.9.2 — two things the table could hear that only one seat should
Both leaks were found while planning the common board (Gitea#20 step 1),
and both are live multiplayer bugs with or without that display, so they
are fixed now rather than with 0.8.0.

`game.log` is one shared list and `linesSince(seat)` slices it with no
per-seat filter, so every line reaches every player. It carried the SEED
in the opening line of each multiplayer game — the whole future of the
deal — and the NAME OF A CARD DRAWN BLIND from the face-down Home Office
deck. Solitaire deliberately keeps both: a one-seat table has nobody to
leak to, the seed is what a bug report quotes, and a player's own history
naming their own draw is the record. A Department slot is face up and
stays named. The drawer still learns their card through `justDrawn`,
which already goes to that seat alone.

Neither was found by a test. Every test in `redaction.test.ts` passes an
empty log, so the whole of narration has sat outside the redaction net
since the net was built. Both now have tests there; TODO #91 carries what
is still owed and supersedes #78, which described a gap that had already
been closed and never mentioned this one.

`docs/rules/` had no current description of the game, and `content.ts`
named `card-reference.md` as the file that carries what the cards say —
a file whose own banner says not to use its numbers, describing the
v0.4.5 deck where 3/4 is a Mail-Express with three coaches. Every file in
that directory is a deliberate historical record, so none of them is
rewritten. `as-built.md` is new and GENERATED from the same catalogues
the engine instantiates from, with a test that re-runs the generator and
fails when the checked-in file disagrees. A hand-written replacement
would have drifted the same way, for the same reason.

TODO.md: #32 closed — the playtest migration note did its job and the
jump is made; the durable fact it carried is kept. #78 retired in favour
of #91. The "play it at a table" section now records that 0.7.4-0.7.9
were test-run without change requests, and that more testing comes at the
end of the 0.7.9 series.

897 tests pass, up from 891.

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

213 lines
9.9 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 {
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)`);