Initial commit
This commit is contained in:
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* 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 } 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);
|
||||
const pos2 = s.trays.get(id)!.position;
|
||||
assert.ok(pos2.at === 'mainline' && pos2.region === 1, 'one region per Stage');
|
||||
});
|
||||
|
||||
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, region: 1 },
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.regions[1]!.occupant = id;
|
||||
|
||||
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;
|
||||
|
||||
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, region: 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, region: 1 },
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.regions[1]!.occupant = 'ahead';
|
||||
|
||||
// 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, region: 0 },
|
||||
movesUsed: 0,
|
||||
});
|
||||
const ml = s.division.nodes[1];
|
||||
if (ml?.kind === 'mainline') ml.regions[0]!.occupant = 'oncoming';
|
||||
|
||||
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: 62 pieces, wherever they sit.
|
||||
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, 62, 'rolling stock leaked or was duplicated');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user