Files
station-master/test/setup.test.ts
T
Jesse.MarkowitzandClaude Opus 5 3befc420da v0.8.2 — every district opens on a Depot, and the docs are pages now
A second-digit bump for a playtest read back against the save file. Nine questions
were asked of one three-Day game; three were bugs, three were the rules working
and undocumented, three were decisions. Every save on the test server was replayed
against this build BEFORE release, which is how the cost of each rule was known
before it was chosen rather than discovered after.

EVERY DISTRICT OPENS ON A DEPOT. A Whistle Post has one A/D track and is not a
Passenger Facility, so the opening of every game was spent unable to work a
passenger and one arrival away from a collision. Two A/D tracks and passengers
from Stage 1 now; "Players start with Whistle Posts, not Depots" is the harder
game, set when the game is created. The deck follows the choice — starting on
Depots the four Depot upgrade cards are left out, because an upgrade must be to
the next tier and a Depot card at a table of Depots is a dead draw. How much
easier it is showed up as a test failure rather than an argument: the cue-coverage
pool needed widening from 24 seeded games to 60 before it held one collision.

NO SAVE WAS STRANDED BY IT, which took care. This is the one house rule that
changes how a game is DEALT rather than how it plays, so replaying a save under
the wrong opening is a different railroad from intent one — silently, with no
error. `withSavedOpening` fills it on the replay paths ONLY. Putting it in the
resolver instead made a fresh Cutthroat game deal Whistle Posts and read as
Custom, which is how the distinction was found.

THREE BUGS, ALL REPORTED FROM ONE GAME AND ALL CONFIRMED ON ITS SAVE.

An Office held TWO TRAINS ON ONE A/D TRACK. The capacity test passed with nothing
standing, the train the Interlocking had been holding at the Limits was moved into
the free slot, and the arriving train was pushed in after it without anyone asking
again whether there was room — so the collision §8.3 calls for never happened. The
held train keeps priority; the newcomer now takes the consequence it would have
met had the held train arrived first.

THE HISTORY FROZE, permanently, and the log cap was not really the cause. Each
seat's "what have I sent you" bookmark was an INDEX into an array the game trims,
so once a seat's bookmark reached the limit the slice returned nothing for the
rest of the game — at a different moment per seat, because each holds its own.
That game's log ended at exactly the cap. Lines carry a sequence number now, which
survives trimming; proven by pushing twice the cap through a simulated seat.

§8.1 ASKED THE WRONG QUESTION TWICE. "Trains may pass" returned `clear` before the
Subdivision was looked at, so a train entering a Double Track was released however
busy the rest of it was — that, not anything about Control Points, is what let
Train 8 out with no ruling. And a train standing at an Office was invisible to the
scan, so one about to re-enter the very Subdivision being entered counted for
nothing. Capacity is the test, not presence: a Depot with a track free is not in
the way; a Whistle Post with its one track taken is.

THINGS THAT HAPPENED SILENTLY NOW SAY SO — a train held against a facing one, a
train released from the Limits (a side effect of somebody else's arrival, so it
simply appeared at the Office), and the train an Interlocking is holding, whose
explanatory tooltip has existed since #99 with NO renderer ever reading the flag.

WHERE A MOVE IS REFUSED, AND WHY. `exploreMoves` decides where the rails go and the
pick-up restrictions are enforced afterwards in `check`, so a square the rails
reached and the card forbade was reachable, un-offered, and absent from the block
list with nothing said. Those squares are blocked with the rule that blocks them
now, and the reasons are got by ASKING `check` rather than re-deriving: a second
implementation of the rules is exactly the failure the block list exists to avoid.
A train may also always recover its own caboose — X13 prints "may drop but not
pick up anything", and a train needs its caboose to be made up, so one that parted
with it could never legally leave again.

RULES DECIDED IN SEPTEMBER AND APPLIED HERE. A Modifier must sit square against its
host, no diagonals. A passenger Modifier may not be played at a Whistle Post. Both
were built, measured, held back for a fortnight so a playtest could finish, and
applied now. A Second Section costs its card: `SECOND_SECTION` was declared in
content.ts and never dealt, so the action was free and the bot ordered 26
accidental ones in a measured round. The card is dealt and spent — gating on a card
the deck never holds would have deleted the mechanic rather than fixed it.

THE DOCUMENTATION IS A SET OF PAGES, not five text files served as text/plain — a
card reference is mostly tables, and as plain text a table is rows of pipes.
Markdown is still the one copy; the build renders it, and publishes the .md beside
each page. No Markdown library: this project has no runtime dependencies and one
would be a poor first. The pages add what Markdown cannot carry without drifting —
a nav across the set, a contents list built from the headings actually rendered,
an anchor on every heading, a 70-character measure, and tables that are tables.
They print as ink on paper.

