Files
station-master/test/advance.test.ts
T
Jesse.MarkowitzandClaude Opus 5 4adf149ba5 v0.8.0.10 — playtest fixes: clearance rulings, the log, the map, and a save file
From the first two multiplayer playtests of v0.8.0.9, each traced before fixing.

The engine:

- A train on a card BEHIND the one departing no longer triggers a clearance
  ruling or an opposite-direction bar (#26). Reproduced from the exported
  save: X15 was held over X18 behind it, and X18 then collided into the full
  Whistle Post. Games in progress holding a ruling the engine no longer asks
  for will not resume (28 of 40 recorded four-seat games); shipped as is at
  Jesse's call.
- `mainlineModified` carries the card's previous kind, so the log can say
  what a Realignment converted (#27).

The screen:

- The turn chart and the Division map name the player whose move is on
  screen while bot turns replay, not the live actor (#25).
- The owning player's name is no longer outlined by the turn arrow's stroke,
  which made it unreadable (#24).
- A Mainline card flashes on the map when a Realignment changes it (#28).
- The history is held back with the board and revealed step by step, instead
  of arriving whole while the board is still catching up (#29).
- A ruling made by holding the office reads "Superintendent Player X" (#30),
  and no line names a player twice (#31).
- A seated player can download their own game as a save file: the play
  page's Save replay button, fed by GET /api/save?token=… (#32). The StartOS
  action cannot do this — an action result is text only.

Closes #24
Closes #25
Closes #26
Closes #27
Closes #28
Closes #29
Closes #30
Closes #31
Closes #32

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-15 22:44:20 -04:00

1585 lines
67 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, isBeingMadeUp } from '../src/engine/apply.ts';
import { EXPEDITE_FAULT_PENALTY, HAND_LIMIT, MAX_CONSIST, STAGES_PER_DAY, 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, DivisionNode, GameConfig, GameState } from '../src/engine/state.ts';
import { coordKey, railFacingOf } from '../src/engine/state.ts';
const baseConfig = (over: Partial<GameConfig> = {}): GameConfig => ({
mode: 'solitaire',
days: 3,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: 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;
/**
* §3.3, EXTENDED PLAY (Gitea#11) — this harness plays the timetable it was dealt.
*
* It picks at random among legal options, and one of the two options here is "play another
* Day" — so left alone it would extend the game forever and terminate only on `maxTurns`. That
* is not a bug in the feature: a game genuinely does not end now until somebody says stop, and
* `developerBot` says stop for exactly this reason. Said explicitly here rather than folded
* into the random pick, because "how does this loop terminate" deserves an answer in the loop.
*/
if (s.status === 'awaitingExtension') {
const r = applyIntent(s, 0, { type: 'game.extend', player: 0, agree: false });
assert.ok(r.ok, 'a solitaire player could not decline an extension');
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('does not collide when a coach is legally parked at the Office (v0.5.0 §A.4 exception)', () => {
// A coach may now be set out at the Office (§A.4's carve-out). It must not become a hazard to
// the next train in — that would punish exactly the thing the rule was written to allow.
const s = game();
const area = s.officeAreas.get(0)!;
area.adOccupancy = []; // an A/D track is free — only the fouling check is under test
const officeCard = area.grid.get(coordKey(area.officeCoord))!;
officeCard.standing = [{ type: 'coach', loaded: false }];
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') {
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.collisionsToday, 0, 'a legally parked coach is not a hazard to the next arrival');
assert.ok(s.trays.has(id), 'the train is not written off');
});
it('still collides when a non-coach car is left fouling the Office track', () => {
// Reachable only by poking state directly — `canDropCarsAt` already refuses every non-coach
// drop at the Office, so this backstops the collision check itself, not a state a legal game
// can reach.
const s = game();
const area = s.officeAreas.get(0)!;
area.adOccupancy = [];
const officeCard = area.grid.get(coordKey(area.officeCoord))!;
officeCard.standing = [{ type: 'boxcar', loaded: false }];
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') {
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.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 as { occupiedBy: string }).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 = { kind: 'clearance', 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) — unified 2026-08-20', () => {
it('loses a timed Solitaire game that misses minCombinedRevenue', () => {
// minCombinedRevenue doubles as a MINIMUM in Solitaire: below it you lose regardless of score.
const s = game(1, { minCombinedRevenue: 10 });
s.clock.day = s.config.days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = 0;
advance(s);
// PAUSES rather than finishes since Gitea#11: a days-based ending offers another Day, and the
// result is recorded either way. `official` is the frozen answer; `status` is only where play
// has got to. The two collision tests at the foot of this block are the contrast.
assert.equal(s.status, 'awaitingExtension');
assert.equal(s.outcome!.result, 'loss');
assert.equal(s.outcome!.reason, 'revenueFloor');
assert.equal(s.official!.outcome.reason, 'revenueFloor');
});
it('wins a timed Solitaire game that clears minCombinedRevenue', () => {
const s = game(1, { minCombinedRevenue: 10 });
s.clock.day = s.config.days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = 10;
advance(s);
assert.equal(s.status, 'awaitingExtension');
assert.equal(s.outcome!.result, 'win');
});
it('minCombinedRevenue = 0 disables the floor — any score wins once days run out', () => {
const s = game(1, { minCombinedRevenue: 0 });
s.clock.day = s.config.days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = 0;
advance(s);
assert.equal(s.status, 'awaitingExtension');
assert.equal(s.outcome!.result, 'win');
});
it('everyone loses in Competitive when the TABLE misses minCombinedRevenue, whoever scored best', () => {
const s = createGame({
id: 'g', seed: 2, config: baseConfig({ mode: 'competitive', minCombinedRevenue: 20 }),
playerNames: ['A', 'B'],
});
s.clock.day = s.config.days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = 5; // best individual score...
s.players[1]!.revenue = 3; // ...but combined (8) still misses the floor (20).
advance(s);
assert.equal(s.status, 'awaitingExtension');
assert.equal(s.outcome!.result, 'loss');
assert.equal(s.outcome!.reason, 'revenueFloor');
});
it('Co-op wins as one table score (summed Revenue), winner stays null', () => {
const s = createGame({
id: 'g', seed: 3, config: baseConfig({ mode: 'coop', minCombinedRevenue: 10 }),
playerNames: ['A', 'B'],
});
s.clock.day = s.config.days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
s.players[0]!.revenue = 4;
s.players[1]!.revenue = 6; // combined 10 clears the floor, neither alone would.
advance(s);
assert.equal(s.status, 'awaitingExtension');
assert.equal(s.outcome!.result, 'win');
assert.equal(s.outcome!.winner, null, 'Co-op names an individual winner instead of a shared one');
});
it('ends immediately, mid-Stage, when collisions reach maxCollisionsPerDay', () => {
// Competitive/coop only, and immediate — not gated on the day boundary the way the revenue
// floor is. Stage 1, well short of STAGES_PER_DAY, proves it fires mid-day.
const s = game(1, { mode: 'competitive', maxCollisionsPerDay: 2 });
s.collisionsToday = 2;
s.clock.stage = 1;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.result, 'loss');
assert.equal(s.outcome!.reason, 'collisionFloor');
});
it('ends immediately when the running total reaches maxCollisionsTotal, even under the per-day cap', () => {
const s = game(1, { mode: 'coop', maxCollisionsPerDay: 0, maxCollisionsTotal: 3 });
s.collisionsTotal = 3;
s.clock.stage = 1;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.reason, 'collisionFloor');
});
it('maxCollisionsPerDay = 0 and maxCollisionsTotal = 0 disable the collision floor entirely', () => {
const s = game(1, { mode: 'competitive', maxCollisionsPerDay: 0, maxCollisionsTotal: 0 });
s.collisionsToday = 99;
s.collisionsTotal = 99;
s.clock.stage = 1;
s.clock.phase = 'shiftChange';
advance(s);
assert.notEqual(s.status, 'finished');
});
it('solitaire checks the collision floor too, like every other mode', () => {
/**
* REVERSED 2026-08-30, and this test previously asserted the opposite ("solitaire never checks
* the collision floor, whatever the counts").
*
* The exclusion was never a stated rule — §3.4 does not carve solitaire out — and nothing on
* screen reflected it: `SOLO_CONFIG` carried both limits, the New Game dialog offered them as
* live settings, and the text beside them said the game would end in a loss. A solitaire player
* could set a limit of 1 and crash all game. Found reviewing that screen's wording; Jesse's
* ruling is that the settings do what they say.
*/
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 1, maxCollisionsTotal: 1 });
s.collisionsToday = 99;
s.collisionsTotal = 99;
s.clock.stage = 1;
s.clock.phase = 'shiftChange';
advance(s);
assert.equal(s.status, 'finished');
assert.equal(s.outcome!.reason, 'collisionFloor');
});
it('still lets a solitaire game switch the collision floor off with 0', () => {
// The disable path is what a player who does not want the new ending reaches for, so it has to
// work at one seat exactly as it does at four.
const s = game(1, { mode: 'solitaire', maxCollisionsPerDay: 0, maxCollisionsTotal: 0 });
s.collisionsToday = 99;
s.collisionsTotal = 99;
s.clock.stage = 1;
s.clock.phase = 'shiftChange';
advance(s);
assert.notEqual(s.status, 'finished');
});
});
// ---------------------------------------------------------------------------
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 / X17 Campaign — a point for setting up (Gitea#13)', () => {
/**
* REPORTED originally: "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.
*
* REDEFINED by Gitea#13 (Jesse, 2026-08-29), and these tests carry the three parts of that
* ruling: the point is paid ONCE PER OFFICE AREA rather than once per game, only when the train
* is FULLY LOADED, and only in an Office Area at all.
*/
const circusAt = (s: GameState, seat: number, coord: { row: number; col: number }, consist: unknown[]) => {
s.clock.phase = 'mainline';
s.trays.set('circus', {
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
consist, direction: 'east',
position: { at: 'grid', seat, coord },
movesUsed: 0,
} as never);
// A card under it, so the crew is somewhere real rather than off the grid.
areaOf(s, seat as never).grid.set(`${coord.row},${coord.col}`, {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never);
};
const loaded = [
{ type: 'boxcar', loaded: true },
{ type: 'boxcar', loaded: true },
{ type: 'coach', loaded: true },
{ type: 'caboose', loaded: true },
];
const runPhase = (s: GameState) => {
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
return pump(s);
};
it('pays a fully loaded Circus for a Stage spent set up', () => {
const s = game();
circusAt(s, 0, { row: -1, col: 0 }, loaded);
const before = s.players[0]!.revenue;
assert.ok(
pump(s).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');
});
it('pays once per Office Area, however long it parks there', () => {
// "Once per stop in an office area" — a train that goes on standing in the same district does
// not keep earning. This is the half that was already true, for a different reason.
const s = game();
circusAt(s, 0, { row: -1, col: 0 }, loaded);
pump(s);
assert.ok(!runPhase(s).some((e) => e.type === 'trainStoodStill'),
'the Circus collected twice for the same set-up');
assert.ok(!runPhase(s).some((e) => e.type === 'trainStoodStill'),
'the Circus collected a third time for the same set-up');
});
it('pays AGAIN in a different district — each player can be visited', () => {
/**
* The half that is new. "In a multiplayer game, each player could score if the circus stops in
* their area" — so the claim is per seat, and a touring Circus is paid by each district it sets
* up in. Before Gitea#13 this paid once per GAME and the second district got nothing.
*/
const s = createGame({
id: 'g', seed: 5, config: baseConfig({ mode: 'competitive' }), playerNames: ['A', 'B'],
});
circusAt(s, 0, { row: -1, col: 0 }, loaded);
pump(s);
const paidFirst = s.players.map((p) => p.revenue);
// The same train, moved into the other player's district.
const tray = s.trays.get('circus')!;
areaOf(s, 1 as never).grid.set('-1,0', {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never);
tray.position = { at: 'grid', seat: 1, coord: { row: -1, col: 0 } } as never;
assert.ok(runPhase(s).some((e) => e.type === 'trainStoodStill'),
'the Circus set up in a second district and earned nothing');
const owner = s.seating[1]!;
assert.equal(
s.players[owner]!.revenue,
paidFirst[owner]! + 1,
'the point did not go to whoever sits in the district it stopped in',
);
});
it('pays nothing when the cars are empty — "not much of a circus"', () => {
const s = game();
circusAt(s, 0, { row: -1, col: 0 }, [
{ type: 'boxcar', loaded: false },
{ type: 'coach', loaded: true },
{ type: 'caboose', loaded: true },
]);
const before = s.players[0]!.revenue;
assert.ok(!pump(s).some((e) => e.type === 'trainStoodStill'),
'an empty car aboard still collected the set-up point');
assert.equal(s.players[0]!.revenue, before, 'Revenue moved for a train that was not full');
});
it('pays nothing to a train carrying nothing at all', () => {
// `every` on an empty list is vacuously true, so the emptiest train of the lot is exactly the
// one a careless test would pay.
const s = game();
circusAt(s, 0, { row: -1, col: 0 }, []);
assert.ok(!pump(s).some((e) => e.type === 'trainStoodStill'),
'a Circus carrying nothing was paid for setting up');
});
it('pays nothing for standing out on the Mainline', () => {
/**
* It used to, and it misattributed the point: `playerAtSeat` needs a seat, there is none off
* the grid, and the fallback handed it to PLAYER 0 wherever the train was standing. Jesse's
* ruling scopes the rule to Office Areas, which removes the bug rather than patching it.
*/
const s = game();
s.clock.phase = 'mainline';
const index = s.division.nodes.findIndex((n) => n.kind === 'mainline');
s.trays.set('circus', {
id: 'circus', trainNumber: 18, trainIsExtra: true, engineAt: 0,
consist: loaded, direction: 'east',
position: { at: 'mainline', index },
movesUsed: 0,
} as never);
const before = s.players[0]!.revenue;
pump(s);
assert.equal(s.players[0]!.revenue, before, 'a Mainline set-up paid a point');
});
it('pays the Campaign Train only when its candidate is aboard', () => {
// X17 carries one coach and no freight, so "fully loaded" is exactly "the coach is occupied".
// It earned nothing at all before Gitea#13 — it had `stopThenExpedite` and no scoring rule.
const occupied = game();
circusAt(occupied, 0, { row: -1, col: 0 }, [{ type: 'coach', loaded: true }]);
occupied.trays.get('circus')!.trainNumber = 17;
const beforeOccupied = occupied.players[0]!.revenue;
pump(occupied);
assert.equal(occupied.players[0]!.revenue, beforeOccupied + 1, 'a full Campaign Train earned nothing');
const empty = game();
circusAt(empty, 0, { row: -1, col: 0 }, [{ type: 'coach', loaded: false }]);
empty.trays.get('circus')!.trainNumber = 17;
const beforeEmpty = empty.players[0]!.revenue;
pump(empty);
assert.equal(empty.players[0]!.revenue, beforeEmpty, 'an empty Campaign Train was paid for its speech');
});
});
// ---------------------------------------------------------------------------
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 was
* false under the OLD (v0.4.8 and earlier) same-stage-departure reading of Q3, and cost the player
* the Cargo turn they did have.
*
* v0.4.9 CORRECTED Q3 ITSELF, not just the narration: Expedite does not change when a train
* leaves — it is released by the ordinary §8.1 rules exactly like any other train, and may be
* switched normally while it stands. What it means is that the train must not be PARKED anywhere
* but the station; leaving it on Secondary Track when a Mainline Phase begins is a Station Master
* fault (`expediteFault`, `EXPEDITE_FAULT_PENALTY`). These pin that behaviour, so a phase-order
* change cannot silently bring back the old forced departure.
*/
/**
* 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, { days: 5 });
for (const n of s.division.nodes) if (n.kind === 'mainline') n.card = 'plains';
/**
* A DIVISION WITH NOTHING ELSE ON IT. §8.1 can still hold any train, Expedited or not, on a
* same-direction meet — with the bot's own traffic running, Train 6 was held four Stages and
* collected four Local Operations turns. Correct behaviour, but it is traffic noise for what
* this test is trying to pin, so clearing the timetable isolates the rule from it.
*/
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 a Local Operations turn too, exactly like an ordinary one', () => {
// Train 6 The Sparrow — Expedite. v0.4.9 corrected Q3: Expedite no longer forces a same-stage
// departure, so an Expedited train is released by the ordinary §8.1 rule and stands for a
// Local Operations turn the same as Train 12 above.
const r = phasesWith(6);
assert.ok(r.localOps >= 1, `an Expedited train got ${r.localOps} Local Operations turns`);
assert.ok(r.loadUnload >= 1, 'an Expedited train never stood through a Cargo phase');
assert.equal(r.leftIn, 'mainline', 'an Expedited train should leave in a Mainline Phase, like any other');
});
it('faults an Expedited train left off the station when a Mainline Phase begins', () => {
// What Expedite actually restricts now: not WHEN the train leaves, but WHERE it may be left
// standing in the meantime. Parked on Secondary Track — cleared there to make way for other
// switching, say — it must not still be there when the next Mainline Phase begins.
const s = game(7, { days: 5 });
const area = areaOf(s, 0);
const id = 'expedited';
const secondary = { row: area.officeCoord.row, col: area.officeCoord.col + 1 };
s.trays.set(id, {
id, trainNumber: 6, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: secondary }, movesUsed: 0,
});
s.clock.phase = 'mainline';
const before = s.players[0]!.revenue;
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'expediteFault' && e.trainNumber === 6),
'no fault was raised for an Expedited train left off the station',
);
assert.equal(
s.players[0]!.revenue,
before - EXPEDITE_FAULT_PENALTY,
'the fault did not cost Revenue',
);
});
it('does not fault an Expedited train legitimately held at the station itself', () => {
// A train sitting on the Office square is not "left" anywhere — including one §8.1 is holding
// for a same-direction meet, which is an ordinary hold, not a Station Master failure.
const s = game(7, { days: 5 });
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,
});
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.
//
// AHEAD MEANS EAST OF THE OFFICE for this eastbound train. This used to take the FIRST Mainline card
// in the Division, which is west of the Office — behind the train — and still expected a ruling,
// which is exactly the fault Gitea#26 reported. The card is now one the train would actually follow.
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
const ahead = s.division.nodes.findIndex((n, i) => i > office && 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 = 'mainline';
const before = s.players[0]!.revenue;
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === id),
'the departure was not put to the Superintendent',
);
assert.ok(
!r.events.some((e) => e.type === 'expediteFault'),
'a train properly parked at the station was faulted anyway',
);
assert.equal(
s.players[0]!.revenue,
before,
'Revenue changed for a train legitimately held at the station',
);
});
});
// ---------------------------------------------------------------------------
describe('an Extra starts where the player puts it (Gitea#4)', () => {
/**
* REPORTED, v0.4.9e: "When extras are played the player doing so may choose where the extra
* starts. They may choose either division point. And if the interchange mainline card has been
* played, they may start the extra on that card and choose the direction from there. If there is
* potential for conflict with other trains in that area the superintendent may hold the extra."
*
* This SUPERSEDES the earlier ruling these tests used to assert — "the number decides, like
* everything else on the timetable" — for Extras only. The number still decides for a timetabled
* train. The reason the two cannot both hold: an odd (westbound) Extra placed at the WEST end
* would leave the Division on its first move having crossed nothing, and be paid for the run.
*
* So: the start decides the direction. A Division Point runs the train away from itself; in the
* middle of the railroad — an Interchange, a Control Point — the player says which way.
*/
const pending = (trainNumber: number, tier?: 'depot' | 'station'): GameState => {
const s = game(11);
// Queued by seat 0, who is therefore the one §7 lets place it.
s.pendingExtras = [{ trainNumber, player: 0 }];
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;
};
/** Turn one Mainline card into an Interchange, and say which node it is. */
const withInterchange = (s: GameState): number => {
const i = s.division.nodes.findIndex((n) => n.kind === 'mainline');
const node = s.division.nodes[i]!;
if (node.kind === 'mainline') node.card = 'interchange';
return i;
};
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('offers BOTH Division Points, not the one the number would dictate', () => {
const s = pending(17);
const sides = legalActions(s, 0)
.filter((i) => i.type === 'newTrain.startExtra' && i.start?.kind === 'divisionPoint')
.map((i) => (i.type === 'newTrain.startExtra' && i.start?.kind === 'divisionPoint' ? i.start.side : ''));
assert.deepEqual([...sides].sort(), ['east', 'west']);
});
it('runs an Extra AWAY from the Division Point it was placed at, whatever its number', () => {
// X17 is odd. Under the superseded rule it could only ever start at the East end and run west.
for (const [side, direction] of [['west', 'east'], ['east', 'west']] as const) {
const s = pending(17);
const r = applyIntent(s, 0, {
type: 'newTrain.startExtra', trainNumber: 17, start: { kind: 'divisionPoint', side },
});
assert.ok(r.ok, `the ${side} Division Point was refused: ${r.ok ? '' : r.code}`);
const tray = started(s);
assert.equal(tray.direction, direction, `an Extra at the ${side} end must run ${direction}`);
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, side);
}
});
it('refuses a Whistle Post, which is not a Control Point, at every setting of the house rule', () => {
for (const extraStart of ['divisionPointsOnly', 'ownOffice', 'anyOffice'] as const) {
const s = pending(18);
s.config = { ...s.config, houseRules: { ...s.config.houseRules, extraStart } };
assert.equal(s.officeAreas.get(0)!.tier, 'whistlePost');
const code = check(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'east',
});
assert.ok(code !== null, `a Whistle Post was allowed under ${extraStart}`);
}
});
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, start: { kind: 'office', seat: 0 }, direction: 'west',
});
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');
// The point of the change: an EVEN Extra running WEST, because the player said so.
assert.equal(tray.direction, 'west', 'the direction the player chose was not honoured');
});
it('needs a direction anywhere that is not an end of the Division', () => {
const s = pending(18, 'depot');
assert.equal(
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 } }),
'NO_DIRECTION_CHOSEN',
);
});
it('honours the extraStart house rule for Office starts, and never for the shared ground', () => {
for (const [extraStart, code] of [
['divisionPointsOnly', 'OFFICE_STARTS_NOT_ALLOWED'],
['anyOffice', null],
] as const) {
const s = pending(18, 'depot');
s.config = { ...s.config, houseRules: { ...s.config.houseRules, extraStart } };
assert.equal(
check(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'east',
}),
code,
`office start under ${extraStart}`,
);
// The Division Points belong to nobody, so no setting ever closes them.
assert.equal(
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'divisionPoint', side: 'west' } }),
null,
);
}
});
it('starts at an Interchange in the players yard, not out on the running line', () => {
const s = pending(18);
const node = withInterchange(s);
const r = applyIntent(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
});
assert.ok(r.ok, `the Interchange was refused: ${r.ok ? '' : r.code}`);
const tray = started(s);
const card = s.division.nodes[node]!;
assert.equal(tray.direction, 'east');
assert.deepEqual(tray.position, { at: 'mainline', index: node });
assert.ok(card.kind === 'mainline' && card.holding?.includes(tray.id), 'it is not in the yard');
assert.equal(card.kind === 'mainline' && card.transits.length, 0, 'it was put on the running line');
});
it('refuses any Mainline card that is not an Interchange', () => {
const s = pending(18);
const plains = s.division.nodes.findIndex((n) => n.kind === 'mainline');
const node = s.division.nodes[plains]!;
if (node.kind === 'mainline') node.card = 'plains';
assert.equal(
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node: plains }, direction: 'east' }),
'NOT_AN_INTERCHANGE',
);
});
it('may be started at an Interchange however busy the card is — the yard forces no collision', () => {
const s = pending(18);
const node = withInterchange(s);
const card = s.division.nodes[node]!;
// Nose to tail with opposing traffic. §7: placing here still must not force a collision.
if (card.kind === 'mainline') {
card.transits.push({ tray: 'tray9', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
}
assert.equal(
check(s, 0, { type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east' }),
null,
);
});
/**
* §7's last clause, and the reason the Interchange start is modelled as a yard at all: "If there
* is potential for conflict with other trains in that area the superintendent may hold the extra."
*
* Jesse's split: a GUARANTEED collision holds the train at the Interchange for another Stage and
* it tries again; a POTENTIAL one is the Superintendent's to rule on. Those are exactly §8.1's
* two answers, so the Extra highballs out of the yard through `evaluateClearance` — the same
* check a train leaving a Division Point goes through — rather than through anything new.
*/
describe('highballing out of the Interchange yard', () => {
/** A pending X18 sitting in the yard of an Interchange, with the terrain pinned. */
const inYard = (): { s: GameState; node: number; tray: CrewTray } => {
const s = pending(18);
const node = withInterchange(s);
const r = applyIntent(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
});
assert.ok(r.ok, `the Interchange was refused: ${r.ok ? '' : r.code}`);
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
return { s, node, tray: started(s) };
};
const card = (s: GameState, node: number): Extract<DivisionNode, { kind: 'mainline' }> => {
const n = s.division.nodes[node]!;
assert.equal(n.kind, 'mainline');
return n as Extract<DivisionNode, { kind: 'mainline' }>;
};
it('pulls out onto the card at the next Mainline Phase when the Subdivision is clear', () => {
const { s, node, tray } = inYard();
advance(s);
const n = card(s, node);
assert.deepEqual(n.holding, [], 'it never left the yard');
assert.ok(n.transits.some((t) => t.tray === tray.id), 'it is not on the running line');
});
it('is held in the yard by a facing train, and tries again the next Stage', () => {
const { s, node, tray } = inYard();
// Westbound, against an eastbound Extra: §8.1 calls that an absolute bar, not a judgment call.
card(s, node).transits.push({ tray: 'facing', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
s.trays.set('facing', {
id: 'facing', trainNumber: 3, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'west', position: { at: 'mainline', index: node }, movesUsed: 0,
});
const r = advance(s);
assert.equal(r.needsInput ?? false, false, 'a guaranteed collision is not a question to ask');
assert.equal(s.clock.pendingDecision, null);
assert.ok(card(s, node).holding?.includes(tray.id), 'it was not held in the yard');
assert.ok(
!card(s, node).transits.some((t) => t.tray === tray.id),
'it pulled out in front of a train coming the other way',
);
assert.ok(s.trays.has(tray.id), 'the Extra was destroyed rather than held');
// AND IT TRIES AGAIN. The Extra waits in the yard, not on the pending list, so once the road
// clears the next Mainline Phase takes it out with no further intervention.
s.trays.delete('facing');
card(s, node).transits = [];
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
assert.deepEqual(card(s, node).holding, [], 'it did not try again once the road was clear');
assert.ok(card(s, node).transits.some((t) => t.tray === tray.id), 'it never pulled out');
});
it('puts a following train to the Superintendent rather than holding it automatically', () => {
const { s, node, tray } = inYard();
// Same direction: §8.1's judgment call, which is what "may hold the extra" means.
card(s, node).transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
s.trays.set('ahead', {
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', position: { at: 'mainline', index: node }, movesUsed: 0,
});
const r = advance(s);
assert.equal(r.needsInput, true, 'the phase must stop and ask');
assert.equal(s.clock.pendingDecision?.train, tray.id);
assert.equal((s.clock.pendingDecision as { occupiedBy: string } | null)?.occupiedBy, 'ahead');
// HOLD keeps it in the yard.
assert.ok(applyIntent(s, s.clock.superintendent, { type: 'mainline.clearance', allow: false }).ok);
advance(s);
assert.ok(card(s, node).holding?.includes(tray.id), 'the Superintendent held it and it left anyway');
});
});
/**
* FOUND BY PLAYING IT, not by the tests above: an Extra started anywhere but a Division Point was
* never offered a car and ran empty.
*
* `isBeingMadeUp` asked only "is this tray standing at a Division Point", which was the whole
* truth while that was the only place to build a train. The Control Point start has therefore
* shipped since it was added with a train that could not be loaded, and the Interchange start
* would have shipped the same way — against Jesse's report, which says an Extra started at the
* Interchange "would be Loaded with cars".
*/
describe('an Extra started away from a Division Point can still be made up', () => {
const fill = (s: GameState): string[] => {
for (let i = 0; i < MAX_CONSIST + 1; i++) {
const options = legalActions(s, s.clock.currentActor ?? 0).filter((a) => a.type === 'newTrain.placeCar');
if (options.length === 0) break;
assert.ok(applyIntent(s, s.clock.currentActor ?? 0, options[0]!).ok);
}
return started(s).consist.map((c) => c.type);
};
it('takes a consist in the Interchange yard', () => {
const s = pending(18);
const node = withInterchange(s);
assert.ok(applyIntent(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
}).ok);
assert.ok(fill(s).length > 0, 'the Extra was never offered a car and would have run empty');
});
it('takes a consist at a Control Point', () => {
const s = pending(18, 'depot');
assert.ok(applyIntent(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'office', seat: 0 }, direction: 'east',
}).ok);
assert.ok(fill(s).length > 0, 'the Extra was never offered a car and would have run empty');
});
it('stops being made up the moment it starts running', () => {
// Otherwise a train out on the Mainline could be handed cars from the Division Yard — the
// "cars appearing on a train nobody was making up" bug `isBeingMadeUp` exists to prevent.
const s = pending(18);
const node = withInterchange(s);
assert.ok(applyIntent(s, 0, {
type: 'newTrain.startExtra', trainNumber: 18, start: { kind: 'mainline', node }, direction: 'east',
}).ok);
const tray = started(s);
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
advance(s);
assert.equal(s.trays.get(tray.id)?.beingMadeUp, undefined, 'a running train is still being made up');
assert.equal(isBeingMadeUp(s.trays.get(tray.id)!), false);
});
});
it('replays a save written before the choice existed exactly as it meant it', () => {
/**
* A save is a seed and a list of intents, so an intent whose meaning moves is a save that
* quietly replays as a different game. The legacy shape carried only `atSeat`: null meant the
* Division Point the NUMBER sent it to, running in the number's direction.
*/
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', 'the legacy intent stopped meaning what it meant');
assert.equal(tray.position.at === 'divisionPoint' && tray.position.side, 'east');
});
it('takes the Extra off the pending list once, whichever end it started from', () => {
const s = pending(17);
const at = { type: 'newTrain.startExtra', trainNumber: 17, start: { kind: 'divisionPoint', side: 'east' } } as const;
assert.ok(applyIntent(s, 0, at).ok);
assert.deepEqual(s.pendingExtras, []);
assert.equal(check(s, 0, at), 'NO_EXTRA_PENDING');
});
});
// ---------------------------------------------------------------------------
describe('§8.1 counts only trains AHEAD of the one departing (Gitea#26)', () => {
/**
* REPORTED from playtesting v0.8.0.9: two westbound Extras, X15 at an Office and X18 still crossing
* the card to its EAST. The Superintendent was asked to rule on X15 against X18 — a train behind it —
* and holding X15 kept the Whistle Post's only A/D track full, so X18 arrived into it and was
* destroyed. Reproduced by replaying the exported save; the positions below are that situation in a
* one-seat Division, where every Office is a Whistle Post and the Subdivision spans them all.
*/
const setup = (occupant: { direction: 'east' | 'west'; side: 'east' | 'west'; number: number }) => {
const s = game(7, { days: 5 });
const area = areaOf(s, 0);
const office = s.division.nodes.findIndex((n) => n.kind === 'office' && n.seat === 0);
s.trays.set('departing', {
id: 'departing', trainNumber: 15, trainIsExtra: true, engineAt: 0, consist: [],
direction: 'west', facing: 'w',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
});
area.adOccupancy.push('departing');
const card = s.division.nodes.findIndex((n, i) =>
n.kind === 'mainline' && (occupant.side === 'east' ? i > office : i < office));
const node = s.division.nodes[card];
assert.equal(node?.kind, 'mainline');
s.trays.set('other', {
id: 'other', trainNumber: occupant.number, trainIsExtra: true, engineAt: 0, consist: [],
direction: occupant.direction, facing: occupant.direction === 'east' ? 'e' : 'w',
position: { at: 'mainline', index: card }, movesUsed: 0,
});
if (node?.kind === 'mainline') {
node.transits.push({ tray: 'other', stagesRemaining: 2, stagesTotal: 2, direction: occupant.direction });
}
s.clock.phase = 'mainline';
return s;
};
it('does not put a same-direction train BEHIND the departing one to the Superintendent', () => {
const s = setup({ direction: 'west', side: 'east', number: 18 });
const r = advance(s);
assert.ok(!r.events.some((e) => e.type === 'clearanceRequested'), 'a train behind was put to the Superintendent');
assert.ok(
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
'the departing train did not highball with nothing ahead of it',
);
assert.ok(!r.events.some((e) => e.type === 'trainsDestroyed'), 'a train was destroyed');
});
it('does not bar a departure over an opposite-direction train BEHIND it, which is moving away', () => {
const s = setup({ direction: 'east', side: 'east', number: 18 });
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'trainHighballed' && e.trainNumber === 15),
'a train moving away behind it held the departure',
);
});
it('still puts a same-direction train AHEAD to the Superintendent', () => {
const s = setup({ direction: 'west', side: 'west', number: 18 });
const r = advance(s);
assert.ok(
r.events.some((e) => e.type === 'clearanceRequested' && e.trainId === 'departing'),
'a train the departing one would follow was not put to the Superintendent',
);
});
});