Files
station-master/test/advance.test.ts
T
Jesse 9f3b92d08e v0.4.7 — the switching game: track order, the cut on your own card, and four rules
Eight play reports and one design that had been written up and not built. The
through-line is switching: what a card can hold, which end of a train a cut comes
off, which way a train meets cars standing on the line, and what the board and the
log say about all of it.

TRACK ORDER FOR STANDING CARS, AND THE CUT ON YOUR OWN CARD

Two reports turned out to be one root cause. `TrackCard.standing` claimed "in track
order (§A.3)" and had no defined orientation at all, while `CrewTray.consist` does
(nose first, relative to facing) — so every transfer between them was a conversion
nothing performed. §A.3 says what it should be: cars occupy the track "in the same
order they originally held, left-to-right". Left-to-right is west-to-east, and that
is now the defined orientation of `standing` and of an industry track through
`carsOn`. It is the board's orientation, not the train's, so it does not change when
a different train touches the card.

  - Setting out is batch-invariant. Four cars at once, four singles and two pairs
    parked three different orders, one of them physically impossible. Successive
    cuts off the same end stack up towards the engine, so the insertion point is the
    train's own place in the row.
  - Approaching a cut from either end now mirrors. `couples` is built nearest-first
    along the direction of travel and reverses onto the nose, so the farthest car met
    ends up nose-most — which is what makes a run-around worth its Move.
  - A train no longer drives through its own cut. The walk began at the neighbour of
    the start square and never read the start card, so a crew could set cars out and
    pull straight away from them. Coupling is mandatory (§A.4) and your own square is
    no exception; the cut counts against the four-car limit. Setting out off the end
    you are not leaving by still works.

`CrewTray.standingWest` records where a train stands among the cars on its card — a
train may set out off both ends on one square, so which side a cut is on is not
recoverable from the array alone.

On the board, the cut is drawn split at the train — west cars left, east cars right,
engine in the gap — and each car's tooltip says whether it stands ahead of or behind
the engine. The history says which end a cut came off, and a move's button separates
"takes your own boxcar back off this card" from cars found standing on the line.

Decided: taking your own cut back on the square you are standing on is UNDOING the
drop. It is exempt from trains 3/4's per-location freight budget, X13's "drop but not
pick up" and X22's "empties only", and it refunds the budget the drop spent.
Otherwise a legal-looking drop becomes silently one-way.

Measured, 200 paired seeds, developer bot: -0.55 revenue (t = -3.63), freight revenue
1.11 -> 0.56. That cost is the bot's, not the rule's — its trains run engine-first,
so at a stub industry it sets a car out between itself and the only way out, and the
correct play is §A.5's facing-point move, which is the cross-turn planning TODO.md
already records as out of reach of any bot. Filtering self-recoupling moves out of its
options took recoupling from 625 of 1,029 set-outs to 101 of 677, and all 101 that
remain are that case. Read the number as a bot measurement, not a balance one.

THE SUPERINTENDENT'S RULING NAMES THE TRAINS IT IS ABOUT

Reported: the Superintendent could not tell which train he was clearing. The heading
asks the question now — "may Train 6 follow Train 4 onto the same Mainline card?" —
and the trains moved to the FRONT of each button, because the button splits its label
at the first em-dash and showed only the head.

AN INDUSTRY TRACK HOLDS FOUR CARS, LIKE EVERY OTHER CARD

Reported at undo 188: "we wanted to drop two cars, but were only allowed to drop one."
An industry track was built as long as its box count, so a one-box industry had room
for one car. Box count is how much WORK an industry can hold, not how much RAIL it
has. Ordinary track was the other exception, unbounded; both are gone and every card
holds four.

THE FREIGHT AGENT MAY STAGE A LOAD BEFORE THE CAR IS THERE

§6.3 asks nothing of the industry track — the empty car belongs to §9.3's Load the
car, which is the Laborer's action. The gate now lives only there, so cargo can wait
on the dock while the car to ship it in is still being switched in. Nothing can jam:
a load in a green box is waiting, not stuck.

THE TRUCK DOCK UNLOADS, AND BRINGS NOBODY

+1 inbound, no Laborer. It printed +1 outbound and +1 Laborer, which made it a
longer-host-list copy of Forklifts. Beside Packing Sheds it now does nothing at all,
and the hand tooltip says so before it is played.

