Files
station-master/test/advance.test.ts
T

569 lines
20 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 } 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> = {}): GameConfig => ({
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'short',
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
...over,
});
const game = (seed = 1, over: Partial<GameConfig> = {}): GameState =>
createGame({ id: 'g', seed, config: baseConfig(over), playerNames: ['Jesse'] });
type PlayStats = { turns: number; scheduled: number; collisions: number; cardsPlayed: number };
/**
* A deliberately simple bot — the seed of component 17. It draws, then plays, then ends its turn.
*
* The draw-first ordering matters: an earlier version never drew, so it burned its three opening
* cards and then held an empty hand for the rest of the game. Every seed still "completed", but
* nothing ever happened — no trains, no revenue. That is why the milestone tests below assert on
* what the game DID, not merely that it terminated.
*/
function playToCompletion(s: GameState, seed = 1, maxTurns = 20_000): PlayStats {
let rng = seed >>> 0;
const pick = <T,>(xs: T[]): T => {
rng = (rng * 1103515245 + 12345) >>> 0;
return xs[rng % xs.length]!;
};
const stats: PlayStats = { turns: 0, scheduled: 0, collisions: 0, cardsPlayed: 0 };
const tally = (events: { type: string }[]): void => {
for (const e of events) {
if (e.type === 'trainScheduled') stats.scheduled++;
if (e.type === 'cardPlayed') stats.cardsPlayed++;
if (e.type === 'revenueChanged' && 'reason' in e && String(e.reason).startsWith('collision')) {
stats.collisions++;
}
}
};
for (; stats.turns < maxTurns; stats.turns++) {
tally(pump(s));
if (s.status === 'finished') break;
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
if (actor === null) break;
const options = legalActions(s, actor);
if (options.length === 0) break;
const draws = options.filter((i) => i.type.startsWith('draw.from'));
const plays = options.filter((i) => i.type === 'card.play');
const enders = options.filter(
(i) =>
i.type === 'switch.end' ||
i.type === 'draw.end' ||
i.type === 'loadUnload.end' ||
i.type === 'mainline.clearance',
);
const choice =
draws.length > 0
? pick(draws)
: plays.length > 0
? pick(plays)
: enders.length > 0
? pick(enders)
: pick(options);
const r = applyIntent(s, actor, choice);
assert.ok(r.ok, `bot chose an illegal action: ${choice.type}`);
tally(r.events);
}
return stats;
}
// ---------------------------------------------------------------------------
describe('phase sequencing (Gap 1)', () => {
it('waits for the actor in a player-driven phase', () => {
const s = game();
const r = advance(s);
assert.equal(r.needsInput, true);
assert.equal(s.clock.phase, 'localOps');
assert.equal(s.clock.currentActor, 0);
});
it('runs the phases in order once the player finishes', () => {
const s = game();
const seen: string[] = [];
for (let i = 0; i < 40 && s.status === 'active'; i++) {
const r = advance(s);
seen.push(...r.events.filter((e) => e.type === 'phaseBegan').map(() => s.clock.phase));
if (r.needsInput) {
applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
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 players (§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');
});
});