/** * 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 } from '../src/engine/apply.ts'; import { STAGES_PER_DAY, lengthProfile, TOTAL_ROLLING_STOCK } from '../src/engine/content.ts'; import { legalActions } from '../src/engine/legal.ts'; import { createGame } from '../src/engine/setup.ts'; import type { GameConfig, GameState } from '../src/engine/state.ts'; const baseConfig = (over: Partial = {}): GameConfig => ({ mode: 'solitaire', victory: 'highestAfterDays', length: 'short', optionalRules: { reducedVisibility: false, sisterTrains: 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' }); 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(); 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, engineFront: true, 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, engineFront: true, 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, direction: 'east' }); } s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); advance(s); assert.equal(s.players[0]!.revenue, -5, 'the fault is the local player’s (§10)'); assert.equal(s.collisionsToday, 1); assert.ok(!s.trays.has(id), 'the train is removed'); }); it('returns cabooses to the Division Yard and other stock to Classification (Gap 2c)', () => { const s = game(); s.officeAreas.get(0)!.adOccupancy = ['blocker']; const classBefore = s.yards.classificationYard.length; const mlx = s.division.nodes[1]; if (mlx?.kind === 'mainline') { // Pin the terrain. Mainline types are drawn from the SHUFFLED deck, so deck composition // would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set // `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here. mlx.card = 'plains'; mlx.transits.push({ tray: 'x', stagesRemaining: 1, direction: 'east' }); } s.trays.set('x', { id: 'x', trainNumber: 8, trainIsExtra: false, engineFront: true, 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, engineFront: true, 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, direction: 'east' }); } // A second train at the Western Division Point wanting to follow it. s.trays.set('behind', { id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true, 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, engineFront: true, 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, direction: 'west' }); } s.trays.set('waiting', { id: 'waiting', trainNumber: 2, trainIsExtra: false, engineFront: true, consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0, }); s.clock.phase = 'mainline'; s.movedThisPhase = new Set(); advance(s); assert.equal(s.clock.pendingDecision, null, 'no judgment call — the train simply holds'); assert.deepEqual(s.trays.get('waiting')!.position, { at: 'divisionPoint', side: 'west' }); }); it('clears the decision once the Superintendent rules', () => { const s = game(); s.clock.phase = 'mainline'; s.clock.pendingDecision = { train: 'a', occupiedBy: 'b' }; const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false }); assert.ok(r.ok); assert.equal(s.clock.pendingDecision, null); }); }); // --------------------------------------------------------------------------- describe('victory conditions (§3, Gap 10e)', () => { it('loses a timed Solitaire game that misses the target', () => { // The target doubles as a MINIMUM in Solitaire: below it you lose regardless of score. const s = game(1); s.clock.day = lengthProfile('short').days + 1; s.clock.stage = STAGES_PER_DAY; s.clock.phase = 'shiftChange'; s.players[0]!.revenue = 0; advance(s); assert.equal(s.status, 'finished'); assert.equal(s.outcome!.result, 'loss'); assert.equal(s.outcome!.reason, 'revenueFloor'); }); it('wins a timed Solitaire game that clears the target', () => { const s = game(1); s.clock.day = lengthProfile('short').days + 1; s.clock.stage = STAGES_PER_DAY; s.clock.phase = 'shiftChange'; s.players[0]!.revenue = lengthProfile('short').target; advance(s); assert.equal(s.status, 'finished'); assert.equal(s.outcome!.result, 'win'); }); it('ends a first-to-target game the moment the target is reached', () => { const s = game(1, { victory: 'firstToTarget' }); s.players[0]!.revenue = lengthProfile('short').target; s.clock.stage = STAGES_PER_DAY; s.clock.phase = 'shiftChange'; advance(s); assert.equal(s.status, 'finished'); assert.equal(s.outcome!.reason, 'targetReached'); }); }); // --------------------------------------------------------------------------- describe('MILESTONE: a full solitaire game runs headless', () => { it('plays a short game to completion', () => { const s = game(42); playToCompletion(s, 42); assert.equal(s.status, 'finished', 'the game must reach an outcome'); assert.ok(s.outcome, 'and record one'); assert.ok(s.clock.day >= 1); }); it('terminates from many different seeds', () => { // The driver must settle regardless of how the deck falls. for (const seed of [1, 7, 23, 99, 256, 1013]) { const s = game(seed); playToCompletion(s, seed); assert.equal(s.status, 'finished', `seed ${seed} did not finish`); } }); it('is fully reproducible from a seed', () => { const a = game(555); const b = game(555); playToCompletion(a, 555); playToCompletion(b, 555); assert.equal(a.clock.day, b.clock.day); assert.equal(a.players[0]!.revenue, b.players[0]!.revenue); assert.deepEqual(a.outcome, b.outcome); }); it('never offers the bot an illegal action along the way', () => { // playToCompletion asserts this on every step; this test states the guarantee explicitly. const s = game(31); const stats = playToCompletion(s, 31); assert.ok(stats.turns > 0, 'the game should take at least one turn'); }); it('actually plays the game — trains get scheduled and cards get played', () => { // GUARD. An earlier bot completed every seed while doing nothing at all: no trains, no // revenue, every game a loss. Asserting only on termination hid two real bugs. These assert // the game DEVELOPS. let scheduled = 0; let cardsPlayed = 0; for (const seed of [7, 42, 99, 256]) { const s = game(seed); const stats = playToCompletion(s, seed); scheduled += stats.scheduled; cardsPlayed += stats.cardsPlayed; } assert.ok(scheduled > 0, 'no train was ever scheduled across four games'); assert.ok(cardsPlayed > 4, `only ${cardsPlayed} cards played across four games`); }); it('terminates even when the Superintendent denies clearance repeatedly', () => { // A denied ruling must be CONSUMED. Without that the driver re-evaluates the same train and // asks the same question forever — a livelock that only showed up on one seed. const s = game(7); const stats = playToCompletion(s, 7, 5_000); assert.equal(s.status, 'finished'); assert.ok(stats.turns < 5_000, `took ${stats.turns} turns — probable livelock`); }); it('conserves rolling stock across a whole game', () => { // Nothing may be created or destroyed: every piece in the roster, wherever it sits. const s = game(88); playToCompletion(s, 88); let count = s.yards.divisionYard.length + s.yards.classificationYard.length; for (const tray of s.trays.values()) count += tray.consist.length; for (const area of s.officeAreas.values()) { for (const card of area.grid.values()) { count += card.standing.length; if (card.facility) { count += card.facility.outboundBox.length; count += card.facility.inboundBox.length; count += card.facility.industryTrack.cars.length; } } } assert.equal(count, TOTAL_ROLLING_STOCK, 'rolling stock leaked or was duplicated'); }); });