/**
* The playable browser build.
*
* These tests drive `game.ts` exactly as the page does — take the offered actions, submit one, look
* at the new position — so "it compiles" is never mistaken for "it plays".
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { cardDescription, describeIntent } from '../src/sim/view.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import { record, renderHtml } from '../src/sim/replay.ts';
import { snapshot } from '../src/sim/view.ts';
import { createGame as createEngineGame } from '../src/engine/setup.ts';
import {
overHandLimit,
actionGroups,
actionMenu,
handPlayable,
currentActor,
fromSave,
newGame,
submit,
toSave,
view,
} from '../src/web/game.ts';
const root = join(import.meta.dirname, '..');
const dist = join(root, 'dist');
/** Play a whole game by always taking the first offered action. */
function playThrough(seed: number, maxTurns = 20_000) {
const game = newGame(seed);
let turns = 0;
for (; turns < maxTurns; turns++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0 || options.length === 0) break;
const first = groups[0]!.actions[0]!;
if (!submit(game, options[first.index]!)) break;
}
return { game, turns };
}
describe('the browser game plays', () => {
it('reaches the end of a game through the same calls the page makes', () => {
const { game, turns } = playThrough(77);
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
assert.equal(game.state.status, 'finished');
assert.ok(game.state.outcome !== null, 'a finished game must have an outcome');
});
it('never offers an action the engine then refuses', () => {
// The action list comes from legalActions, which delegates to check — so every button on the
// page must be accepted. A rejection here means the UI and the rules disagree.
const game = newGame(21);
for (let i = 0; i < 300; i++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0) break;
const pick = groups[groups.length - 1]!.actions[0]!;
assert.ok(submit(game, options[pick.index]!), 'the engine refused an offered action');
}
});
it('offers every legal action somewhere, dropping none', () => {
// Grouping is presentation. If a kind is not named in GROUP_ORDER it must still appear, or a
// legal move becomes unreachable in a way that is very hard to notice.
const game = newGame(5);
for (let i = 0; i < 120; i++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (options.length === 0) break;
const shown = new Set(groups.flatMap((g) => g.actions.map((a) => a.index)));
const kinds = new Set(options.map((o) => o.type));
const shownKinds = new Set([...shown].map((i2) => options[i2]!.type));
assert.deepEqual(shownKinds, kinds, 'a kind of legal action was not offered at all');
submit(game, options[[...shown][0]!]!);
}
});
it('restores a saved game to the same position', () => {
// A save is the seed plus the intents; replaying them must reproduce the game exactly. That is
// the payoff of event sourcing, and it is only true while the RNG stays seeded.
const game = newGame(909);
for (let i = 0; i < 80; i++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0) break;
submit(game, options[groups[0]!.actions[0]!.index]!);
}
const restored = fromSave(toSave(game));
assert.equal(restored.state.players[0]!.revenue, game.state.players[0]!.revenue);
assert.equal(restored.state.clock.day, game.state.clock.day);
assert.equal(restored.state.clock.stage, game.state.clock.stage);
assert.equal(restored.state.status, game.state.status);
assert.deepEqual(view(restored).cells, view(game).cells, 'the board differs after restore');
});
it('is deterministic — the same seed deals the same game', () => {
const a = playThrough(4242).game;
const b = playThrough(4242).game;
assert.equal(a.state.players[0]!.revenue, b.state.players[0]!.revenue);
assert.deepEqual(view(a).cells, view(b).cells);
});
});
describe('the action menu presents choices the way they are made', () => {
it('offers a card ONCE, with its locations underneath', () => {
// A single turn offered 29 track buttons and 7 card buttons in one flat list, with no way to
// tell which square each referred to. Subject first, location second.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const menu = actionMenu(game);
const items = menu.placeable.flatMap((g) => g.items);
assert.ok(items.length > 0, 'nothing placeable was offered');
const keys = items.map((i) => i.subjectKey);
assert.equal(new Set(keys).size, keys.length, 'the same card appeared as two subjects');
for (const item of items) {
const labels = item.spots.map((sp) => sp.label);
assert.equal(new Set(labels).size, labels.length, `${item.subject} lists a spot twice`);
assert.ok(item.spots.length > 0, `${item.subject} has nowhere to go but was offered`);
}
});
it('loses no legal action in the reshuffle', () => {
// Splitting into direct + placeable must not drop anything: every option must still be reachable.
const game = newGame(88);
for (let i = 0; i < 120; i++) {
if (currentActor(game) === null) break;
const menu = actionMenu(game);
if (menu.options.length === 0) break;
const reachable = new Set([
...menu.direct.flatMap((g) => g.actions.map((a) => a.index)),
...menu.placeable.flatMap((g) => g.items.flatMap((it) => it.spots.map((sp) => sp.index))),
]);
const kinds = new Set(menu.options.map((o) => o.type));
const shown = new Set([...reachable].map((k) => menu.options[k]!.type));
assert.deepEqual(shown, kinds, 'a kind of legal action became unreachable');
submit(game, menu.options[[...reachable][0]!]!);
}
});
it('names the card in every face-up slot', () => {
// "slot 2" is unusable information: the whole point of a face-up slot is choosing it on sight.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
// The draw actions are split by SOURCE now — gambling on the deck, taking a named face-up
// card, and ending the turn are three different decisions — so gather every group that offers
// one rather than looking for a single group called "Draw".
const menu = actionMenu(game);
const draws = menu.direct.filter((g) =>
g.actions.some((a) => menu.options[a.index]?.type?.startsWith('draw.')),
);
assert.ok(draws.length > 0, 'no draw actions are offered at all');
for (const g of draws) {
for (const a of g.actions) {
assert.ok(!/^draw\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`);
}
}
assert.ok(
draws.flatMap((g) => g.actions).some((a) => /face-up slot/.test(a.label)),
'face-up slots are not named',
);
});
it('never offers a train card a place on the board', () => {
// A train card goes to the TIMETABLE. Seed 555 offered Extra X15 at six squares with six
// rotations each — all identical, because the placement was accepted and then ignored.
const game = newGame(555);
for (let i = 0; i < 200; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
for (const o of options) {
if (o.type !== 'card.play') continue;
const kind = game.state.cards.get(o.cardId)?.kind.kind;
if (kind === 'timetabledTrain' || kind === 'extraTrain') {
assert.equal(o.placement, undefined, 'a train card was offered a board square');
}
}
submit(game, options[0]!);
}
});
it('reports what is left of the personal track supply', () => {
// Track is not drawn from the deck — 26 pieces per player, at most one laid a turn — so the
// board alone could never answer "how many straights have I got left".
const game = newGame(31);
const supply = view(game).trackSupply;
assert.ok(supply.length > 0, 'no track supply reported');
assert.equal(supply.reduce((n, t) => n + t.left, 0), 26, 'the opening supply should be 26');
assert.ok(supply.every((t) => !/none/.test(t.piece)), 'un-handed pieces should not say "none"');
});
});
describe('the board shows freight work happening', () => {
it('moves the load across MEN | AT | WORK on the card itself', () => {
// Advancing a load changed only the side panel, so the card being worked showed nothing. The
// load crossing green -> MEN|AT|WORK -> a spotted car IS freight; the printed cards put these
// squares under the track for exactly this reason.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [{ type: 'hopper', loaded: true }], inboundBox: [],
capacity: { outbound: 1, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 2, cars: [{ type: 'hopper', loaded: false }] },
laborers: 3, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const filled = (): string[] => {
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
const svg = officeSvg([cell], 0);
return [...svg.matchAll(/class="bs-wb (bs-\w+)( bs-occ)?"/g)].map(
(m) => m[1]!.replace('bs-', '') + (m[2] ? '=FULL' : ''),
);
};
assert.deepEqual(filled(), ['green=FULL', 'maw', 'maw', 'maw', 'red'], 'the waiting load is not drawn');
game.state.clock.phase = 'loadUnload';
game.state.clock.currentActor = 0;
assert.ok(submit(game, { type: 'laborer.startLoad', at: { row: -1, col: 0 } }));
assert.deepEqual(filled(), ['green', 'maw=FULL', 'maw', 'maw', 'red'], 'starting a load is invisible');
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 0 }));
assert.deepEqual(filled(), ['green', 'maw', 'maw=FULL', 'maw', 'red'], 'advancing a load is invisible');
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 1 }));
assert.deepEqual(filled(), ['green', 'maw', 'maw', 'maw=FULL', 'red'], 'the load did not reach WORK');
});
it('names where the work has got to, in the tooltip', () => {
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [], inboundBox: [],
capacity: { outbound: 1, inbound: 0 },
menAtWork: [null, { type: 'hopper', dir: 'out' }, null],
industryTrack: { length: 2, cars: [] },
laborers: 2, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
assert.match(cell.what, /a load is on AT/, 'the card does not say where the work has got to');
});
it('keeps the enhancement label clear of the pipeline squares', () => {
// `overpass` and `facingPointLocks` are placed `onCard`, so they can land on a facility. At its
// old baseline the label printed straight through the green/MEN|AT|WORK/red row.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: ['overpass'],
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
outboundBox: [], inboundBox: [],
capacity: { outbound: 1, inbound: 0 },
menAtWork: [null, null, null],
industryTrack: { length: 2, cars: [] },
laborers: 2, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
const svg = officeSvg([cell], 0);
const enhY = Number(/class="bs-enh" x="\d+" y="(\d+)"/.exec(svg)?.[1]);
const boxTop = Math.min(
...[...svg.matchAll(/class="bs-wb [^"]*" x="\d+" y="(\d+)"/g)].map((m) => Number(m[1])),
);
assert.ok(Number.isFinite(enhY) && Number.isFinite(boxTop), 'label or squares are not drawn');
// 9px text sits roughly 9px above its baseline, so the baseline itself must clear the squares.
assert.ok(enhY <= boxTop, `enhancement label at y=${enhY} prints over the squares at y=${boxTop}`);
});
});
describe('the board draws the printed card (docs/tracks.png)', () => {
/** One card, alone, so the measurements are in card coordinates. */
const draw = (links: string[]): { x1: number; y1: number; x2: number; y2: number }[] => {
const cell = {
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [],
tray: null, cars: [], facility: null, links, what: '',
};
const svg = officeSvg([cell] as never, 0);
return [...svg.matchAll(//g)]
.map((m) => ({ x1: Number(m[1]), y1: Number(m[2]), x2: Number(m[3]), y2: Number(m[4]) }));
};
/** Undirected segment angles in degrees, deduplicated. 0 is horizontal. */
const angles = (links: string[]): number[] => [
...new Set(
draw(links).map((r) => {
const a = (Math.atan2(r.y2 - r.y1, r.x2 - r.x1) * 180) / Math.PI;
return Math.round((((a % 180) + 180) % 180) * 10) / 10;
}),
),
].sort((a, b) => a - b);
it('runs the through track dead centre, edge to edge', () => {
// The measurement from the printed sheet: on every one of its nine rows the east-west rail sits
// at the card's exact vertical middle. It used to be drawn at 31% down the card, on the belief
// that the art put it along the top edge.
const rails = draw(['ew']);
assert.equal(rails.length, 2, 'a track is two rails');
const H = 96;
for (const r of rails) {
assert.equal(r.y1, r.y2, 'the through track must be level');
assert.ok(Math.abs(r.y1 - H / 2) <= 3, `through rail at y=${r.y1}, not the card's middle (${H / 2})`);
assert.deepEqual([Math.min(r.x1, r.x2), Math.max(r.x1, r.x2)], [0, 166], 'it must span the whole card');
}
});
it('leaves at exactly 45°, through the middle of the north or south edge', () => {
for (const links of [['ne'], ['nw'], ['se'], ['sw'], ['ew', 'ws'], ['ew', 'en']]) {
const found = angles(links);
assert.ok(found.includes(0), `${links} has no level run along the centre line`);
assert.ok(
found.includes(45) || found.includes(135),
`${links} draws its leg at ${found.join('/')}° — the card prints 45°`,
);
assert.equal(found.length, 2, `${links} draws ${found.join('/')}° — a leg is a run and one 45° angle`);
}
});
it('draws the two diagonals as two different diagonals', () => {
// The matching rule made visible. `ne` and `sw` lie on one line and `nw` and `se` on the other,
// so a stacked pair on the same diagonal reads as one unbroken rail and a mismatched pair reads
// as the V it is. Drawn alike, the picture would claim a join the engine refuses.
assert.deepEqual(angles(['ne']), angles(['sw']), 'ne and sw must lie on the same diagonal');
assert.deepEqual(angles(['nw']), angles(['se']), 'nw and se must lie on the same diagonal');
assert.notDeepEqual(angles(['ne']), angles(['nw']), 'the two diagonals must be distinguishable');
});
it('meets the card edge at its midpoint, so abutting cards line up', () => {
// A leg that met the edge anywhere else would join to nothing, however right its angle.
const W = 166;
const H = 96;
// Only the sloping segments, and only the end of each that lands on the south edge.
const atEdge = draw(['sw'])
.filter((r) => r.y1 !== r.y2)
.map((r) => (Math.abs(r.y1 - H) < Math.abs(r.y2 - H) ? r.x1 : r.x2));
assert.equal(atEdge.length, 2, 'the leg is two rails');
const mid = (atEdge[0]! + atEdge[1]!) / 2;
assert.ok(Math.abs(mid - W / 2) < 0.5, `the leg crosses the south edge at x=${mid}, not the middle (${W / 2})`);
});
});
describe('board highlighting', () => {
it('gives every spot a coordinate to highlight', () => {
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const items = actionMenu(game).placeable.flatMap((g) => g.items);
assert.ok(items.length > 0);
for (const it of items) {
for (const sp of it.spots) {
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
}
}
});
it('highlights squares OUTSIDE the current district', () => {
// The commonest placement is just beyond the existing cards — that is what extending means. A
// grid sized only to the cards already down would highlight nothing exactly when it matters.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
/**
* Open the district first, with a TURNOUT on the Running Track.
*
* Every office and industry card is a plain east-west straight and the Office carries no stub,
* so on the opening board the only legal squares are the two Limits signs — on the Running Track
* itself, inside the existing bounds, and this test would prove nothing. A turnout is what puts
* a square OFF the main in reach. The lay flag is then cleared rather than playing out a second
* turn: the subject here is the drawn canvas, not the turn economy.
*/
const lay = actionGroups(game).options.find(
(o) => o.type === 'track.lay' && o.geometry === 'turnout' && o.placement !== undefined,
);
assert.ok(lay, 'no turnout could be laid — the district can never open');
assert.ok(submit(game, lay), 'the turnout was refused');
game.state.turn.laidThisTurn = false;
const cells = view(game).cells;
const minCol = Math.min(...cells.map((c) => c.col));
const maxCol = Math.max(...cells.map((c) => c.col));
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((i) => i.spots);
assert.ok(
spots.some((sp) => sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0),
'no legal spot lies outside the current cards — the bounds test would be vacuous',
);
});
});
describe('the page explains itself', () => {
it('says what every card in hand and every face-up slot does', () => {
// A hand of bare names is unplayable: "Steam Turbines" carries no hint of its effect, and all of
// this text already existed in the content tables without reaching the screen.
for (const seed of [555, 430, 7, 99]) {
const f = view(newGame(seed));
assert.equal(f.handWhat.length, f.hand.length, 'a hand card has no description slot');
f.hand.forEach((name, i) => {
const what = f.handWhat[i]!;
assert.ok(what.length > 0, `${name} has no description`);
// camelCase leaking to the screen is the recurring failure here.
assert.doesNotMatch(what, /[a-z][A-Z]/, `raw camelCase in "${name}": ${what}`);
assert.doesNotMatch(what, /playerChoicebound|undefined|NaN/, `broken text: ${what}`);
});
f.departments.forEach((name, i) => {
if (name === '—') return;
assert.ok(f.departmentsWhat[i]!.length > 0, `face-up ${name} has no description`);
});
}
});
it('says what every card on the BOARD does, in readable words', () => {
// A played card becomes a cell with a name on it — "turnout", "Freight House", "waiting area" —
// and the explanation that was visible while it sat in hand disappears exactly when it starts
// mattering. Play a long way in so every card kind reaches the grid.
const game = newGame(111);
for (let i = 0; i < 400; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
const pick =
options.find((o) => o.type === 'card.play' && o.placement) ??
options.find((o) => o.type === 'track.lay') ??
options[0]!;
if (!submit(game, pick)) break;
}
const cells = view(game).cells;
assert.ok(cells.length > 4, 'not enough of the board was built to be a real check');
for (const c of cells) {
assert.ok(c.what.length > 0, `(${c.row},${c.col}) ${c.label} has no explanation`);
// camelCase on the board is the failure that keeps recurring — labels AND descriptions.
assert.doesNotMatch(c.label, /[a-z][A-Z]/, `raw camelCase label: ${c.label}`);
assert.doesNotMatch(c.what, /[a-z][A-Z]/, `raw camelCase in "${c.label}": ${c.what}`);
}
});
it('spells out which way a turnout will and will not let a train run', () => {
// §A.1 is an ABSENT edge, not a one-way street, and it is invisible on a card that just says
// "turnout". Getting it wrong is how a crew ends up somewhere it cannot leave.
const game = newGame(3);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
});
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
assert.match(cell.what, /stem east/);
assert.match(cell.what, /diverges south/);
assert.match(cell.what, /NEVER/, 'the missing edge is not spelled out');
});
it('describes every card kind in the deck, not just the easy ones', () => {
// Walk the whole deck rather than one hand: the categories differ, and a missing branch would
// show as a blank line under a card name only for the seeds that happen to deal it.
const game = newGame(1);
const seen = new Set();
for (const [id, card] of game.state.cards) {
const what = cardDescription(game.state, id);
assert.ok(what.length > 0, `${card.kind.kind} has no description`);
seen.add(card.kind.kind);
}
assert.ok(seen.size >= 7, `only ${seen.size} card kinds seen — the deck should hold more`);
});
it('marks cards that cannot be played yet, and says why', () => {
// A Station upgrade drawn at a Whistle Post is dead weight — upgrades are strictly sequential
// (Gap 3b) — but the hand showed it identically to a playable card, so taking it looked like an
// action that did nothing.
const game = newGame(111);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
submit(game, actionGroups(game).options.find((o) => o.type === 'draw.fromDepartment' && o.slot === 0)!);
const f = view(game);
const playable = handPlayable(game);
assert.equal(playable.length, f.hand.length, 'a hand card has no playable flag');
const upgrades = f.hand
.map((name, i) => ({ name, what: f.handWhat[i]!, can: playable[i]! }))
.filter((c) => /upgrade/.test(c.name));
assert.ok(upgrades.length > 0, 'this seed should deal an Office upgrade');
for (const u of upgrades) {
// Only Depot is reachable from a Whistle Post.
if (/Depot/.test(u.name)) continue;
assert.equal(u.can, false, `${u.name} should not be playable from a Whistle Post`);
assert.match(u.what, /requires the Office to be a/, `${u.name} does not say what it needs`);
}
});
it('agrees with the buttons about what is playable', () => {
// The flag is derived from legalActions, so it must never disagree with the offered actions.
const game = newGame(7);
for (let i = 0; i < 60; i++) {
if (currentActor(game) === null) break;
const hand = game.state.decks.hands.get(0) ?? [];
const flags = handPlayable(game);
const offered = new Set(
actionGroups(game)
.options.filter((o) => o.type === 'card.play')
.map((o) => (o as { cardId: string }).cardId),
);
hand.forEach((id, k) => {
assert.equal(flags[k], offered.has(id), 'playable flag disagrees with the action list');
});
const { options } = actionGroups(game);
if (options.length === 0) break;
submit(game, options[0]!);
}
});
it('never puts an internal tray id on a BUTTON either', () => {
// The history check below missed this: `maneuver.redFlags` was labelled "Red Flags on tray3",
// so the id leaked through the action list rather than the log. Every button, every turn.
const game = newGame(2345);
for (let i = 0; i < 400; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
for (const o of options) {
const label = describeIntent(game.state, o);
assert.doesNotMatch(label, /\btray\d+\b/, `internal tray id on a button: ${label}`);
}
if (!submit(game, options[0]!)) break;
}
});
it('never offers to load a caboose', () => {
// A caboose carries the crew, not freight. "add loaded caboose" appeared because the button was
// formatted by hand instead of using the labeller that already knew.
const game = newGame(2345);
for (let i = 0; i < 400; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
for (const o of options) {
assert.doesNotMatch(
describeIntent(game.state, o),
/(loaded|empty) caboose/,
'a caboose was described as loaded or empty',
);
}
if (!submit(game, options[0]!)) break;
}
});
it('warns that an industry on the Running Track is in the way of arrivals', () => {
// §11.2 — a Facility carries its own rails, so it does NOT block traffic. But §10 makes a car
// left standing between the Limits and the Office a collision, and nothing said so.
const game = newGame(9);
const area = game.state.officeAreas.get(0)!;
const industry = {
geometry: { kind: 'facility' as const, facility: 'grocersWarehouse' as const },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight' as const, subtype: 'grocersWarehouse',
allows: { outbound: false, inbound: true },
outboundBox: [], inboundBox: [], capacity: { outbound: 0, inbound: 1 },
menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
};
area.grid.set(`${area.runningRow},2`, industry as never);
area.grid.set(`${area.runningRow - 1},0`, JSON.parse(JSON.stringify(industry)) as never);
const cells = view(game).cells;
const onRunning = cells.find((c) => c.row === area.runningRow && c.col === 2)!;
const below = cells.find((c) => c.row === area.runningRow - 1 && c.col === 0)!;
assert.match(onRunning.what, /RUNNING TRACK/, 'no warning on a Running Track industry');
assert.match(onRunning.what, /pass straight through/, 'does not say traffic still passes');
assert.doesNotMatch(below.what, /RUNNING TRACK/, 'a Secondary Track industry must not warn');
});
it('never puts an internal tray id in front of a player', () => {
// The §8.1 clearance question read "may tray2 follow tray3 into the next Subdivision?" — the
// sharpest decision in the game, phrased in internal identifiers.
const game = newGame(430);
for (let i = 0; i < 600; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
submit(game, options[0]!);
}
for (const line of game.log) {
assert.doesNotMatch(line.text, /\btray\d+\b/, `internal tray id shown to the player: ${line.text}`);
}
});
it('spells out what each clearance ruling costs', () => {
const game = newGame(5);
const s = game.state;
s.trays.set('tray2', {
id: 'tray2', trainNumber: 4, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
s.trays.set('tray3', {
id: 'tray3', trainNumber: 7, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
});
s.clock.pendingDecision = { train: 'tray2', occupiedBy: 'tray3' };
const allow = describeIntent(s, { type: 'mainline.clearance', allow: true });
const hold = describeIntent(s, { type: 'mainline.clearance', allow: false });
for (const label of [allow, hold]) {
assert.match(label, /Train 4/, `the ruling does not name the train: ${label}`);
assert.doesNotMatch(label, /tray\d/, `internal id on a button: ${label}`);
}
// Deliberately NOT asserting that granting clearance mentions a collision: no rear-end is
// implemented, and promising one would describe a consequence the engine never delivers.
assert.match(allow, /same Mainline card/, 'granting clearance does not say what happens');
assert.match(hold, /losing the Stage/, 'holding does not mention the cost');
});
it('states the objective and whether you are keeping up', () => {
const f = view(newGame(430));
assert.equal(f.objective.target, 20);
assert.equal(f.objective.days, 5);
assert.match(f.objective.note, /of 20/);
assert.match(f.objective.note, /Days? left/);
});
it('explains what each Local Operations option spends', () => {
// The most consequential decision of the Stage, previously labelled "choose switch".
const game = newGame(555);
const choices = actionGroups(game)
.options.filter((o) => o.type === 'localOps.choose')
.map((o) => describeIntent(game.state, o));
assert.ok(choices.length > 0);
for (const c of choices) {
assert.ok(c.length > 25, `too terse to be an explanation: "${c}"`);
assert.match(c, /—/, `no explanation after the option name: "${c}"`);
}
});
it('says which way a piece will point, never "rotation 2"', () => {
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const track = actionMenu(game)
.placeable.flatMap((g) => g.items)
.filter((i) => /straight|curve|turnout/.test(i.subject));
assert.ok(track.length > 0, 'no track pieces offered');
for (const item of track) {
for (const sp of item.spots) {
assert.doesNotMatch(sp.label, /rotation \d/, `opaque rotation label: ${sp.label}`);
}
// A piece with more than one orientation must say which is which, or the spots are ambiguous.
const perSquare = new Map();
for (const sp of item.spots) {
const k = `${sp.coord.row},${sp.coord.col}`;
perSquare.set(k, (perSquare.get(k) ?? 0) + 1);
}
if ([...perSquare.values()].some((n) => n > 1)) {
assert.ok(
item.spots.some((sp) => /east|west|north|south/.test(sp.label)),
`${item.subject} offers two orientations on one square without naming them`,
);
}
}
});
});
describe('replays are saves', () => {
it('rebuilds the exact position a save describes', () => {
// A replay is `{seed, history}` — a few hundred bytes — because the engine is deterministic and
// runs in the browser. Re-submitting the same intents must land on the same board, or a shared
// replay shows something the player never saw.
const game = newGame(430);
for (let i = 0; i < 250; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
}
const restored = fromSave(toSave(game));
assert.equal(restored.state.players[0]!.revenue, game.state.players[0]!.revenue);
assert.equal(restored.state.clock.day, game.state.clock.day);
assert.equal(restored.state.clock.stage, game.state.clock.stage);
assert.deepEqual(view(restored).cells, view(game).cells, 'the rebuilt board differs');
assert.deepEqual(view(restored).division, view(game).division, 'the rebuilt Division differs');
});
it('stays small enough to email', () => {
const game = newGame(202);
for (let i = 0; i < 400; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
if (!submit(game, options[0]!)) break;
}
const kb = Buffer.byteLength(JSON.stringify(toSave(game))) / 1024;
assert.ok(kb < 200, `a save is ${kb.toFixed(0)} KB — too big to be worth sharing as a file`);
});
it('publishes every save in public/replays with an index', () => {
// Static hosting cannot list a directory, so the page needs a manifest or it shows nothing.
const manifestPath = join(dist, 'replays/manifest.json');
assert.ok(existsSync(manifestPath), 'no replay manifest was built');
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { file: string; title: string }[];
for (const entry of manifest) {
assert.ok(existsSync(join(dist, 'replays', entry.file)), `${entry.file} is indexed but not published`);
assert.ok(entry.title.length > 0, `${entry.file} has no title`);
const save = JSON.parse(readFileSync(join(dist, 'replays', entry.file), 'utf8')) as Record;
assert.equal(typeof save['seed'], 'number', `${entry.file} has no seed`);
assert.ok(Array.isArray(save['history']), `${entry.file} has no history`);
}
});
});
describe('the static build', () => {
it('builds, and needs nothing but a static host', () => {
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
assert.ok(existsSync(join(dist, 'index.html')), 'no index.html');
assert.ok(existsSync(join(dist, 'web/main.js')), 'no entry script');
assert.ok(existsSync(join(dist, 'engine/apply.js')), 'the engine did not emit');
});
it('emits no import that a browser cannot resolve', () => {
// Node's type-stripping lets the source import './x.ts'; a browser cannot. The build rewrites
// those to .js, and nothing may reach for a bare module or a node: builtin.
const files: string[] = [];
const walk = (dir: string): void => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
if (e.isDirectory()) walk(join(dir, e.name));
else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
}
};
walk(dist);
assert.ok(files.length > 5, 'suspiciously few emitted files');
for (const f of files) {
const src = readFileSync(f, 'utf8');
// Anchor to real import/export statements. A loose `from '...'` also matches PROSE — the
// turnout comment "a train entering a turnout from \"A\"" was read as importing a module
// called A, which is the kind of false alarm that trains you to ignore a failing test.
const imports = /^\s*(?:import|export)[^'"\n]*?from\s+['"]([^'"]+)['"]/gm;
for (const m of src.matchAll(imports)) {
const spec = m[1]!.split('?')[0]!;
assert.ok(!spec.endsWith('.ts'), `${f} imports a .ts path: ${spec}`);
assert.ok(!spec.startsWith('node:'), `${f} imports a Node builtin: ${spec}`);
assert.ok(spec.startsWith('.') || spec.startsWith('/'), `${f} imports bare module: ${spec}`);
}
assert.ok(!/^[^/*]*\bprocess\.\w/m.test(src), `${f} references process`);
assert.ok(!/\brequire\(/.test(src), `${f} uses require()`);
}
});
it('renders clickable highlights on the board when a card is picked', async () => {
// The real check: load the EMITTED bundle with a DOM stub, click a card, and confirm the board
// came back with highlighted squares wired to handlers. Asserting the data has coordinates says
// nothing about whether the page draws them.
const els = new Map>();
const injected: Record[] = [];
const make = (): Record => {
let html = '';
// Cache per selector and clear it when innerHTML changes. Returning fresh objects each call
// would drop the handlers the page assigns — the test would then report "no button" for a
// page that works perfectly.
let cache = new Map[]>();
const found = new Map>();
// Every element carries a classList. The page folds the district panel by toggling a class on
// it, and a stub without one throws on load — which is a page that never starts, not a
// cosmetic gap.
const ownClasses = new Set();
const node: Record = {
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
classList: {
add: (c: string) => void ownClasses.add(c),
remove: (c: string) => void ownClasses.delete(c),
contains: (c: string) => ownClasses.has(c),
toggle: (c: string) => (ownClasses.has(c) ? ownClasses.delete(c) : ownClasses.add(c)),
},
classes: ownClasses,
// The board is SVG now, and highlighting works by finding a card group and adding a class.
// The stub has to model that much or it cannot see whether highlighting happened at all.
querySelector(sel: string) {
const m = /\[data-(cell|ghost)="([^"]+)"\]/.exec(sel);
if (!m) return null;
const key = `${m[1]}:${m[2]}`;
if (!html.includes(`data-${m[1]}="${m[2]}"`)) return null;
if (!found.has(key)) {
const classes = new Set();
found.set(key, {
onclick: null,
classList: { add: (c: string) => void classes.add(c), has: (c: string) => classes.has(c) },
classes,
});
}
return found.get(key);
},
highlighted: () => [...found.values()].filter((f) => (f['classes'] as Set).size > 0),
querySelectorAll(sel: string) {
const hit = cache.get(sel);
if (hit) return hit;
const out: Record[] = [];
const re = sel.startsWith('[')
? /data-at="([^"]*)"/g
: new RegExp(`