/** * 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 => ({ 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 = {}): 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 = (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('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!.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) — 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); 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 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, 'finished'); 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, 'finished'); 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, 'finished'); 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, 'finished'); 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 never checks the collision floor, whatever the counts', () => { 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.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 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 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(); 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. 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 = '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); 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; }; /** 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 => { const n = s.division.nodes[node]!; assert.equal(n.kind, 'mainline'); return n as Extract; }; 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?.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'); }); });