Also in this release, from the days before: Mainline card tooltips computed from the
crossing rule; an Extra starts from the Division Point its number sends it to; a
modifier's suppressed grant comes back when a Whistle Post is upgraded; the Oil
Refinery and the Grocer's Warehouse ship as well as receive, per the card reference;
and the dormant defences name the attack they answer. `.claude/` is now gitignored —
it holds Claude Code's worktrees, i.e. a second checkout of this repository.

570 tests, typecheck clean. The three published replays were re-recorded twice —
legality changed, so bot play changed. Full detail in CHANGELOG.md.
2026-08-19 12:12:30 -04:00

947 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Build order step 4 — "A full solitaire game runs to completion, headless."
* See architecture/components.md §4. This is the milestone that proves the rules before a single
* pixel is drawn.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { advance, pump } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import { HAND_LIMIT, STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import { developerBot } from '../src/sim/bot.ts';
import type { CrewTray, GameConfig, GameState } from '../src/engine/state.ts';
import { railFacingOf } from '../src/engine/state.ts';
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'short',
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
...over,
});
const game = (seed = 1, over: Partial<GameConfig> = {}): GameState =>
createGame({ id: 'g', seed, config: baseConfig(over), playerNames: ['Jesse'] });
type PlayStats = { turns: number; scheduled: number; collisions: number; cardsPlayed: number };
/**
* A deliberately simple bot — the seed of component 17. It draws, then plays, then ends its turn.
*
* The draw-first ordering matters: an earlier version never drew, so it burned its three opening
* cards and then held an empty hand for the rest of the game. Every seed still "completed", but
* nothing ever happened — no trains, no revenue. That is why the milestone tests below assert on
* what the game DID, not merely that it terminated.
*/
function playToCompletion(s: GameState, seed = 1, maxTurns = 20_000): PlayStats {
let rng = seed >>> 0;
const pick = <T,>(xs: T[]): T => {
rng = (rng * 1103515245 + 12345) >>> 0;
return xs[rng % xs.length]!;
};
const stats: PlayStats = { turns: 0, scheduled: 0, collisions: 0, cardsPlayed: 0 };
const tally = (events: { type: string }[]): void => {
for (const e of events) {
if (e.type === 'trainScheduled') stats.scheduled++;
if (e.type === 'cardPlayed') stats.cardsPlayed++;
if (e.type === 'revenueChanged' && 'reason' in e && String(e.reason).startsWith('collision')) {
stats.collisions++;
}
}
};
for (; stats.turns < maxTurns; stats.turns++) {
tally(pump(s));
if (s.status === 'finished') break;
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const draws = options.filter((i) => i.type.startsWith('draw.from'));
const plays = options.filter((i) => i.type === 'card.play');
const enders = options.filter(
(i) =>
i.type === 'switch.end' ||
i.type === 'draw.end' ||
i.type === 'loadUnload.end' ||
i.type === 'mainline.clearance',
);
const choice =
draws.length > 0
? pick(draws)
: plays.length > 0
? pick(plays)
: enders.length > 0
? pick(enders)
: pick(options);
const r = applyIntent(s, actor, choice);
assert.ok(r.ok, `bot chose an illegal action: ${choice.type}`);
tally(r.events);
}
return stats;
}
// ---------------------------------------------------------------------------
describe('phase sequencing (Gap 1)', () => {
it('waits for the actor in a player-driven phase', () => {
const s = game();
const r = advance(s);
assert.equal(r.needsInput, true);
assert.equal(s.clock.phase, 'localOps');
assert.equal(s.clock.currentActor, 0);
});
it('runs the phases in order once the player finishes', () => {
const s = game();
const seen: string[] = [];
for (let i = 0; i < 40 && s.status === 'active'; i++) {
const r = advance(s);
seen.push(...r.events.filter((e) => e.type === 'phaseBegan').map(() => s.clock.phase));
if (r.needsInput) {
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
// The opening deal is six cards against a limit of three (§6.2), so a turn cannot be ended
// until the hand is played or discarded down. Only the first turn actually has anything to
// shed; after that this loop is a no-op.
while ((s.decks.hands.get(0) ?? []).length > HAND_LIMIT) {
applyIntent(s, 0, { type: 'card.discard', cardId: s.decks.hands.get(0)![0]!, toSlot: 0 });
}
applyIntent(s, 0, { type: 'draw.end' });
}
}
// Phase-major: the whole sequence runs per Stage, with no player acting in mainline.
assert.ok(seen.includes('newTrain'));
assert.ok(seen.includes('mainline'));
assert.ok(seen.includes('loadUnload'));
});
it('has no current actor on entering the automatic Mainline Phase', () => {
// Entering `mainline` nulls the actor — nobody acts in turn order there (Gap 1). With no
// trains running the phase then completes immediately, so this checks the moment of entry.
const s = game();
s.clock.phase = 'newTrain';
advance(s);
assert.equal(s.clock.phase, 'mainline');
assert.equal(s.clock.currentActor, null);
});
it('advances the Stage and resets per-Stage worker usage', () => {
// §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
const s = game();
const office = s.officeAreas.get(0)!.grid.get('0,0')!;
office.facility!.usedThisStage = { laborers: 2, porters: 1 };
s.clock.phase = 'shiftChange';
advance(s);
assert.deepEqual(office.facility!.usedThisStage, { laborers: 0, porters: 0 });
assert.equal(s.clock.stage, 2);
});
it('rolls over into a new Day after twelve Stages', () => {
const s = game();
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.clock.day, 2);
assert.equal(s.clock.stage, 1);
});
it('passes the Fedora every three Stages', () => {
// §5 — shift changes at Stages 3, 6, 9 and 12. With one player it returns to them.
const s = createGame({
id: 'g',
seed: 3,
config: baseConfig({ mode: 'competitive' }),
playerNames: ['A', 'B', 'C'],
});
const before = s.clock.superintendent;
s.clock.stage = 3;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.clock.superintendent, (before + 1) % 3);
});
it('does not pass the Fedora on a non-shift-change Stage', () => {
const s = createGame({
id: 'g',
seed: 3,
config: baseConfig({ mode: 'competitive' }),
playerNames: ['A', 'B', 'C'],
});
const before = s.clock.superintendent;
s.clock.stage = 4;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.clock.superintendent, before);
});
it('resets the collision count each Day', () => {
// §3.4 — the counter resets at the start of each Day.
const s = game();
s.collisionsToday = 2;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.collisionsToday, 0);
});
});
// ---------------------------------------------------------------------------
describe('Mainline Phase (§8)', () => {
function scheduleTrain(s: GameState, stage: number, number: number): void {
s.timetable[stage - 1] = number;
}
it('makes up a train due this Stage at the correct Division Point', () => {
const s = game();
scheduleTrain(s, 1, 2); // train 2 is even, therefore eastbound
s.clock.phase = 'newTrain';
advance(s);
const trays = [...s.trays.values()];
assert.equal(trays.length, 1);
assert.equal(trays[0]!.trainNumber, 2);
assert.equal(trays[0]!.direction, 'east');
// An eastbound train starts in the west.
assert.deepEqual(trays[0]!.position, { at: 'divisionPoint', side: 'west' });
});
it('holds a train when no Crew Tray is free (§7)', () => {
const s = game();
s.freeTrays = [];
scheduleTrain(s, 1, 2);
s.clock.phase = 'newTrain';
advance(s);
assert.equal(s.trays.size, 0, 'the train is held, not made up');
assert.equal(s.clock.phase, 'mainline', 'and the Stage moves on');
});
it('moves trains one region per Stage (§8.2)', () => {
const s = game();
/**
* PIN THE TERRAIN. This used to rely on whatever card the seed happened to lay down, and the
* moment the RNG stream moved — a different opening deal draws a different number of cards
* before the Division is built — seed 1 dealt a 60 card instead of a 30 and the train crossed in
* one Stage. What is under test is that crossing takes the card's time, so the card has to be
* the test's own: Curves is a 30, which is two Stages for a fast train.
*/
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'curves';
scheduleTrain(s, 1, 2);
s.clock.phase = 'newTrain';
pump(s);
const id = [...s.trays.keys()][0]!;
s.trays.get(id)!.consist = [{ type: 'coach', loaded: false }];
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
const pos = s.trays.get(id)!.position;
assert.equal(pos.at, 'mainline', 'the train highballed onto the mainline');
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
// Q1/Q2 — crossing time is counted in Stages by card speed and Fast/Slow class, so the train
// stays on the card until its transit expires rather than stepping through printed cells.
const pos2 = s.trays.get(id)!.position;
assert.equal(pos2.at, 'mainline', 'still crossing');
});
it('orders movement by train number, Timetabled before Extra on a tie (Gap 5)', () => {
const s = game();
const order: number[] = [];
for (const [i, spec] of [
{ n: 9, extra: true },
{ n: 9, extra: false },
{ n: 3, extra: false },
].entries()) {
s.trays.set(`t${i}`, {
id: `t${i}`,
trainNumber: spec.n,
trainIsExtra: spec.extra,
engineAt: 0,
consist: [],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
movesUsed: 0,
});
}
const sorted = [...s.trays.values()].sort((a, b) => {
const d = (a.trainNumber ?? 0) - (b.trainNumber ?? 0);
return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra);
});
for (const t of sorted) order.push(t.trainIsExtra ? -t.trainNumber! : t.trainNumber!);
assert.deepEqual(order, [3, 9, -9], 'train 3, then Timetabled 9, then Extra X9');
});
});
// ---------------------------------------------------------------------------
describe('collisions are automatic (Gap 2)', () => {
it('collides when a train arrives with every A/D track occupied', () => {
// Gap 2d — no room at the station, the local player's fault, −5 Revenue.
const s = game();
const area = s.officeAreas.get(0)!;
area.adOccupancy = ['blocker']; // a Whistle Post has exactly one A/D track
const id = 'inbound';
s.trays.set(id, {
id,
trainNumber: 2,
trainIsExtra: false,
engineAt: 0,
consist: [{ type: 'coach', loaded: true }],
direction: 'east',
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') {
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
ml.card = 'plains';
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
}
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
assert.equal(s.players[0]!.revenue, -5, 'the fault is the local player’s (§10)');
assert.equal(s.collisionsToday, 1);
assert.ok(!s.trays.has(id), 'the train is removed');
});
it('returns cabooses to the Division Yard and other stock to Classification (Gap 2c)', () => {
const s = game();
s.officeAreas.get(0)!.adOccupancy = ['blocker'];
const classBefore = s.yards.classificationYard.length;
const mlx = s.division.nodes[1];
if (mlx?.kind === 'mainline') {
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
mlx.card = 'plains';
mlx.transits.push({ tray: 'x', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
}
s.trays.set('x', {
id: 'x',
trainNumber: 8,
trainIsExtra: false,
engineAt: 0,
consist: [
{ type: 'hopper', loaded: true },
{ type: 'caboose', loaded: true },
],
direction: 'east',
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
assert.equal(s.yards.classificationYard.length, classBefore + 1, 'the hopper');
assert.ok(
s.yards.divisionYard.some((c) => c.type === 'caboose'),
'the caboose goes straight back to the Division Yard',
);
});
});
// ---------------------------------------------------------------------------
describe('the Superintendent clearance interrupt (§8.1)', () => {
it('pauses the Mainline Phase for a following-train decision', () => {
const s = game();
// A train already on the first Mainline card, moving east.
s.trays.set('ahead', {
id: 'ahead',
trainNumber: 4,
trainIsExtra: false,
engineAt: 0,
consist: [],
direction: 'east',
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') {
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
ml.card = 'plains';
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
}
// A second train at the Western Division Point wanting to follow it.
s.trays.set('behind', {
id: 'behind',
trainNumber: 2,
trainIsExtra: false,
engineAt: 0,
consist: [],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
movesUsed: 0,
});
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
const r = advance(s);
assert.equal(r.needsInput, true, 'the phase must stop and ask');
assert.notEqual(s.clock.pendingDecision, null);
assert.equal(s.clock.pendingDecision!.train, 'behind');
assert.equal(s.clock.pendingDecision!.occupiedBy, 'ahead');
});
it('does not ask when the train ahead is coming the other way — that is an absolute bar', () => {
const s = game();
s.trays.set('oncoming', {
id: 'oncoming',
trainNumber: 3,
trainIsExtra: false,
engineAt: 0,
consist: [],
direction: 'west',
position: { at: 'mainline', index: 1 },
movesUsed: 0,
});
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') {
// Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
ml.card = 'plains';
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
}
s.trays.set('waiting', {
id: 'waiting',
trainNumber: 2,
trainIsExtra: false,
engineAt: 0,
consist: [],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
movesUsed: 0,
});
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
assert.equal(s.clock.pendingDecision, null, 'no judgment call — the train simply holds');
assert.deepEqual(s.trays.get('waiting')!.position, { at: 'divisionPoint', side: 'west' });
});
it('clears the decision once the Superintendent rules', () => {
const s = game();
s.clock.phase = 'mainline';
s.clock.pendingDecision = { train: 'a', occupiedBy: 'b' };
const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
assert.ok(r.ok);
assert.equal(s.clock.pendingDecision, null);
});
});
// ---------------------------------------------------------------------------
describe('victory conditions (§3, Gap 10e)', () => {
it('loses a timed Solitaire game that misses the target', () => {
// The target doubles as a MINIMUM in Solitaire: below it you lose regardless of score.
const s = game(1);
s.clock.day = lengthProfile('short').days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = 0;
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.result, 'loss');
assert.equal(s.outcome!.reason, 'revenueFloor');
});
it('wins a timed Solitaire game that clears the target', () => {
const s = game(1);
s.clock.day = lengthProfile('short').days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = lengthProfile('short').target;
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.result, 'win');
});
it('ends a first-to-target game the moment the target is reached', () => {
const s = game(1, { victory: 'firstToTarget' });
s.players[0]!.revenue = lengthProfile('short').target;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.reason, 'targetReached');
});
});
// ---------------------------------------------------------------------------
describe('MILESTONE: a full solitaire game runs headless', () => {
it('plays a short game to completion', () => {
const s = game(42);
playToCompletion(s, 42);
assert.equal(s.status, 'finished', 'the game must reach an outcome');
assert.ok(s.outcome, 'and record one');
assert.ok(s.clock.day >= 1);
});
it('terminates from many different seeds', () => {
// The driver must settle regardless of how the deck falls.
for (const seed of [1, 7, 23, 99, 256, 1013]) {
const s = game(seed);
playToCompletion(s, seed);
assert.equal(s.status, 'finished', `seed ${seed} did not finish`);
}
});
it('is fully reproducible from a seed', () => {
const a = game(555);
const b = game(555);
playToCompletion(a, 555);
playToCompletion(b, 555);
assert.equal(a.clock.day, b.clock.day);
assert.equal(a.players[0]!.revenue, b.players[0]!.revenue);
assert.deepEqual(a.outcome, b.outcome);
});
it('never offers the bot an illegal action along the way', () => {
// playToCompletion asserts this on every step; this test states the guarantee explicitly.
const s = game(31);
const stats = playToCompletion(s, 31);
assert.ok(stats.turns > 0, 'the game should take at least one turn');
});
it('actually plays the game — trains get scheduled and cards get played', () => {
// GUARD. An earlier bot completed every seed while doing nothing at all: no trains, no
// revenue, every game a loss. Asserting only on termination hid two real bugs. These assert
// the game DEVELOPS.
let scheduled = 0;
let cardsPlayed = 0;
for (const seed of [7, 42, 99, 256]) {
const s = game(seed);
const stats = playToCompletion(s, seed);
scheduled += stats.scheduled;
cardsPlayed += stats.cardsPlayed;
}
assert.ok(scheduled > 0, 'no train was ever scheduled across four games');
assert.ok(cardsPlayed > 4, `only ${cardsPlayed} cards played across four games`);
});
it('terminates even when the Superintendent denies clearance repeatedly', () => {
// A denied ruling must be CONSUMED. Without that the driver re-evaluates the same train and
// asks the same question forever — a livelock that only showed up on one seed.
const s = game(7);
const stats = playToCompletion(s, 7, 5_000);
assert.equal(s.status, 'finished');
assert.ok(stats.turns < 5_000, `took ${stats.turns} turns — probable livelock`);
});
it('conserves rolling stock across a whole game', () => {
// Nothing may be created or destroyed: every piece in the roster, wherever it sits.
const s = game(88);
playToCompletion(s, 88);
let count = s.yards.divisionYard.length + s.yards.classificationYard.length;
for (const tray of s.trays.values()) count += tray.consist.length;
for (const area of s.officeAreas.values()) {
for (const card of area.grid.values()) {
count += card.standing.length;
if (card.facility) {
count += card.facility.outboundBox.length;
count += card.facility.inboundBox.length;
count += card.facility.industryTrack.cars.length;
}
}
}
assert.equal(count, TOTAL_ROLLING_STOCK, 'rolling stock leaked or was duplicated');
});
});
describe('X18 Circus Train — a point for standing still', () => {
it('pays once for a Stage spent stopped, and never again', () => {
/**
* REPORTED: "Circus train TX18 was stopped on a siding for a full Stage and I did not get my
* Revenue point." It never could: `stopEarnsPoint` was declared on the profile and read
* NOWHERE, along with eight other special-train rules. The one card in the deck that pays for
* standing still paid nothing.
*/
const s = game();
s.clock.phase = 'mainline';
s.trays.set('circus', {
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
consist: [], direction: 'east',
position: { at: 'grid', seat: 0, coord: { row: -1, col: 0 } },
movesUsed: 0,
} as never);
// A card under it, so the crew is somewhere real rather than off the grid.
areaOf(s, 0).grid.set('-1,0', {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never);
const before = s.players[0]!.revenue;
const first = pump(s);
assert.ok(
first.some((e) => e.type === 'trainStoodStill' && e.trainNumber === 18),
'the Circus Train stood still for a Stage and earned nothing',
);
assert.equal(s.players[0]!.revenue, before + 1, 'the point was not paid');
// "One turn stopped" — once. A train that goes on standing there does not keep earning.
const paidAgain = () => {
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
return pump(s).some((e) => e.type === 'trainStoodStill');
};
assert.ok(!paidAgain(), 'the Circus Train collected a second time for the same set-up');
});
});
// ---------------------------------------------------------------------------
describe('a train on the Division points the way it is running', () => {
/**
* Nothing reset `facing` when a train left a district, so a crew that had been shunted onto a
* north-south spur carried a compass port — 'n' or 's' — out onto the Division with it.
*
* The Division is east-west, and so is every Office card, so that port exists nowhere the train is
* about to be. `movesFor` explores from `facing` and from its opposite, and a card with neither
* yields nothing at all: the train arrived at the next Office **unable to switch at all**. It also
* drew a ▲ on the Division map, where there is no north.
*
* A train running the Division has its engine at one end of an east-west railroad, so this is not
* a repair applied after the fact — it is the only thing `facing` can mean out there.
*/
const runningOnTheDivision = (spurFacing: 'n' | 's', direction: 'east' | 'west'): CrewTray => {
const s = game();
// Straight onto the Mainline card west of the first Office, as a departure would.
const index = s.division.nodes.findIndex((n) => n.kind === 'mainline');
const node = s.division.nodes[index]!;
assert.equal(node.kind, 'mainline');
const id = 'shunted';
s.trays.set(id, {
id, trainNumber: 2, trainIsExtra: false, engineAt: 0, consist: [],
direction,
// What a Move round a district leaves behind: a real port on the card it was standing on.
facing: spurFacing,
railFacing: 'w',
position: { at: 'grid', seat: 0, coord: areaOf(s, 0).officeCoord },
movesUsed: 0,
});
const tray = s.trays.get(id)!;
// Depart it: the Mainline Phase's own path onto the card.
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
for (let i = 0; i < 3 && tray.position.at === 'grid'; i++) {
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
}
assert.notEqual(tray.position.at, 'grid', 'the train never left the district');
return tray;
};
it('drops the spur port the moment it reaches the Mainline', () => {
for (const spur of ['n', 's'] as const) {
for (const direction of ['east', 'west'] as const) {
const tray = runningOnTheDivision(spur, direction);
assert.equal(
tray.facing,
direction === 'east' ? 'e' : 'w',
`a ${direction}bound train left the district still facing ${spur}`,
);
assert.equal(railFacingOf(tray), direction === 'east' ? 'e' : 'w');
}
}
});
});
// ---------------------------------------------------------------------------
describe('the history says WHY a train moved, and says it truthfully', () => {
/**
* REPORTED from play: "some trains seem to be moving before I can switch or do other operations on
* them — it may be the rules are wrong, or it may be my perception."
*
* It was perception, but the log was actively feeding it. Every departure read alike, and the
* arrival line for an Expedited train said there was "no turn in which to work it" — which is
* false and cost the player the Cargo turn they did have. These pin the two claims the log now
* makes, so a phase-order change cannot leave the narration lying about it.
*/
/**
* Drop one named train at the Western Division Point and follow it, counting the DISTINCT phases
* it spends standing in a district — `advance` is called many times inside one phase, so a raw
* count would say nothing. Driven by the developer bot rather than a hand-rolled phase-ender,
* which deadlocks the moment Local Operations wants an option chosen before it can be ended.
*/
const phasesWith = (trainNumber: number): { localOps: number; loadUnload: number; leftIn: string } => {
const s = game(7, { length: 'standard' });
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
/**
* A DIVISION WITH NOTHING ELSE ON IT. Expedite is not absolute: §8.1 can still hold the train,
* and `shiftChange` says so — "an expedited train that would need a ruling simply stays, and
* runs normally next Stage". With the bot's own traffic running, Train 6 was held four Stages
* and collected four Local Operations turns, which is correct behaviour and the opposite of what
* this test is trying to pin. Clearing the timetable isolates the rule from the traffic.
*/
s.timetable = s.timetable.map(() => null);
const id = 'watched';
s.trays.set(id, {
id, trainNumber, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'coach', loaded: true }] as never,
direction: 'east', facing: 'e',
position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west');
if (dp?.kind === 'divisionPoint') dp.holding.push(id);
const standingIn = new Set<string>();
let reachedOffice = false;
let leftIn = '';
for (let i = 0; i < 20_000; i++) {
const phase = s.clock.phase;
const onGrid = s.trays.get(id)?.position.at === 'grid';
if (onGrid) {
reachedOffice = true;
standingIn.add(`${s.clock.day}|${s.clock.stage}|${phase}`);
}
const r = advance(s);
if (reachedOffice && onGrid && s.trays.get(id)?.position.at !== 'grid' && leftIn === '') {
leftIn = phase;
break;
}
if (s.status === 'finished') break;
if (r.needsInput) {
/**
* A PLAYER WHO DOES NOTHING. The developer bot plays train cards, and a train card rolls
* itself onto the Timetable — so clearing the Timetable above achieved nothing while the bot
* was driving, and the watched train kept meeting traffic it had to be cleared past. This
* ends every turn without playing anything, which is the only way to isolate one train.
*/
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const pick =
options.find((x) => x.type === 'mainline.clearance') ??
options.find(
(x) =>
x.type === 'switch.end' ||
x.type === 'draw.end' ||
x.type === 'loadUnload.end' ||
x.type === 'freightAgent.end',
) ??
options.find((x) => x.type === 'localOps.choose') ??
options[0]!;
if (!applyIntent(s, actor, pick).ok) break;
}
}
const count = (phase: string): number => [...standingIn].filter((k) => k.endsWith(`|${phase}`)).length;
assert.ok(reachedOffice, `train ${trainNumber} never reached a district at all`);
return { localOps: count('localOps'), loadUnload: count('loadUnload'), leftIn };
};
it('gives an ordinary train a Local Operations turn before it goes', () => {
// Train 12 Drag Freight — no Expedite. Arrives in a Mainline Phase, stands, and the player gets
// a Local Operations turn with it in the NEXT Stage. This is what the arrival line promises.
const r = phasesWith(12);
assert.ok(r.localOps >= 1, `an ordinary train got ${r.localOps} Local Operations turns`);
assert.equal(r.leftIn, 'mainline', 'an ordinary train should leave in a Mainline Phase');
});
it('gives an Expedited train the Cargo phase but never Local Operations', () => {
// Train 6 The Sparrow — Expedite. The claim the arrival line makes is precisely this pair:
// Porters and Laborers can reach it, a switching turn never comes, and it goes at the end of
// the Stage rather than in a Mainline Phase.
const r = phasesWith(6);
assert.equal(r.localOps, 0, 'an Expedited train got a Local Operations turn after all');
assert.ok(r.loadUnload >= 1, 'an Expedited train never stood through a Cargo phase');
assert.equal(r.leftIn, 'shiftChange', 'an Expedited train should leave in Supervisor Shift');
});
it('does NOT expedite when §8.1 wants a ruling — it stays and runs normally', () => {
/**
* EXPEDITE IS CONDITIONAL, which is most of why it feels arbitrary at the table.
*
* `shiftChange` says so — "an expedited train that would need a ruling simply stays, and runs
* normally next Stage" — and it happens often: driven by the developer bot this was reached by
* ordinary traffic, and Train 6 collected FOUR Local Operations turns instead of none. Built by
* hand here rather than fished out of a bot game, because whether the bot happens to produce a
* meet depends on the deck, and the deck moves.
*
* So the log and the card must not promise that an Expedited train can never be switched.
*/
const s = game(7, { length: 'standard' });
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
// Train 6 (Expedite) standing at the Office, having just arrived: this is the state
// `arriveAtOffice` leaves behind when it sets `departsThisStage`.
const area = areaOf(s, 0);
const id = 'expedited';
s.trays.set(id, {
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
departsThisStage: true,
});
area.adOccupancy.push(id);
// A train ahead of it in the same Subdivision, running the SAME way — §8.1's fourth condition,
// which is the Superintendent's call rather than an absolute bar.
const ahead = s.division.nodes.findIndex((n) => n.kind === 'mainline');
const node = s.division.nodes[ahead];
assert.equal(node?.kind, 'mainline');
s.trays.set('ahead', {
id: 'ahead', trainNumber: 12, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', facing: 'e', position: { at: 'mainline', index: ahead }, movesUsed: 0,
});
if (node?.kind === 'mainline') {
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
}
s.clock.phase = 'shiftChange';
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id),
'the Expedited departure was not put to the Superintendent',
);
assert.equal(
s.trays.get(id)?.position.at,
'grid',
'the Expedited train left despite needing a ruling nobody could give',
);
assert.ok(
r.events.some((e) => e.type === 'trainHeld' && /EXPEDITES/.test(e.reason)),
'the log does not say why the Expedited train stayed',
);
assert.equal(
s.trays.get(id)?.departsThisStage,
false,
'it should run as an ordinary train from now on, not retry the expedited departure',
);
});
});
// ---------------------------------------------------------------------------
describe('an Extra starts where its number sends it, or at a Control Point', () => {
/**
* REPORTED: "Extras should start at Eastern or Western Division point based on their numbers. Even
* trains run to the east (start at western DP), odd run to the west (start at eastern DP). They
* can also start at a control point (any office except whistlepost) at player's choice."
*
* Every Extra used to launch eastbound from the West Division Point, hardcoded, with the
* simplification flagged in a comment — so half the Extras ran the wrong way and the Control Point
* option did not exist at all.
*/
const pending = (trainNumber: number, tier?: 'depot' | 'station'): GameState => {
const s = game(11);
s.pendingExtras = [trainNumber];
s.timetable = s.timetable.map(() => null);
if (tier) s.officeAreas.get(0)!.tier = tier;
s.clock.phase = 'newTrain';
advance(s);
return s;
};
const started = (s: GameState): CrewTray => {
const tray = [...s.trays.values()].find((t) => t.trainIsExtra);
assert.ok(tray, 'the Extra never took a Crew Tray');
return tray;
};
it('stops for the decision instead of launching the train itself', () => {
const s = pending(17);
assert.equal(s.clock.phase, 'newTrain');
assert.equal(s.trays.size, 0, 'the Extra was placed without anyone choosing where');
assert.ok(
legalActions(s, s.clock.currentActor ?? 0).some((i) => i.type === 'newTrain.startExtra'),
'the placement was never offered',
);
});
it('sends an odd Extra west from the EASTERN Division Point', () => {
// §2.3 — odd runs west. It therefore starts at the end it runs away from.
const s = pending(17);
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }).ok);
const tray = started(s);
assert.equal(tray.direction, 'west');
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'east');
});
it('sends an even Extra east from the WESTERN Division Point', () => {
const s = pending(18);
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: null }).ok);
const tray = started(s);
assert.equal(tray.direction, 'east');
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'west');
});
it('refuses a Whistle Post, which is not a Control Point', () => {
const s = pending(18);
assert.equal(s.officeAreas.get(0)!.tier, 'whistlePost');
assert.equal(
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: 0 }),
'NOT_A_CONTROL_POINT',
);
});
it('starts at a Control Point when the player picks one, taking an A/D track', () => {
// Upgrading the Office is what buys this: a Depot is a Control Point, a Whistle Post is not.
const s = pending(18, 'depot');
const r = applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, atSeat: 0 });
assert.ok(r.ok, `starting at the Depot was refused: ${r.ok ? '' : r.code}`);
const tray = started(s);
const area = s.officeAreas.get(0)!;
assert.equal(tray.position.at, 'grid');
assert.deepEqual(
tray.position.at === 'grid' ? tray.position.coord : null,
area.officeCoord,
'the Extra did not start on the Office card',
);
assert.ok(area.adOccupancy.includes(tray.id), 'it did not take an A/D track');
assert.equal(tray.direction, 'east', 'an even Extra still runs east from wherever it starts');
});
it('takes the Extra off the pending list once, whichever end it started from', () => {
const s = pending(17);
assert.ok(applyIntent(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }).ok);
assert.deepEqual(s.pendingExtras, []);
assert.equal(
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 17, atSeat: null }),
'NO_EXTRA_PENDING',
);
});
});