The references caught up with the rules, checked rather than assumed: two
statements had gone from stale to misleading (the Quickstart told a new player to
"get a Depot down as soon as one appears"), and four rules nobody could look up
are written down — the Office tier table, §8.1 in practice, what the Circus Train
pays for, and that a Realignment can be a card with no legal target.

Adding one card to the deck reshuffles every seeded deal, which broke five
fixtures. Each was a seed meaning "a game like this" — TODO #84, exactly — so
seeds moved and pools widened rather than assertions weakening, and the clearance
fixture pins its terrain the way `enhancements.test.ts` already does. The three
published replays were re-recorded.

Closes TODO #40, #42a, #108, #109 and #110.

1046 fast tests and 35 sim tests pass.

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

696 lines
32 KiB
TypeScript

/**
* Build order step 1 — "Card data loads; a game state can be constructed and seeded."
* See architecture/components.md §4.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import type { CarType } from '../src/engine/content.ts';
import { DEFENCE_ONLY_CARDS, DEFENCE_ONLY_COPIES, MODIFIER_PROFILES, OPENING_OTHER, OPENING_TRACK, SOLITAIRE_DECK_SIZE, TRACK_CARDS, TRACK_IN_DECK, withSavedOpening } from '../src/engine/content.ts';
import {
DECK_SIZE,
EXTRA_TRAINS,
FREIGHT_PROFILES,
LENGTH_PROFILES,
MAX_CONSIST,
OFFICE_PROFILES,
ROLLING_STOCK_SUPPLY,
TIMETABLED_TRAINS,
TOTAL_ROLLING_STOCK,
crewTrayCount,
deckComposition,
isFreightHouse,
lengthProfile,
MAINLINE_DECK,
mainlineCardCount,
nextOfficeTier,
officeProfile,
} from '../src/engine/content.ts';
import { coordKey } from '../src/engine/state.ts';
import { createRng } from '../src/engine/rng.ts';
import { buildDeck, buildRollingStock, createGame } from '../src/engine/setup.ts';
import type { StartingHand } from '../src/engine/content.ts';
import type { GameConfig } from '../src/engine/state.ts';
import { subdivisions } from '../src/engine/state.ts';
const solitaireConfig: GameConfig = {
mode: 'solitaire',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
// These fixtures were written against a Whistle Post opening — one A/D track and no
// Control Point — and several of them test exactly that. Named explicitly since the
// default became a Depot.
houseRules: { startingOffice: 'whistlePost' },
optionalRules: {
reducedVisibility: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
const newSolitaireGame = (seed = 1234) =>
createGame({ id: 'g1', seed, config: solitaireConfig, playerNames: ['Jesse'] });
/** A game dealt under one named `StartingHand`, for the tests that are about the deal itself. */
const gameDealtWith = (startingHand: StartingHand, seed = 1234) =>
createGame({
id: 'g1',
seed,
// Spread, not replaced: `solitaireConfig` names the Whistle Post opening these counts assume.
config: { ...solitaireConfig, houseRules: { ...solitaireConfig.houseRules, startingHand } },
playerNames: ['Jesse'],
});
// ---------------------------------------------------------------------------
describe('card catalogue (component 1)', () => {
it('composes the deck from the design', () => {
// THE WHOLE CATALOGUE IS docs/Deck cards5.xlsx NOW (Gitea#14). Sheet 5's own totals are
// "Total track 48" and "Total other (in play) 107", i.e. 155 shuffled, plus 12 start cards for
// its grand total of 167. Card for card, 84 rows agree with it exactly and the only ones that
// do not are listed below — every one of them a deliberate hold, in one direction or the other.
//
// EVERY COUNT IN THE CATALOGUE IS NOW THE SHEET'S. The two deliberate departures that used to
// sit here are gone with Gitea#14 — the Q12 office doubling (offices 14 → 7) and the Gap 12
// industry tripling (27 → 9) — because both were measured against a deck holding 96 track
// cards, and sheet 5 halves that. content.ts carries the measurements that decided it.
//
// We are at 144 rather than the sheet's 155 for ONE reason: the ten Safety, Event, Inspection
// and Space-use cards sheet 5 adds are not built, and stay out until they are (Jesse,
// 2026-08-26) — Cargo Theft, Civic Improvement, Civilian angel, Delayed Clearance, Flares 2,
// Robbery, Service Delays, Shipper complaints, Strike, Union Hall 2. Twelve copies in all.
//
// NOTHING RUNS THE OTHER WAY ANY MORE. Every card sheet 5 does not list is dealt ZERO copies
// rather than deleted, so the design stays visible and the rules stay implemented: the
// Telegraph/Telephone/Radio ladder, Facing Point Locks (both), Flying Switch, Section House and
// Vandalism, all removed from the design on purpose; Poling, whose effect the source records as
// "TBD"; and the sharp curves, whose only difference from an ordinary curve was a Move cost
// nothing ever charged — sheet 5 deals those zero too, so the catalogue and the design agree.
//
// DECK_SIZE is the CATALOGUE, 144. The deck actually dealt is smaller: the 20 opponent-directed
// cards are held back in every mode until they are implemented, so `buildDeck` returns 123.
assert.equal(DECK_SIZE, 144);
assert.equal(buildDeck().length, SOLITAIRE_DECK_SIZE);
});
it('matches the design deck composition exactly', () => {
const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
assert.deepEqual(byCategory, {
// Sheet 5's track counts exactly (Gitea#14): 16 straights, 8+8 curves, 8+8 turnouts, and the
// sharp curves dealt none — which is where the catalogue already had them, and where sheet 5
// now puts them too.
track: 48,
// The sheet's 7 — the Q12 doubling came out in Gitea#14; see OFFICE_PROFILES.
office: 7,
// The sheet's 9 — the Gap 12 tripling came out in Gitea#14; see INDUSTRY_PROFILES.
industry: 9,
modifier: 23,
train: 22,
// Q9 — dealt since 2026-09-23, which is what makes `newTrain.secondSection` cost something.
secondSection: 1,
spaceUse: 11,
// 6 — the dispatching ladder and Facing Point Locks are dealt 0 copies (see
// ENHANCEMENT_CARDS), and Interlocking, Water column and ABS Signals came down to the sheet's
// single copies. What is left is the sheet's Enhancements exactly, bar Railroad crossing,
// which sheet 5 moved here from the Action cards and which is still counted there below.
enhancement: 6,
// 5 — Facing Point Locks came out of the Mainline modifiers too.
mainlineModifier: 5,
// 3 — Red Flags is the sheet's 3; Flying Switch and Poling are both dealt none.
maneuver: 3,
// 9 — Vandalism is dealt none. The rest are opponent-directed and held out of every deck.
action: 9,
});
});
it('removes opponent-directed cards from a solitaire deck', () => {
// Q6 took Space-use and Action cards out of solitaire, where they have no legal target. They are
// now out of the COMPETITIVE deck too, until they are implemented: `checkPlay` answers both
// categories NOT_IMPLEMENTED, so dealing them would be a dead draw.
// 122, not 124: the 20 opponent-directed cards come out, and so do the TWO that exist only to
// answer them — one Water Column and one Overpass. A defence with nothing to defend against is
// the same dead draw as the attack would be. `SimpleCard.answers` names the pairing, so they
// return together. It was seven until Gitea#14 dealt Facing Point Locks zero copies: a card at
// zero is already out, so it no longer needs holding back.
assert.equal(SOLITAIRE_DECK_SIZE, 122);
assert.equal(DEFENCE_ONLY_COPIES, 2);
for (const c of DEFENCE_ONLY_CARDS) {
assert.ok(c.answers, `${c.name} is held back without saying what it answers`);
assert.ok(
!buildDeck().some((x) => (x.kind as { key?: string }).key === c.key),
`${c.name} is still dealt`,
);
}
for (const mode of ['solitaire', 'competitive'] as const) {
const deck = buildDeck(mode);
assert.equal(deck.length, SOLITAIRE_DECK_SIZE, `${mode} deck size`);
assert.ok(
!deck.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'),
`${mode} deck still holds opponent-directed cards`,
);
}
});
it('deals track FROM the deck, at the sheet\'s counts', () => {
// Column B of Deck cards5.xlsx, "Number in Deck": 16 straights, 8+8 curves, 8+8 turnouts, and
// 0+0 sharp curves. Sheet 2 had each of those at double, which is what the deck dealt until
// Gitea#14. An earlier reading took the sheet's LAST column, "Track Per Player" (26), as a
// separate stack outside the deck; it is the sheet's total shared out among four players, not a
// second pile.
assert.equal(TRACK_IN_DECK, 48);
const deck = buildDeck();
for (const t of TRACK_CARDS) {
const n = deck.filter(
(c) => c.kind.kind === 'track' && c.kind.geometry === t.geometry && c.kind.hand === t.hand,
).length;
assert.equal(n, t.copiesInDeck, `${t.name}: ${n} in the deck, expected ${t.copiesInDeck}`);
}
});
it('makes track the largest category in the deck', () => {
// 48 of 122. Building a district is paid for in the industry or train you did not draw, which
// is the whole reason it matters that track is a card rather than a private supply.
//
// This asked for a THIRD of the deck until Gitea#14, which was only ever a rule of thumb. It
// asks its own question now — is track still the biggest single thing you can draw — plus a
// loose band, because the exact share is not settled yet and should not be pinned as though it
// were. Sheet 5 puts track at 48 of the 155 cards it would have you shuffle, i.e. 31%; we read
// 40% because the Space-use, Safety, Event and Inspection cards are held out, which concentrates
// everything that is left. The share falls TOWARDS the sheet as those land, so the band is set
// to hold across that whole journey rather than to be re-edited at each step.
const deck = buildDeck();
const counts = new Map<string, number>();
for (const c of deck) counts.set(c.kind.kind, (counts.get(c.kind.kind) ?? 0) + 1);
const track = counts.get('track') ?? 0;
for (const [kind, n] of counts) {
if (kind === 'track') continue;
assert.ok(track > n, `${kind} has ${n} cards against track's ${track}`);
}
const share = track / deck.length;
assert.ok(share > 0.28 && share < 0.45, `track is ${(share * 100).toFixed(1)}% of the deck`);
});
it('has 12 timetabled trains, odd westbound and even eastbound', () => {
assert.equal(TIMETABLED_TRAINS.length, 12);
for (const t of TIMETABLED_TRAINS) {
assert.equal(t.direction, t.number % 2 === 1 ? 'west' : 'east', `train ${t.number}`);
}
});
it('never lets a consist exceed four slots, caboose included', () => {
// §A.4 + Gap 4c — the most likely off-by-one in the whole model.
for (const t of [...TIMETABLED_TRAINS, ...EXTRA_TRAINS]) {
const slots = t.consist.freight + t.consist.coach + t.consist.caboose;
assert.ok(slots <= MAX_CONSIST, `${t.name} ${t.number} uses ${slots} slots`);
}
});
it('numbers Extras X13-X22, all junior to every timetabled train', () => {
// This is why Gap 5's Extra-vs-Timetabled tie can no longer occur.
assert.deepEqual(
EXTRA_TRAINS.map((t) => t.number).sort((a, b) => a - b),
[13, 14, 15, 16, 17, 18, 19, 20, 21, 22],
);
assert.ok(EXTRA_TRAINS.every((e) => TIMETABLED_TRAINS.every((t) => t.number < e.number)));
});
it('gives every train a name and a speed class', () => {
for (const t of [...TIMETABLED_TRAINS, ...EXTRA_TRAINS]) {
assert.ok(t.name.length > 2, `train ${t.number} has no name`);
assert.ok(t.speed === 'fast' || t.speed === 'slow', `train ${t.number} has no speed`);
}
});
it('names the Freight House and nothing else as the two-way industry', () => {
/**
* §9.3 — "Passenger Facilities and Freight Houses permit cars to move each direction". ONE card
* answers to that.
*
* This briefly asserted three. `card-reference.md` reads "'Freight House' is not a card. It is
* the collective term for a freight facility that loads *and* unloads — the Grocer's Warehouse
* and the Oil Refinery", and on that premise the Refinery and the Grocer's were both made
* `flow: 'both'`. The premise is dead: `glossary.md` and `rules-v0.2.md` corrected the Freight
* House to a card of its own, dealt like any other industry, so §9.3 names it and the table's
* "Both" column loses its only argument.
*
* Reported from playtesting v0.4.9d and confirmed by Jesse: the Refinery only ships tanks out,
* the Grocer's Warehouse only receives. `home-deck.md` prints both that way,
* and so does the modifier set — all three Refinery modifiers grant outbound.
*/
const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
assert.deepEqual(houses.sort(), ['freightHouse']);
const refinery = FREIGHT_PROFILES.find((f) => f.kind === 'refinery')!;
assert.equal(refinery.flow, 'outbound');
assert.deepEqual([refinery.baseOut, refinery.baseIn], [1, 0]);
const grocers = FREIGHT_PROFILES.find((f) => f.kind === 'grocersWarehouse')!;
assert.equal(grocers.flow, 'inbound');
assert.deepEqual([grocers.baseOut, grocers.baseIn], [0, 1]);
});
it('starts every industry at one car out and one loader', () => {
// The design is far leaner than the placeholder: capacity grows via modifier cards.
for (const f of FREIGHT_PROFILES) {
assert.ok(f.baseOut <= 1, `${f.name} baseOut ${f.baseOut}`);
assert.ok(f.baseIn <= 1, `${f.name} baseIn ${f.baseIn}`);
assert.equal(f.baseLoaders, 1, `${f.name} loaders`);
}
});
it('gives a facility capacity only in the directions it allows', () => {
for (const f of FREIGHT_PROFILES) {
const wantsOut = f.flow === 'outbound' || f.flow === 'both';
const wantsIn = f.flow === 'inbound' || f.flow === 'both';
assert.equal(f.baseOut > 0, wantsOut, `${f.name} outbound`);
assert.equal(f.baseIn > 0, wantsIn, `${f.name} inbound`);
}
});
it('records lockouts symmetrically', () => {
// Column E of the sheet. Semantics are still open, but the data must be consistent.
for (const f of FREIGHT_PROFILES) {
for (const other of f.lockouts) {
const o = FREIGHT_PROFILES.find((x) => x.kind === other)!;
assert.ok(o.lockouts.includes(f.kind), `${f.kind} locks out ${other} but not vice versa`);
}
}
});
it('ties every modifier to at least one real host', () => {
for (const m of MODIFIER_PROFILES) {
assert.ok(m.hosts.length > 0, `${m.name} has no host`);
for (const h of m.hosts) {
if (h === 'office') continue;
assert.ok(FREIGHT_PROFILES.some((f) => f.kind === h), `${m.name} names unknown host ${h}`);
}
}
});
it('makes only the Whistle Post a non-Control-Point', () => {
for (const o of OFFICE_PROFILES) {
assert.equal(o.isControlPoint, o.tier !== 'whistlePost', o.tier);
assert.equal(o.isPassengerFacility, o.tier !== 'whistlePost', o.tier);
}
});
it('scales A/D tracks 1/2/3/4 and slots above porter count', () => {
assert.deepEqual(OFFICE_PROFILES.map((o) => o.adTracks), [1, 2, 3, 4]);
// The design gives slots EQUAL to porters; the earlier guess was one too generous.
for (const o of OFFICE_PROFILES) {
if (o.tier === 'whistlePost') continue;
assert.equal(o.passengerOut, o.porters, `${o.tier} green slots`);
assert.equal(o.passengerIn, o.porters, `${o.tier} red slots`);
}
});
it('forms an office pyramid so the strict upgrade sequence cannot stall', () => {
// Assert the PROPERTY, not the literal counts. Upgrades are strictly sequential (Gap 3b), so
// every tier must be at least as common as the one above it — otherwise players reach a rung
// whose next card is scarcer than the one that got them there. Densities are provisional (Q12)
// and expected to move again; the pyramid shape is what must survive.
const inDeck = OFFICE_PROFILES.filter((o) => o.copiesInDeck > 0).map((o) => o.copiesInDeck);
assert.ok(inDeck.length >= 2, 'there must be an upgrade ladder at all');
for (let i = 1; i < inDeck.length; i++) {
assert.ok(
inDeck[i]! <= inDeck[i - 1]!,
`tier ${i} has ${inDeck[i]} copies but the tier below it has ${inDeck[i - 1]}`,
);
}
// The Whistle Post is the starting state, never a card.
assert.equal(OFFICE_PROFILES.find((o) => o.tier === 'whistlePost')!.copiesInDeck, 0);
});
it('walks the upgrade sequence without skipping', () => {
assert.equal(nextOfficeTier('whistlePost'), 'depot');
assert.equal(nextOfficeTier('depot'), 'station');
assert.equal(nextOfficeTier('station'), 'terminal');
assert.equal(nextOfficeTier('terminal'), null);
});
it('supplies the full rolling stock roster', () => {
// 80 pieces after the Gap 12 supply scale-up. Asserted against the constant rather than a
// literal so the invariant is "the yard holds exactly the roster", not a number to re-edit.
assert.equal(TOTAL_ROLLING_STOCK, 80);
assert.equal(buildRollingStock().length, TOTAL_ROLLING_STOCK);
});
it('never demands more cars than the supply can furnish', () => {
// card-reference.md §8 supply check, as an executable assertion.
const supply = new Map(ROLLING_STOCK_SUPPLY.map((s) => [s.type, s.loaded + s.empty]));
const demand = new Map<CarType, number>();
for (const f of FREIGHT_PROFILES) {
const need = (f.baseOut + f.baseIn) * f.copies;
for (const t of f.carTypes) demand.set(t, (demand.get(t) ?? 0) + need);
}
for (const [type, need] of demand) {
assert.ok(need <= supply.get(type)!, `${type}: demand ${need} > supply ${supply.get(type)}`);
}
});
it('keeps the three day-count presets sim/CLI tooling still relies on', () => {
// Gap 10e's fixed targets retired 2026-08-20 — `minCombinedRevenue` is a free `GameConfig`
// variable now, not a `length`-keyed lookup. `LENGTH_PROFILES` survives only as a convenience
// preset for tools that still want a short/standard/campaign argument (`harness.ts`, `compare.ts`,
// `replay.ts`).
assert.deepEqual(
LENGTH_PROFILES.map((p) => [p.length, p.days]),
[['short', 3], ['standard', 5], ['campaign', 10]],
);
assert.equal(lengthProfile('standard').days, 5);
});
it('scales fixed supplies with player count', () => {
assert.equal(mainlineCardCount(1), 2);
assert.equal(mainlineCardCount(3), 4);
assert.equal(crewTrayCount(1), 4);
assert.equal(crewTrayCount(3), 6);
});
});
// ---------------------------------------------------------------------------
describe('seeded RNG (component 7)', () => {
it('is deterministic for a given seed', () => {
const a = createRng(42);
const b = createRng(42);
const drawsA = Array.from({ length: 50 }, () => a.nextInt(1000));
const drawsB = Array.from({ length: 50 }, () => b.nextInt(1000));
assert.deepEqual(drawsA, drawsB);
});
it('differs between seeds', () => {
const a = Array.from({ length: 20 }, ((r) => () => r.nextInt(1000))(createRng(1)));
const b = Array.from({ length: 20 }, ((r) => () => r.nextInt(1000))(createRng(2)));
assert.notDeepEqual(a, b);
});
it('rolls a D12 strictly within 1..12', () => {
const rng = createRng(7);
const seen = new Set<number>();
for (let i = 0; i < 5000; i++) {
const roll = rng.d12();
assert.ok(roll >= 1 && roll <= 12, `roll out of range: ${roll}`);
seen.add(roll);
}
assert.equal(seen.size, 12, 'every face should appear over 5000 rolls');
});
it('shuffles without mutating or losing elements', () => {
const rng = createRng(99);
const input = Array.from({ length: 52 }, (_, i) => i);
const out = rng.shuffle(input);
assert.deepEqual(input, Array.from({ length: 52 }, (_, i) => i), 'input mutated');
assert.deepEqual([...out].sort((x, y) => x - y), input, 'elements lost');
assert.notDeepEqual(out, input, 'shuffle was a no-op');
});
it('rejects an invalid bound', () => {
const rng = createRng(1);
assert.throws(() => rng.nextInt(0));
assert.throws(() => rng.nextInt(-3));
});
});
// ---------------------------------------------------------------------------
describe('game setup (component 2)', () => {
it('constructs a solitaire game from a seed', () => {
const g = newSolitaireGame();
assert.equal(g.status, 'active');
assert.equal(g.players.length, 1);
assert.equal(g.clock.day, 1);
assert.equal(g.clock.stage, 1);
assert.equal(g.clock.phase, 'localOps');
assert.equal(g.clock.superintendent, 0);
assert.equal(g.clock.currentActor, 0);
});
it('is reproducible from the same seed and differs on another', () => {
assert.deepEqual(newSolitaireGame(5).decks.homeOffice, newSolitaireGame(5).decks.homeOffice);
assert.notDeepEqual(
newSolitaireGame(5).decks.homeOffice,
newSolitaireGame(6).decks.homeOffice,
);
});
it('builds the MVP division: DP - ML - Office - ML - DP', () => {
const g = newSolitaireGame();
assert.deepEqual(
g.division.nodes.map((n) => n.kind),
['divisionPoint', 'mainline', 'office', 'mainline', 'divisionPoint'],
);
});
it('deals each Mainline card a terrain type', () => {
// Terrain is what sets crossing time (Q1), so every Mainline node must carry a card kind.
const g = newSolitaireGame();
for (const node of g.division.nodes) {
if (node.kind === 'mainline') {
assert.ok(node.card, 'mainline node has no card type');
assert.deepEqual(node.transits, []);
}
}
});
it('deals the Mainline cards from the printed deck, without replacement', () => {
/**
* `buildDivision` drew uniformly from the nine card TYPES with replacement, so a Division could
* be dealt two Interchanges (or two Tunnels), and Plains — printed twice in the deck — carried
* the same weight as cards printed once. That became a rules question rather than a flavour one
* when an Extra gained the right to start "at the Interchange if one is on the board" (§7): the
* board has to hold at most one for that to mean anything.
*
* Swept over many seeds because a single deal cannot tell a deck from a die.
*/
const seen = new Map<string, number>();
for (let seed = 0; seed < 400; seed++) {
for (const players of [1, 2, 3, 4]) {
const g = createGame({
id: 'deck', seed,
config: players === 1 ? solitaireConfig : { ...solitaireConfig, mode: 'competitive' },
playerNames: Array.from({ length: players }, (_, i) => `P${i}`),
});
const cards = g.division.nodes.flatMap((n) => (n.kind === 'mainline' ? [n.card] : []));
assert.equal(cards.length, mainlineCardCount(players));
const counts = new Map<string, number>();
for (const c of cards) {
const n = (counts.get(c) ?? 0) + 1;
counts.set(c, n);
seen.set(c, (seen.get(c) ?? 0) + 1);
// Plains is the one card printed twice; nothing else may be dealt twice at all.
assert.ok(n <= (c === 'plains' ? 2 : 1), `${c} dealt ${n} times at seed ${seed}`);
}
}
}
// Every card in the deck reachable, so the deal is not quietly missing one.
for (const kind of new Set(MAINLINE_DECK)) {
assert.ok((seen.get(kind) ?? 0) > 0, `${kind} was never dealt in 400 seeds`);
}
});
it('opens with the whole railroad as one Subdivision', () => {
// §8 — every Office is a Whistle Post, which is not a Control Point.
const g = newSolitaireGame();
const subs = subdivisions(g);
assert.equal(subs.length, 1, 'expected exactly one Subdivision at game start');
});
it('starts each player on a Whistle Post with three cards in a row', () => {
const g = newSolitaireGame();
const area = g.officeAreas.get(0)!;
assert.equal(area.tier, 'whistlePost');
assert.equal(area.grid.size, 3, 'Limits, Whistle Post, Limits');
assert.equal(officeProfile(area.tier).adTracks, 1);
assert.equal(area.adOccupancy.length, 0);
});
it('deals three random cards by default, and starts three Department piles', () => {
// The prototype rule, and the New Game dialog's default: three off one deck, already at the hand
// limit, with no guarantee of anything. The other two shapes are below.
const g = newSolitaireGame();
assert.equal(g.decks.hands.get(0)!.length, 3);
assert.equal(g.decks.departments.length, 3);
assert.ok(g.decks.departments.every((pile) => pile.length === 1), 'each Department starts with one face-up card');
});
it('deals six random cards when that is the rule, over the hand limit on purpose', () => {
// Six against a hand limit of three is deliberate: the first turn is spent choosing which of
// them to keep. What it does NOT do is guarantee track, which is the difference from the split
// deal below.
const g = gameDealtWith('sixRandom');
assert.equal(g.decks.hands.get(0)!.length, 6);
});
it('deals three track and three other cards when that is the rule', () => {
// From two separately shuffled piles, so the district you can build is dealt rather than waited
// for — a run-around needs five specific pieces, and drawing for them took eight games.
const g = gameDealtWith('threeTrackThreeOther');
const hand = g.decks.hands.get(0)!;
assert.equal(hand.length, OPENING_TRACK + OPENING_OTHER);
const track = hand.filter((id) => g.cards.get(id)!.kind.kind === 'track');
assert.equal(track.length, OPENING_TRACK, 'the opening hand is not three track cards');
assert.equal(hand.length - track.length, OPENING_OTHER, 'the opening hand is not three other cards');
});
it('shuffles the leftover track back into one deck for the rest of the game', () => {
// The split is an opening-deal device only. If the leftover track stayed out, every draw after
// the first turn would be drawn from a deck with no track in it at all.
const g = gameDealtWith('threeTrackThreeOther');
const rest = [...g.decks.homeOffice, ...g.decks.departments.flat()];
const track = rest.filter((id) => g.cards.get(id)!.kind.kind === 'track');
assert.equal(
track.length, TRACK_IN_DECK - OPENING_TRACK,
'the track left over after the deal is not back in the deck',
);
});
it('leaves every card accounted for whichever shape it was dealt in', () => {
// The single-deck path and the two-pile path deal from different piles into the same game; a
// card lost or duplicated by either would be a deck that quietly runs short mid-game.
for (const shape of ['threeRandom', 'sixRandom', 'threeTrackThreeOther'] as const) {
const g = gameDealtWith(shape);
const all = [
...g.decks.homeOffice,
...g.decks.departments.flat(),
...g.decks.salvageYard,
...[...g.decks.hands.values()].flat(),
];
assert.equal(all.length, SOLITAIRE_DECK_SIZE, `cards lost or duplicated dealing ${shape}`);
assert.equal(new Set(all).size, all.length, `duplicate card dealing ${shape}`);
}
});
it('accounts for every card exactly once', () => {
const g = newSolitaireGame();
const all = [
...g.decks.homeOffice,
...g.decks.departments.flat(),
...g.decks.salvageYard,
...[...g.decks.hands.values()].flat(),
];
// A solitaire deck omits the 22 opponent-directed cards (Q6).
assert.equal(all.length, SOLITAIRE_DECK_SIZE, 'cards lost or duplicated');
assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
});
it('flanks every Office with Mainline cards, never a Division Point', () => {
// The layout is always DP · Mainline · Office · Mainline · … · Mainline · DP, at every player
// count. `moveTrain` carries a branch for departing an Office straight onto a Division Point
// which this makes UNREACHABLE — it is kept correct rather than deleted, and this is the
// invariant that says why. If the layout ever changes, that branch wakes up and wants checking.
for (const players of [1, 2, 3, 4]) {
const g = createGame({
id: 'g', seed: 1234,
// Solitaire is a one-player mode by construction, so the wider counts run competitive.
config: players === 1 ? solitaireConfig : { ...solitaireConfig, mode: 'competitive' },
playerNames: Array.from({ length: players }, (_, i) => `p${i}`),
});
const nodes = g.division.nodes;
nodes.forEach((n, i) => {
if (n.kind !== 'office') return;
for (const j of [i - 1, i + 1]) {
assert.equal(nodes[j]?.kind, 'mainline', `office at ${i} is not flanked by Mainline at ${j}`);
}
});
}
});
it('starts with no trains scheduled and none running', () => {
// §4 — "no trains are officially running yet". This is the Day-1 revenue ramp (Gap 10e).
const g = newSolitaireGame();
assert.equal(g.timetable.length, 12);
assert.ok(g.timetable.every((slot) => slot === null));
assert.equal(g.trays.size, 0);
});
it('provides player-count + 3 crew trays', () => {
assert.equal(newSolitaireGame().freeTrays.length, 4);
});
it('puts every rolling stock piece in the Division Yard', () => {
const g = newSolitaireGame();
assert.equal(g.yards.divisionYard.length, TOTAL_ROLLING_STOCK);
assert.equal(g.yards.classificationYard.length, 0);
});
it('rejects a solitaire game with more than one player', () => {
assert.throws(() =>
createGame({ id: 'x', seed: 1, config: solitaireConfig, playerNames: ['a', 'b'] }),
);
});
});
/**
* WHICH OFFICE EVERY PLAYER OPENS ON.
*
* Jesse's call, 2026-09-23: a Whistle Post has one A/D track and is not a Passenger Facility, so the
* opening of every game was spent unable to work a passenger and one arrival away from a collision.
* The default is a Depot now; the Whistle Post opening stays as the harder setting.
*/
describe('the starting Office, and the deck that goes with it', () => {
const withRules = (houseRules: Record<string, unknown>) =>
createGame({
id: 'so', seed: 7,
config: { ...solitaireConfig, mode: 'competitive', houseRules } as never,
playerNames: ['A', 'B'],
});
const officeCards = (g: ReturnType<typeof withRules>, tier: string): number =>
[...g.cards.values()].filter((c) => c.kind.kind === 'office' && (c.kind as { tier: string }).tier === tier).length;
it('deals Depots by default, and leaves the Depot upgrades out of the deck', () => {
// A Depot card at a table that already has Depots is a dead draw: `check` refuses it, because an
// upgrade must be to the NEXT tier. Station and Terminal are still upgrades and stay in.
const g = createGame({
id: 'd', seed: 7,
config: { ...solitaireConfig, mode: 'competitive', houseRules: {} } as never,
playerNames: ['A', 'B'],
});
assert.deepEqual([...g.officeAreas.values()].map((a) => a.tier), ['depot', 'depot']);
assert.equal(officeCards(g, 'depot'), 0, 'Depot upgrades are still in the deck');
assert.ok(officeCards(g, 'station') > 0, 'Station upgrades were dropped too');
assert.ok(officeCards(g, 'terminal') > 0, 'Terminal upgrades were dropped too');
});
it('deals Whistle Posts when the table asks for the harder game, Depot cards and all', () => {
const g = withRules({ startingOffice: 'whistlePost' });
assert.deepEqual([...g.officeAreas.values()].map((a) => a.tier), ['whistlePost', 'whistlePost']);
assert.ok(officeCards(g, 'depot') > 0, 'the Depot upgrade is missing from a Whistle Post game');
});
it('gives a Depot two A/D tracks and a working passenger facility from Stage 1', () => {
// This is the whole of why the default moved: one A/D track is what made an arrival a collision,
// and a Whistle Post earns nothing from a passenger however well the district is built.
const g = createGame({
id: 'p', seed: 7, config: { ...solitaireConfig, houseRules: {} } as never, playerNames: ['A'],
});
const area = g.officeAreas.get(0 as never)!;
assert.equal(officeProfile(area.tier).adTracks, 2, 'a Depot should have two A/D tracks');
const f = area.grid.get(coordKey(area.officeCoord))!.facility!;
assert.equal(f.allows.outbound, true, 'a Depot should board passengers from the start');
assert.equal(f.allows.inbound, true, 'a Depot should detrain passengers from the start');
assert.ok(f.porters > 0, 'a Depot should have a Porter');
});
it('replays a save written before the setting as the Whistle Post game it was', () => {
/**
* The one house rule that changes how a game is DEALT rather than how it plays, so replaying it
* under the wrong opening is a different railroad from intent one — silently. `withSavedOpening`
* fills it for a save that names other rules and cannot name this one.
*/
const saved: { houseRules: { startingHand: 'sixRandom'; startingOffice?: 'depot' | 'whistlePost' } } =
{ houseRules: { startingHand: 'sixRandom' } };
assert.equal(withSavedOpening(saved).houseRules.startingOffice, 'whistlePost');
// A config that names it is left exactly as it is, in both directions.
assert.equal(withSavedOpening({ houseRules: { startingOffice: 'depot' as const } }).houseRules.startingOffice, 'depot');
// And a config with no house rules at all is a fresh game, not an old save.
assert.deepEqual(withSavedOpening({}), {});
});
});