Files
station-master/test/web.test.ts
T
Jesse.MarkowitzandClaude Opus 5 b90c0413d2 v0.8.0.17 — four things the game knew and the screen did not say
All four reported from a table on Day 1 of v0.8.0.16, and all the same shape.

ABS SIGNALS COULD ONLY BE PLAYED ON ONE MAINLINE CARD, while its tooltip said
"any Mainline card". The engine was never wrong: check accepts any node whose
kind is mainline and legalActions filters by check, so all of them were legal.
The failure was the LABEL — describeIntent named i.placement and never i.node,
so every placement described itself as plain "play ABS Signals", and the action
list drops duplicate labels. All but the lowest-index node were discarded before
the menu saw them. This is the THIRD time that trap has fired and the file
documents the other two three lines apart: a turnout's two rotations, and three
Department discards. Same fix — name what distinguishes them.

The card is also called what the card face calls it. prettyKey rendered
absSignals as "Abs Signals" beside a tooltip saying ABS, an acronym no
key-splitter can recover, so the authored names now win. Three of those names
were transcribed in sentence case and were CORRECTED rather than adopted: the
repository says "Yard Office" 36 times against "Yard office" twice. A lookup
that imports its own source's typos is the drift it exists to prevent.

NOTHING ON A MAINLINE CARD SHOWED WHAT WAS STANDING ON IT. Played, ABS left no
mark and you found out by hovering — the same complaint the Heavy Grade wedge
answered, and it matters more here because ABS decides whether a second train on
that card is safe. It draws a signal mast with a lit lamp now; a signal is the
literal object and needs no room for words, which is what lets it sit clear of a
name as long as "Uncontrolled Siding" on a 152px cell. The Mainline modifiers
draw as BRK, AIR and HLP. Realignment is deliberately not among them: reduce
takes the `became` branch and changes node.card, so a realigned Trestle IS an
Uncontrolled Siding afterwards. Asserted, so the absence reads as a finding.

A FREIGHT AGENT TURN SAID A CAR MOVED WHEN NONE HAD. Three faults behind one
line. It asserted an outcome, where §6.3 requires no action and the bot declines
deliberately — unjamming a healthy box destroys a load that cost a whole action
to stock. An idle Agent was then silent, which read as a dropped turn; a new
freightAgentIdled event says so and why, reducing to nothing exactly like
switchingEnded. And the work named a coordinate rather than the industry, though
a `place` helper has existed for precisely that since the switching lines moved
to it. "Loaded a loaded boxcar INTO the green Outbound box at the Freight House",
with the direction in capitals because to-or-from was the question asked.

THE LOG AND THE ACTION MENU SPELLED THE SAME SQUARE DIFFERENTLY. view.ts wrote
(col,row) — X,Y, east/west then north/south — with a comment saying why;
narrate.ts wrote the internal storage order with no comment at all. So the menu
offered a move to "(1,-1)" and the log reported it at "(-1,1)", side by side.
Pinned by a test that renders one square through BOTH describers and compares
them to each other: a test written against either file alone would have passed.

THE DOCUMENTATION IS REACHABLE FROM A RUNNING GAME, AND ALL OF IT IS PUBLISHED.
v0.8.0.16 published the Quickstart and nothing it points at — its §8 links five
documents by relative path and every one 404'd on the package, verified against
the running container. The build publishes the full set, and the test reads the
links OUT OF the guide rather than listing them. They are linked from the This
Game card, where reference already lives, rather than the header that must not
wrap; no mode awareness is needed, because solitaire and multiplayer are the
same page on the same origin.

THE REFERENCES DROPPED THE VERSION FROM THEIR NAMES. Four described v0.8.0.16
and had since the v0.8.0.15 audit; the v0.4.5 was the prototype edition they
were first written against, kept only because 36 citations pointed at it — and
it read as documentation five minor versions stale. They are quickstart.md,
rules.md, home-deck.md, mainline-deck.md and components.md now, kept current
with each release rather than published as editions. Two errors surfaced while
checking them against this release, which is the argument for doing it:
home-deck.md filed ABS Signals under Enhancements "played into your district"
that "change what a square does" — it does neither, this release's bug written
down — and mainline-deck.md, which lists everything playable onto a Mainline
card, never mentioned it at all.

1010 fast tests and 35 sim tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2026-09-21 05:53:52 -04:00

5793 lines
300 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.
/**
* 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 { turnOf } from '../src/engine/state.ts';
import { areaOf, check } from '../src/engine/apply.ts';
import type { Game } from '../src/web/game.ts';
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
// The REAL one. An earlier test in this file replaces `globalThis.URLSearchParams` with a two-line
// stub, and the dialog needs `set` as well as `get` — reading the global here would hand the page
// whichever stub happened to run last.
import { URLSearchParams as NodeURLSearchParams } from 'node:url';
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
import { variantsFor } from '../src/engine/track.ts';
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
import type { DivisionView } from '../src/sim/view.ts';
import { ENHANCEMENT_RULES, STAGES_PER_DAY, mainlineProfile } from '../src/engine/content.ts';
import { dayEndHtml, facilitiesHtml, pilesHtml, resultsHtml, timetableHtml } from '../src/web/panels.ts';
import { turnChartHtml } from '../src/sim/turnchart.ts';
import { fieldSelectors } from '../src/web/settings-form.ts';
import { record, renderHtml } from '../src/sim/replay.ts';
import type { Frame } from '../src/sim/view.ts';
import { snapshot } from '../src/sim/view.ts';
import { createGame as createEngineGame } from '../src/engine/setup.ts';
import { advance as advanceEngine } from '../src/engine/advance.ts';
import type { GameConfig } from '../src/engine/state.ts';
import {
configWith,
SOLO_CONFIG,
overHandLimit,
actionGroups,
actionMenu,
drain,
handPlayable,
currentActor,
fromSave,
newGame,
submit,
toSave,
undo,
view,
} from '../src/web/game.ts';
const root = join(import.meta.dirname, '..');
/**
* `dist/` is built once by the `pretest` npm script (`scripts/build-web.ts`), before `node --test`
* ever starts — every test below that reads `dist` assumes it is already complete, not that some
* other test in this file built it. `npm run test` directly (skipping `npm test`'s `pretest` hook)
* will not have run it.
*/
const dist = join(root, 'dist');
/**
* A directory the actual "run the build command" test below builds into, kept separate from the
* shared `dist/` above. Two suites racing to write and read the SAME `dist/` — the build command
* doing a full `rmSync` + rebuild while other tests in this file read it mid-run — used to fail 9
* times in one measured run and 0 the next. `dist/` is now single-writer (`pretest`, once, before
* anything reads it); this directory is the ONLY thing the build-command test itself may touch.
*/
const distTest = join(root, 'dist-test');
/** 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 };
}
/**
* Force a specific TRACK card into hand and return the `card.play` option that would lay it.
*
* Track is drawn from the Home Office deck like everything else, so a test that needs a particular
* piece has to deal itself one rather than assume the opening hand holds it.
*/
function dealTrack(
game: ReturnType<typeof newGame>,
geometry: string,
hand: string,
where?: (o: { row: number; col: number }) => boolean,
) {
for (const [id, card] of game.state.cards) {
const k = card.kind as { kind: string; geometry?: string; hand?: string };
if (k.kind !== 'track' || k.geometry !== geometry || k.hand !== hand) continue;
game.state.decks.hands.set(0, [id]);
const option = actionGroups(game).options.find(
(o) => o.type === 'card.play' && o.cardId === id && o.placement !== undefined && (!where || where(o.placement)),
);
return { cardId: id, option };
}
throw new Error(`no track card: ${geometry}/${hand}`);
}
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`);
/**
* The timetable runs out and the game STOPS TO ASK rather than ending (Gitea#11) — `currentActor`
* is null there, which is where `playThrough` breaks. Declining through `submit` finishes it,
* which is worth doing here rather than merely asserting the pause: this test exists to prove a
* whole game is playable through the calls the page makes, and since Gitea#11 the last of those
* calls is the vote.
*/
assert.equal(game.state.status, 'awaitingExtension');
assert.ok(game.state.official !== null, 'an ended game must have recorded its official result');
assert.ok(submit(game, { type: 'game.extend', player: 0, agree: false }, 0), 'the page cannot decline');
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 on top of every Department pile', () => {
// "Department 2" is unusable information: the whole point of a face-up pile is choosing it on
// sight, and only the top card may be taken.
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) => /from Department \d/.test(a.label)),
'Department piles are not named',
);
});
it('offers all three Departments for a discard, not one collapsed button', () => {
// REGRESSION. Every discard described itself as just "discard X", and the action list drops
// duplicate labels — so three genuinely different choices collapsed into one button and the
// Department could not be picked at all. Naming the pile, and what the card would bury, is what
// makes the choice a choice.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
submit(game, actionGroups(game).options.find((o) => o.type === 'draw.fromHomeOffice')!);
const menu = actionMenu(game);
const discards = menu.direct
.flatMap((g) => g.actions)
.filter((a) => menu.options[a.index]?.type === 'card.discard');
assert.ok(discards.length > 0, 'no discard is offered at all');
const first = menu.options[discards[0]!.index]!;
assert.equal(first.type, 'card.discard');
const forThatCard = discards.filter(
(a) => (menu.options[a.index] as { cardId?: string }).cardId === (first as { cardId: string }).cardId,
);
assert.equal(forThatCard.length, 3, 'all three Departments must be separately choosable');
for (const slot of [1, 2, 3]) {
assert.ok(
forThatCard.some((a) => a.label.includes(`Department ${slot}`)),
`Department ${slot} is not offered: ${forThatCard.map((a) => a.label).join(' | ')}`,
);
}
});
it('shows the top card and the depth of the Salvage Yard too', () => {
// §6.2 sweeps the Salvage Yard back into the deck when it runs out, so watching it fill is the
// only warning a player gets that the reshuffle is coming.
const game = newGame(555);
assert.equal(view(game).salvage.depth, 0, 'nothing is salvaged at setup');
assert.equal(view(game).salvage.top, '—');
const spare = game.state.decks.homeOffice.pop()!;
game.state.decks.salvageYard.push(spare);
const f = view(game);
assert.equal(f.salvage.depth, 1);
assert.equal(f.salvage.top, cardName(game.state, spare), 'the top of the Salvage Yard is not named');
});
it('offers BOTH rotations of a turnout, each with the shape it would lay', () => {
// REGRESSION, and the reason rotating a turnout felt impossible. The action list drops duplicate
// labels, and `describeIntent` for a card play said only "play X at (0, 1)" — so a turnout's two
// orientations produced the same label and the second was discarded before the menu ever saw it.
// The rotation is the entire decision for a turnout, and it could not be made.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
dealTrack(game, 'turnout', 'right');
const spots = actionMenu(game)
.placeable.flatMap((g) => g.items)
.flatMap((it) => it.spots)
.filter((sp) => sp.coord?.row === 0 && sp.coord.col === 1);
assert.equal(spots.length, 2, 'both rotations must be offered on the same square');
assert.ok(spots.some((sp) => /turn to the south/.test(sp.label)), 'the south-diverging rotation is missing');
assert.ok(spots.some((sp) => /turn to the north/.test(sp.label)), 'the north-diverging rotation is missing');
// And each carries the rails it would actually lay, so the UI can draw it rather than only
// describe it. Taken from the engine's connections, so a preview cannot promise a shape the
// placement will not produce.
const shapes = spots.map((sp) => [...sp.links].sort().join('|')).sort();
assert.deepEqual(shapes, ['en|ew', 'we|ws'], `the two rotations do not carry distinct shapes: ${shapes.join(' / ')}`);
});
it('offers ABS Signals on EVERY Mainline card, each one named', () => {
/**
* REGRESSION — reported from a table on Day 1 Stage 1 of v0.8.0.16, and the THIRD instance of
* one trap. The action list drops duplicate labels, and `describeIntent` for a card play named
* the grid placement but never `node` — so every Mainline card produced the identical label
* "play ABS Signals" and all but the lowest-index one were discarded before the menu saw them.
* The card's own tooltip says "any Mainline card" while exactly one was ever on offer.
*
* The engine was never wrong: `check` accepts any node whose kind is 'mainline', and
* `legalActions` filters by `check`. The whole failure was in the label.
*/
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
let absId: string | undefined;
for (const [id, card] of game.state.cards) {
const k = card.kind as { kind: string; key?: string };
if (k.kind === 'enhancement' && k.key === 'absSignals') { absId = id; break; }
}
assert.ok(absId, 'the deck has no ABS Signals card');
game.state.decks.hands.set(0, [absId]);
const mainlineNodes = game.state.division.nodes
.map((n, i) => ({ n, i }))
.filter(({ n }) => n.kind === 'mainline');
assert.ok(mainlineNodes.length > 1, 'this division has only one Mainline card — nothing to distinguish');
// The engine offers one per Mainline card ...
const offered = actionGroups(game).options.filter(
(o) => o.type === 'card.play' && o.cardId === absId && o.node !== undefined,
);
assert.equal(
offered.length,
mainlineNodes.length,
`the engine offers ${offered.length} placements for ${mainlineNodes.length} Mainline cards`,
);
// ... and every one of them must survive into the menu, which means distinct labels.
const labels = offered.map((o) => describeIntent(game.state, o));
assert.equal(
new Set(labels).size,
offered.length,
`the labels collapse, so the menu drops all but one: ${[...new Set(labels)].join(' / ')}`,
);
const spots = actionMenu(game)
.placeable.flatMap((g) => g.items)
.filter((it) => it.subjectKey === `card:${absId}`)
.flatMap((it) => it.spots);
assert.equal(
spots.length,
mainlineNodes.length,
`only ${spots.length} of ${mainlineNodes.length} Mainline cards can be chosen`,
);
/**
* And the card is called what the card face calls it. `prettyKey` rendered `absSignals` as
* "Abs Signals" on a button while the tooltip beside it said ABS — an acronym no key-splitter
* can recover, so the authored name in `ENHANCEMENT_CARDS` has to win.
*/
assert.ok(
labels.every((l) => l.includes('ABS Signals')),
`the card is not called by its printed name: ${labels[0]}`,
);
// Each spot names the card it would go on, so the choice is legible rather than positional.
for (const { n } of mainlineNodes) {
const name = mainlineProfile((n as { card: Parameters<typeof mainlineProfile>[0] }).card).name;
assert.ok(
spots.some((sp) => sp.label.includes(name)),
`no spot names the ${name}: ${spots.map((sp) => sp.label).join(' / ')}`,
);
}
});
it('spells a square the same way in the action menu and in the log', () => {
/**
* REPORTED 2026-09-21. `view.ts` wrote "(col,row)" — X,Y, east/west then north/south, with a
* comment saying so — and `narrate.ts` wrote "(row,col)", the internal storage order, with no
* comment at all. So the action menu offered a move to "(1,-1)" and the log then reported it at
* "(-1,1)", in two panels a player reads side by side.
*
* PINNED AGAINST EACH OTHER rather than against a literal: a test asserting one format would
* have passed all along on whichever file it was written against. This compares the two
* renderers on the same square, which is the property that was actually broken.
*/
const game = newGame(555);
const square = { row: -1, col: 2 };
const logged = describeIntent(game.state, {
type: 'switch.move',
trayId: [...game.state.trays.keys()][0]!,
to: square,
reverse: false,
});
const menu = describeIntent(game.state, {
type: 'card.play',
cardId: game.state.decks.hands.get(0)![0]!,
placement: square,
});
// Both must render the square, and render it identically.
const coord = /\((-?\d+,-?\d+)\)/;
const inLog = coord.exec(logged)?.[1];
const inMenu = coord.exec(menu)?.[1];
assert.ok(inLog, `the log line names no square: ${logged}`);
assert.ok(inMenu, `the menu line names no square: ${menu}`);
assert.equal(inMenu, inLog, 'the action menu and the log spell the same square differently');
// And the shared spelling is X,Y — east/west first, which is the order the map is drawn in.
assert.equal(inMenu, '2,-1', `not X,Y order: ${inMenu}`);
});
it('says how deep a Department pile is, so a discard can be aimed', () => {
// A discard goes ON TOP, so choosing where to put it is choosing whether to offer a card or to
// bury one a rival wants. Neither is decidable without seeing what is already stacked up.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
assert.deepEqual(view(game).departmentDepth, [1, 1, 1], 'each Department starts one card deep');
// Bury one, and the count must follow.
game.state.decks.departments[1]!.push(game.state.decks.hands.get(0)![0]!);
assert.deepEqual(view(game).departmentDepth, [1, 2, 1], 'the depth does not track the pile');
const label = describeIntent(game.state, { type: 'draw.fromDepartment', slot: 1 });
assert.match(label, /1 buried beneath it/, `the buried card is invisible: ${label}`);
});
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('names a track card by its hand, because the hand is what it can meet', () => {
// Track is drawn from the deck like everything else, so it appears in the hand rather than in a
// supply panel — and "curve" alone does not say which diagonal its 45° leg lies on, which is the
// only thing that decides what it can be joined to.
const game = newGame(31);
const names = new Set<string>();
for (const [, card] of game.state.cards) {
if (card.kind.kind === 'track') names.add(cardName(game.state, card.id));
}
assert.ok(names.has('left-hand turnout'), `turnouts are not named by hand: ${[...names].join(', ')}`);
assert.ok(names.has('right-hand curve'), `curves are not named by hand: ${[...names].join(', ')}`);
assert.ok(names.has('straight'), 'a straight has no hand and must not claim one');
assert.ok(![...names].some((n) => /none/.test(n)), '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: { 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' : ''),
);
};
// A Mine Tipple only ships OUT, so it has no red Unloading box at all — only the boxes an
// industry actually uses are drawn now.
assert.deepEqual(filled(), ['green=FULL', 'maw', 'maw', 'maw'], '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'], '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'], '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'], 'the load did not reach WORK');
});
it('draws the pipeline the way the freight actually flows', () => {
// REPORTED after unloading at a warehouse: "it went W, A, M, and then to the red box at the far
// right." §9.3 runs loading Green → MEN → AT → WORK → car, and unloading the other way entirely:
// car → WORK → AT → MEN → red. So green and red BOTH sit beside MEN and the car sits beside
// WORK — but red was drawn at the far right, which is exactly where the car is, so an unload
// looked like it ran backwards across the row and landed on the end it came from.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
const fac = (kind: string, out: boolean, into: boolean) => ({
geometry: { kind: 'facility', facility: kind },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: kind,
allows: { outbound: out, inbound: into },
outboundBox: [], inboundBox: [], capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
menAtWork: [null, null, null], industryTrack: { cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
});
area.grid.set('-1,0', fac('grocersWarehouse', false, true) as never);
area.grid.set('-1,1', fac('mineTipple', true, false) as never);
const cells = view(game).cells;
const order = (row: number, col: number): string[] => {
const cell = cells.find((c) => c.row === row && c.col === col)!;
return [...officeSvg([cell], 0).matchAll(/class="bs-wb (bs-\w+)/g)].map((m) => m[1]!.replace('bs-', ''));
};
// Receives only: the red box sits BEFORE the sign, which is the end an unload arrives at.
assert.deepEqual(order(-1, 0), ['red', 'maw', 'maw', 'maw'], 'the red box is not beside MEN');
// Ships out only: green before the sign, and no red box at all.
assert.deepEqual(order(-1, 1), ['green', 'maw', 'maw', 'maw'], 'the green box is not beside MEN');
});
it('says what an enhancement does, and admits when it does nothing yet', () => {
// REPORTED from playtesting: an Interlocking on the board is a bare label with no hover text.
// Writing only the printed effect would be worse than silence for the four that are read by
// nothing — a player who builds one to hold a train at the Limit watches it not happen with no
// way to tell a misread card from a bug.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
const office = area.grid.get(`${area.officeCoord.row},${area.officeCoord.col}`)!;
// Interlocking and Telegraph both resolve in play; Overpass is the one card nothing reads.
office.enhancements = ['interlocking', 'telegraph', 'overpass'];
const cell = view(game).cells.find((c) => c.row === area.officeCoord.row && c.col === area.officeCoord.col)!;
const svg = officeSvg([cell], area.runningRow);
const tip = /class="bs-enh"[^>]*data-tip="([^"]*)"/.exec(svg)?.[1] ?? '';
assert.match(tip, /Limit Track/, 'the Interlocking does not say what it is meant to do');
assert.match(tip, /add \+?4/i, 'the Telegraph does not say what it does');
assert.match(tip, /Railroad Crossing/, 'the Overpass does not say what it is meant to do');
// Exactly ONE of the three carries the warning, and it is the Overpass. The first version of
// this test asserted the opposite for Interlocking, which is read at advance.ts:770 — that is
// how four working cards came to be labelled unimplemented on the board.
const warned = tip.split('·').filter((part) => /NOT YET IMPLEMENTED/.test(part));
assert.equal(warned.length, 1, `expected one unimplemented card, got: ${tip}`);
assert.match(warned[0]!, /Overpass/, 'the warning is on the wrong card');
});
it('marks only the enhancements that really are unwired', () => {
/**
* The status is data, so it can drift from reality — and it already did. The first version of
* this test asserted `effect === 'live'` if and only if the rule carried a `dispatchBonus`,
* which was not a check at all: it restated the very assumption that produced the table, so it
* passed while four working cards were labelled unimplemented in the UI.
*
* So this asserts the SET, keyed to what actually reads each one. A card whose behaviour gets
* written must be moved here, and the failure message says where to look.
*/
const by = (e: string): string[] =>
ENHANCEMENT_RULES.filter((r) => r.effect === e).map((r) => r.key).sort();
assert.deepEqual(
by('live'),
['absSignals', 'interlocking', 'radio', 'smallYard', 'telegraph', 'telephone', 'yardOffice'],
'grep the key itself before changing this — interlocking, yardOffice and smallYard are read ' +
'by key in advance.ts/apply.ts, and absSignals through node.absSignals, not via a helper',
);
// Wired and read, but the card that would trigger them is opponent-directed and cut from the
// solitaire deck (Q6): Derail for Facing Point Locks, Watertower for the Water Column.
assert.deepEqual(by('dormantSolo'), ['facingPointLocks', 'waterColumn']);
// Overpass alone has no code path anywhere — it would do nothing even in a multiplayer game.
assert.deepEqual(by('unbuilt'), ['overpass']);
});
it('puts the card just drawn at the FRONT of the hand, and badges it', () => {
// REPORTED from playtesting. The engine pushes a drawn card onto the END of the hand, and with
// the row wrapping that put the card you just turned over wherever the eye is least likely to be
// — among two others that look exactly like it.
const game = newGame(21);
const draw = actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw');
assert.ok(draw, 'this seed does not offer the draw option');
submit(game, draw!);
const before = [...(game.state.decks.hands.get(0) ?? [])];
const take = actionGroups(game).options.find((o) => o.type === 'draw.fromHomeOffice');
assert.ok(take, 'no draw is on offer');
submit(game, take!);
const after = game.state.decks.hands.get(0) ?? [];
const drawn = after.find((id) => !before.includes(id));
assert.ok(drawn, 'nothing was actually drawn');
// The engine still pushes — deliberately, so the bot's hand iteration and every revenue figure
// measured with it are untouched. The reversal is the display's, in both places that show a hand.
assert.equal(after[after.length - 1], drawn, 'the engine should still append; only the views reverse');
assert.equal(actionMenu(game).hand[0]?.cardId, drawn, 'the new card is not first on the play page');
assert.equal(view(game).hand[0], cardName(game.state, drawn!), 'the new card is not first in the replay frame');
assert.equal(game.justDrawn, drawn, 'the badge does not know which card is new');
// The names and their descriptions must reverse together, or the tooltips come off the wrong card.
const f = view(game);
assert.deepEqual(
f.handWhat,
[...after].reverse().map((id) => cardDescription(game.state, id)),
'hand and handWhat are out of step',
);
});
it('draws no freight fittings on a Depot, a Station or a Terminal', () => {
// REPORTED from playtesting: a Depot showed three MEN | AT | WORK boxes. An Office is a Passenger
// Facility — the sign is printed "For Freight Facilities" (§9.1) and every tier is built with 0
// Laborers, so the boxes could never be worked. They were drawn because `menAtWork` was a
// three-slot array of nulls on every facility and this loop had no guard, unlike the green and
// red rows either side of it. The engine now has no pipeline to draw on a passenger facility.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
// Depot 1/1/1, Station 2/2/2, Terminal 3/3/3 (OFFICE_PROFILES). All three carry 0 Laborers and
// — the point of this test — no MEN | AT | WORK pipeline at all.
for (const [tier, n] of [['depot', 1], ['station', 2], ['terminal', 3]] as const) {
area.tier = tier;
const office = area.grid.get(`${area.officeCoord.row},${area.officeCoord.col}`)!;
office.facility = {
kind: 'passenger', subtype: 'office',
allows: { outbound: true, inbound: true },
outboundBox: [{ type: 'coach', loaded: true }], inboundBox: [{ type: 'coach', loaded: true }],
capacity: { outbound: n, inbound: n },
menAtWork: null,
industryTrack: { cars: [] },
laborers: 0, porters: n, usedThisStage: { laborers: 0, porters: 0 },
};
const cell = view(game).cells.find((c) => c.row === area.officeCoord.row && c.col === area.officeCoord.col)!;
const svg = officeSvg([cell], area.runningRow);
const boxes = [...svg.matchAll(/class="bs-wb (bs-\w+)/g)].map((m) => m[1]!.replace('bs-', ''));
assert.ok(!boxes.includes('maw'), `a ${tier} draws MEN | AT | WORK boxes it has no Laborer to work`);
assert.ok(boxes.includes('green') && boxes.includes('red'), `a ${tier} should still show passengers waiting and arrived`);
// And it must not borrow an industry's word for what it does, nor a siding it has no track for.
assert.match(svg, /BOARDS \+ ALIGHTS/, `a ${tier} is labelled like an industry`);
assert.doesNotMatch(svg, /SHIPS|RECEIVES/, `a ${tier} is labelled like an industry`);
assert.equal(
[...svg.matchAll(/class="bs-slot/g)].length, 0,
`a ${tier} draws a siding slot, but a passenger facility has no industry track`,
);
}
});
it('paints inbound boxes red in the side panel, not green', () => {
// REPORTED from playtesting. The panel's shared box helper used ONE class for every filled box,
// so the inbound row rendered green while the board SVG on the same screen drew it red — the two
// views of one card disagreeing about the colour code at the same moment.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'facility', facility: 'grocersWarehouse' },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: 'grocersWarehouse',
allows: { outbound: false, inbound: true },
outboundBox: [], inboundBox: [{ type: 'boxcar', loaded: true }],
capacity: { outbound: 0, inbound: 1 },
menAtWork: [null, null, null], industryTrack: { cars: [] },
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const html = facilitiesHtml(view(game));
assert.match(html, /class="box r"/, 'a cleared inbound load is not painted red');
assert.doesNotMatch(html, /class="box f"/, 'the direction-blind box class is still in use');
});
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: { 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('captions a legal square that already has a card on it', () => {
/**
* REPORTED: "it shows on the map where the possible places are. It also looks like my depot is
* highlighted as well... New squares where I could place my track say PLACE HERE. Limits are
* also highlighted but do not."
*
* Three different acts light up the same blue — building on empty ground, EXTENDING the Running
* Track at a Limits sign, and ATTACHING an Enhancement to a card already down — and only the
* first said anything. A blue outline with no words reads as a bug, not as an offer.
*/
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const cells = view(game).cells;
const limits = cells.find((c) => c.kind === 'limits')!;
const svg = officeSvg(cells, area.runningRow, [], [
{ row: limits.row, col: limits.col, label: 'EXTEND THE RUNNING TRACK HERE' },
]);
assert.match(svg, /EXTEND THE RUNNING TRACK HERE/, 'the Limits sign is offered without a word');
// And the caption must clear the rail down the middle of the card, where the train is drawn.
const y = Number(/class="bs-legalcap"[^>]*>(?:<rect x="\d+" y="(\d+)")/.exec(svg)?.[1]);
assert.ok(y > 48, `the caption at y=${y} prints over the rail`);
});
it('draws the Limits as the edge of the buildable district, at every row', () => {
/**
* Track may not be laid outside the Limits at ANY row now (§2.1), and two signs sitting on the
* Running Track could not say that: a player looking at open ground beyond a sign had no way to
* know nothing of his would ever go there until the square failed to light up.
*
* Just OUTSIDE the sign's own column, because the sign stands ON the boundary — a siding may run
* under it — so a line drawn inside the sign would teach the opposite of the rule.
*/
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const f = view(game);
assert.deepEqual(
f.limits,
{ west: area.limitsWest.col, east: area.limitsEast.col },
'the Frame does not carry the district edges a remote client cannot look up',
);
const svg = officeSvg(f.cells, f.runningRow, [], [], f.limits);
const xs = [...svg.matchAll(/class="bs-limitline" x1="(-?[\d.]+)"/g)].map((m) => Number(m[1]));
assert.equal(xs.length, 2, 'the district was drawn without both of its edges');
// The sign cards themselves must fall INSIDE the two lines.
const signX = [...svg.matchAll(/data-cell="(-?\d+),(-?\d+)"[^>]*transform="translate\((-?[\d.]+)/g)]
.filter((m) => Number(m[2]) === area.limitsWest.col || Number(m[2]) === area.limitsEast.col)
.map((m) => Number(m[3]));
assert.ok(signX.length > 0, 'no Limits sign was drawn to check the boundary against');
for (const x of signX) {
assert.ok(
x >= Math.min(...xs) && x <= Math.max(...xs),
`a Limits sign at x=${x} sits outside its own boundary (${xs.join(', ')})`,
);
}
assert.match(svg, /bs-limitlab/, 'the boundary is drawn but never named');
// AND ON THE CANVAS. The west sign is the leftmost card, so a line drawn outside its column sits
// at a negative x — off a viewBox that starts at 0, which is a boundary nobody can see.
const vb = /viewBox="(-?[\d.]+) (-?[\d.]+) ([\d.]+) ([\d.]+)"/.exec(svg);
assert.ok(vb, 'the board produced no viewBox');
const x0 = Number(vb![1]);
for (const x of xs) {
assert.ok(
x >= x0 && x <= x0 + Number(vb![3]),
`the boundary at x=${x} is drawn outside the canvas (${x0} to ${x0 + Number(vb![3])})`,
);
}
});
it('says on the card which way an industry runs, and how many workers it has', () => {
/**
* TWO REPORTS, ONE CARD.
*
* "Should the display be any different for industries that are send-only versus receive-only?
* At Grocer's Warehouse, which is receive only, and Refinery, which is send only, the men at
* work boxes, the arrows and the green-red boxes look the same in both." They were different —
* by the stroke colour of one 13x12px box and the direction of a 10px chevron, which is not a
* difference a player can see.
*
* And: "how do I see the number of laborers in an industry card?" You could not. The number
* that decides every Cargo phase was in a side panel only.
*/
const game = newGame(5);
const industry = (kind: string, out: boolean, into: boolean, laborers: number) => {
game.state.officeAreas.get(0)!.grid.set('-1,0', {
geometry: { kind: 'facility', facility: kind },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: kind,
allows: { outbound: out, inbound: into },
outboundBox: [], inboundBox: [],
capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
menAtWork: [null, null, null],
industryTrack: { cars: [] },
laborers, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
return officeSvg([cell], 0);
};
const ships = industry('refinery', true, false, 2);
const receives = industry('grocersWarehouse', false, true, 1);
assert.match(ships, /SHIPS OUT/, 'a send-only industry does not say so');
assert.match(receives, /RECEIVES/, 'a receive-only industry does not say so');
assert.doesNotMatch(ships, /RECEIVES/, 'a send-only industry claims to receive');
// And the two cards must not be one another with a different tint.
const strip = (svg: string): string => svg.replace(/(SHIPS OUT|RECEIVES|L \d\/\d)/g, '');
assert.notEqual(strip(ships), strip(receives), 'the two industries draw identically');
assert.match(ships, /bs-crewlab[^>]*>L 2\/2</, 'the Laborer count is not on the card');
assert.match(receives, /bs-crewlab[^>]*>L 1\/1</, 'the Laborer count is not on the card');
});
it('shows the Office its Porters, and what a Modifier added', () => {
// REPORTED: "I added a restaurant. That gave me an extra outbound slot, so I see an extra green
// box. And it gave me an extra porter that I see no indication of anywhere." Porters were on
// the view-model and no renderer had ever drawn them.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
const office = area.grid.get(`${area.runningRow},0`)!;
area.tier = 'depot';
office.facility!.porters = 2; // a Depot prints 1; a Restaurant beside it adds one
office.facility!.capacity = { outbound: 2, inbound: 1 };
office.facility!.allows = { outbound: true, inbound: true };
const cell = view(game).cells.find((c) => c.kind === 'office')!;
assert.match(officeSvg([cell], area.runningRow), /bs-crewlab[^>]*>P 2\/2</, 'the card does not show Porters');
assert.match(cell.what, /2 porters/, 'the tooltip still reads the printed tier, not the Office as it stands');
assert.match(cell.what, /Modifiers beside it add/, 'nothing says where the extra came from');
// And the panel must not invent a Modifier that is not there.
const plain = newGame(5);
const parea = plain.state.officeAreas.get(0)!;
parea.tier = 'depot';
const poffice = parea.grid.get(`${parea.runningRow},0`)!;
poffice.facility!.porters = 1;
poffice.facility!.capacity = { outbound: 1, inbound: 1 };
poffice.facility!.allows = { outbound: true, inbound: true };
const html = facilitiesHtml(view(plain));
assert.doesNotMatch(html, /class="added"/, 'a plain Depot is credited with a Modifier it does not have');
});
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: { 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: [], enhancementsWhat: [],
trains: [], adTracks: null, cars: [], standingWest: 0, facility: null, links, what: '',
};
const svg = officeSvg([cell] as never, 0);
return [...svg.matchAll(/<line class="bs-rail" x1="([-\d.]+)" y1="([-\d.]+)" x2="([-\d.]+)" y2="([-\d.]+)"\/>/g)]
.map((m) => ({ x1: Number(m[1]), y1: Number(m[2]), x2: Number(m[3]), y2: Number(m[4]) }));
};
/**
* A 45° leg is a curved `<path>` now (v0.5.0), not straight `<line>`s — see `curvedRail` in
* `board-svg.ts`. Each returned array is the sampled polyline for one of the leg's two rails, in
* order from the through edge to the diagonal edge.
*/
const drawCurve = (links: string[]): { x: number; y: number }[][] => {
const cell = {
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
trains: [], adTracks: null, cars: [], standingWest: 0, facility: null, links, what: '',
};
const svg = officeSvg([cell] as never, 0);
return [...svg.matchAll(/<path class="bs-rail" fill="none" d="M([^"]+)"\/>/g)].map((m) =>
m[1]!.split(' L').map((pair) => {
const [x, y] = pair.trim().split(' ').map(Number);
return { x: x!, y: y! };
}),
);
};
/** Undirected angle in degrees between two points, 0–180. */
const angleOf = (a: { x: number; y: number }, b: { x: number; y: number }): number => {
const deg = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI;
return ((deg % 180) + 180) % 180;
};
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 the through edge level and the diagonal edge at 45°, curved rather than kinked', () => {
// The TRUE tangent is exactly 0° at the through edge and exactly 45°/135° at the diagonal edge —
// that is what the curve's control points are chosen to hit (see `curvedRail`'s call site). What
// is rendered is a sampled polyline, so the first and last drawn segment only APPROXIMATE that
// limit; a few degrees off at the sample count in use, well inside the tolerance below.
for (const links of [['ne'], ['nw'], ['se'], ['sw'], ['ew', 'ws'], ['ew', 'en']]) {
const [rail] = drawCurve(links);
assert.ok(rail && rail.length > 2, `${links} drew no curved leg`);
const start = angleOf(rail[0]!, rail[1]!);
const end = angleOf(rail[rail.length - 2]!, rail[rail.length - 1]!);
assert.ok(start <= 6 || start >= 174, `${links} leaves the through edge at ${start}°, not level`);
const offDiagonal = Math.min(Math.abs(end - 45), Math.abs(end - 135));
assert.ok(offDiagonal <= 6, `${links} meets the diagonal edge at ${end}°, not 45°/135°`);
}
});
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 continuous curve and a mismatched pair
// reads as the V it is. Drawn alike, the picture would claim a join the engine refuses.
const family = (links: string[]): 'rising' | 'falling' => {
const [rail] = drawCurve(links);
const end = angleOf(rail![rail!.length - 2]!, rail![rail!.length - 1]!);
return Math.abs(end - 45) < Math.abs(end - 135) ? 'rising' : 'falling';
};
assert.equal(family(['ne']), family(['sw']), 'ne and sw must lie on the same diagonal');
assert.equal(family(['nw']), family(['se']), 'nw and se must lie on the same diagonal');
assert.notEqual(family(['ne']), family(['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. Exact,
// not approximate: the curve's LAST sampled point is always the true edge point (`edge` in
// `curvedRail`'s call site, not a discretised approximation of it), so averaging the two rails'
// endpoints cancels their perpendicular offset exactly, the same way it did when the leg was two
// straight segments.
const W = 166;
const rails = drawCurve(['sw']);
assert.equal(rails.length, 2, 'the leg is two rails');
const atEdge = rails.map((r) => r[r.length - 1]!.x);
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, or says it is not on this board', () => {
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) {
// A spot with no coordinate is a placement out on the Mainline, and it must say so — the
// board cannot highlight it, so the label is the only thing telling the player where it
// goes. It used to carry the fake coordinate (-1, node), which is a real district row.
if (sp.coord === null) {
assert.equal(typeof sp.node, 'number', `${it.subject} has neither a coordinate nor a node`);
assert.match(sp.label, /out on the Mainline/, `${it.subject}: no coordinate and no explanation`);
continue;
}
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.col}, ${sp.coord.row})`), '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 { option: lay } = dealTrack(game, 'turnout', 'left');
assert.ok(lay, 'no turnout could be laid — the district can never open');
assert.ok(submit(game, lay), 'the turnout was refused');
// A curve of the same hand is what the square below that leg wants; deal one so there is
// something to highlight there.
dealTrack(game, 'curved', 'left');
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 !== null && (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.
/**
* THE SEED IS SEARCHED FOR, not written down. This took seed 111 flat and asserted its board
* ended up with more than four cards on it. Gitea#3 changed how fast trains cross, which changes
* how a game unfolds, and 111 stopped building enough of a district — so the test failed on its
* own precondition rather than on anything about tooltips.
*
* It needs A well-built board, not one particular one, so it takes the first seed that gives it.
*/
let game = newGame(111);
for (let seed = 111; seed < 211; seed++) {
game = newGame(seed);
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[0]!;
if (!submit(game, pick)) break;
}
if (view(game).cells.length > 4) break;
}
const cells = view(game).cells;
assert.ok(cells.length > 4, 'no seed under 211 built enough of a board 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: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
// Said the way a player would ask it: if my train comes in from over there, where can it go?
// "stem east, through west, diverges south" is three pieces of jargon and a compass reading.
assert.match(
cell.what,
/allows traffic from the east to travel west or turn to the south/,
`the turnout does not say what it does: ${cell.what}`,
);
assert.match(cell.what, /the two roads never join/, '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<string>();
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')!);
// A Station upgrade is PUT in hand rather than drawn for. This used to take whatever seed 111
// happened to deal, which made it luck: the moment deck composition changed it dealt no upgrade
// and the test failed without anything being wrong.
const station = [...game.state.cards.entries()].find(
([, c]) => c.kind.kind === 'office' && c.kind.tier === 'station',
);
assert.ok(station, 'no Station card in the deck');
game.state.decks.hands.set(0, [station![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, 'no Office upgrade in hand — the fixture failed to place one');
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('describes the piece it would actually lay, not its mirror image', () => {
// REGRESSION, and a silent one: the placement was always right and only the words were wrong.
// `rotationNote` called `variantsFor(geometry)` without the hand, which answers for the LEFT-hand
// card whatever you are holding — so every right-hand turnout was offered as "stem west, through
// east, diverges south", the mirror of the card it would lay, and every right-hand curve named
// the wrong edge. A player aiming a crossover down onto a siding was reading the opposite of
// what they would get.
const say: Record<string, string> = { n: 'north', s: 'south', e: 'east', w: 'west' };
for (const geometry of ['turnout', 'curved', 'sharpCurved'] as const) {
for (const hand of ['left', 'right'] as const) {
variantsFor(geometry, hand).forEach((v, variant) => {
const label = variantLabel(geometry, variant, hand);
if (v.turnout) {
assert.equal(
label.trim(),
`— allows traffic from the ${say[v.turnout.stem]} to travel ${say[v.turnout.through]} ` +
`or turn to the ${say[v.turnout.diverge]}`,
`${geometry}/${hand} v${variant}`,
);
} else if (v.arc) {
const [a, b] = [v.arc[0]!, v.arc[1]!];
const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b];
assert.equal(
label.trim(),
`— carries traffic from the ${say[side!]} round to the ${say[leg!]}`,
`${geometry}/${hand} v${variant}`,
);
}
});
}
}
});
it('says what a placement would connect to, so a crossover can be aimed', () => {
// A turnout laid under a turnout is a crossover, and it is how a siding gets a track running
// parallel to the Running Track. It was always legal; nothing on screen said which card and
// which rotation would actually meet the leg coming down.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const { option: first } = dealTrack(game, 'turnout', 'left');
assert.ok(first && first.type === 'card.play' && first.placement, 'no turnout could be laid');
assert.ok(submit(game, first), 'the turnout was refused');
// The crossover needs a second turnout of the SAME hand — the diagonal has to match.
dealTrack(game, 'turnout', 'left');
const under = { row: first.placement.row - 1, col: first.placement.col };
const spots = actionMenu(game)
.placeable.flatMap((g) => g.items)
.filter((it) => it.subject.includes('turnout'))
.flatMap((it) => it.spots)
.filter((sp) => sp.coord?.row === under.row && sp.coord.col === under.col);
assert.equal(spots.length, 1, 'exactly one turnout rotation should meet the leg coming down');
assert.match(spots[0]!.label, /turn to the north/, 'the crossover turnout must point back up');
assert.match(spots[0]!.label, /joins the track above/, 'the spot must say what it connects to');
});
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('says an industry on the Running Track does not belong there', () => {
// It cannot legally BE there any more — the sheet puts every industry on "Straight, Stub (not on
// Running Track)" and `check` returns ON_RUNNING_TRACK — so this is the card text holding up if
// one somehow is, not a warning about a placement a player can still make.
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: { 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, /belong on a stub/, 'does not say where an industry belongs');
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 = { kind: 'clearance', 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', () => {
// SOLO_CONFIG's minCombinedRevenue is collectiveRevenueFloor(1, 5) = 15 (game.ts) — the old
// fixed target of 20 retired 2026-08-20 with the victory-condition redesign.
const f = view(newGame(430));
assert.equal(f.objective.target, 15);
assert.equal(f.objective.days, 5);
assert.match(f.objective.note, /of 15/);
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<string, number>();
for (const sp of item.spots) {
if (sp.coord === null) continue;
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('takes the last action back, and keeps taking', () => {
/**
* "Is there a way to remove a card that has been misplayed? In a solitaire game it might be
* nice to say oops, take that back."
*
* The save IS the game, so undo is a replay without the last intent — which means the position
* it lands on must be EXACTLY the position that was there before, board, Division, clock and
* revenue. Anything less and undo becomes its own source of divergence.
*/
const game = newGame(430);
const marks: { cells: unknown; division: unknown; revenue: number; day: number; stage: number }[] = [];
const mark = (g: typeof game) => ({
cells: view(g).cells,
division: view(g).division,
revenue: g.state.players[0]!.revenue,
day: g.state.clock.day,
stage: g.state.clock.stage,
});
for (let i = 0; i < 60; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
marks.push(mark(game));
if (!submit(game, options[0]!)) break;
}
assert.ok(marks.length > 20, 'the game did not get far enough to prove anything');
// Walk all the way back, checking each step lands on the position it came from.
let now = game;
for (let i = marks.length - 1; i >= 0; i--) {
const back = undo(now);
assert.ok(back, `undo refused with ${now.history.length} moves still on the clock`);
now = back;
assert.deepEqual(mark(now), marks[i], `undo ${marks.length - i} landed somewhere else`);
}
assert.equal(now.history.length, 0, 'walking all the way back did not empty the history');
assert.equal(undo(now), null, 'undo must refuse when there is nothing to take back');
});
it('leaves nothing in the log describing a move that was taken back', () => {
// The history panel is rebuilt with the game, so it cannot be left narrating an action that no
// longer happened — the single most confusing thing a half-undo could do.
const game = newGame(202);
for (let i = 0; i < 30; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
const before = game.log.length;
const back = undo(game)!;
assert.ok(back.log.length <= before, 'the log grew when a move was taken back');
assert.deepEqual(back.log, fromSave(toSave(back)).log, 'the rebuilt log is not the replayed log');
});
it('replays a save into the same words the live game wrote', () => {
/**
* THE COMPARISON THAT WAS MISSING. `fromSave`'s loop called `record(game, result.events)` with
* no `actor`, so every restored save, every undo (which rebuilds through `fromSave`) and the
* replay viewer stripped the "Player X" prefix off every attributed line — describing the same
* moves in different words from the game that produced them.
*
* It went unnoticed because nothing compared a `fromSave`-built log against a LIVE-played one.
* The test above compares one rebuilt log against another rebuilt log, so the gap cancelled out
* on both sides and stayed green throughout. This plays a game, saves it, restores it, and
* asserts the two logs are identical — the direction that catches it.
*/
const live = newGame(202);
for (let i = 0; i < 30; i++) {
if (currentActor(live) === null) break;
const { options } = actionGroups(live);
if (options.length === 0 || !submit(live, options[0]!)) break;
}
const attributed = live.log.filter((l) => l.text.startsWith('Player '));
assert.ok(attributed.length > 0, 'the live game attributed nothing, so this proves nothing');
assert.deepEqual(
fromSave(toSave(live)).log,
live.log,
'a restored game does not narrate what the live game narrated',
);
});
it('does not mangle a narration that opens with a shouted keyword', () => {
/**
* `record` folds a narration's first word into the middle of a sentence — "Chose to draw" has to
* read "Player Bob chose to draw". It used to do that with a flat `charAt(0).toLowerCase()`, so
* every line opening with an all-caps keyword came out as `Player Solitaire eXTRA X18 started…`.
* The same happened to `TRAIN 1 MADE UP` and `COLLISION`, which are shouted on purpose.
*/
const game = newGame(202);
for (let i = 0; i < 120; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
const mangled = game.log.filter((l) => /^Player .* [a-z][A-Z]{2}/.test(l.text));
assert.deepEqual(mangled, [], 'a shouted keyword was lowercased into the middle of a word');
// And the ordinary case still folds, or the prefix would read "Player Bob Chose to draw".
const folded = game.log.filter((l) => /^Player \S+ [a-z]/.test(l.text));
assert.ok(folded.length > 0, 'nothing was folded at all — the prefix is no longer a sentence');
});
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<string, unknown>;
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', () => {
// Its OWN directory (`dist-test/`), not the shared `dist/` every other test in this file reads
// — see the comment on `distTest` above.
execFileSync('node', ['scripts/build-web.ts'], {
cwd: root,
stdio: 'pipe',
env: { ...process.env, BUILD_DIST_DIR: 'dist-test' },
});
assert.ok(existsSync(join(distTest, 'index.html')), 'no index.html');
assert.ok(existsSync(join(distTest, 'web/main.js')), 'no entry script');
assert.ok(existsSync(join(distTest, '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, and points at the square an action names', 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<string, Record<string, unknown>>();
const injected: Record<string, unknown>[] = [];
const make = (): Record<string, unknown> => {
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<string, Record<string, unknown>[]>();
const found = new Map<string, Record<string, unknown>>();
// 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<string>();
// The New Game dialog is a native <dialog>: the page listens for `close` on it at load, and
// opens it with `showModal`. Without these the page throws before it draws anything — which
// is a page that never starts, exactly what this stub exists to catch.
const listeners = new Map<string, ((e?: unknown) => void)[]>();
/**
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
* Office Area's segmented control (TODO #16): without it every render threw
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
* accessible control ships green and unexercised.
*/
const attrs: Record<string, string> = {};
const node: Record<string, unknown> = {
attrs,
setAttribute: (k: string, v: string) => void (attrs[k] = v),
getAttribute: (k: string) => attrs[k] ?? null,
/**
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
* `setAttribute` taught this factory above, learned again on 2026-09-16.
*
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
* display name is another player's text and must never be interpolated into markup. Without
* this the district panel throws on every render.
*/
appendChild: () => {},
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
title: '', returnValue: '', open: false,
addEventListener: (type: string, fn: (e?: unknown) => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal() {
(node as { open: boolean }).open = true;
},
close(value?: string) {
(node as { open: boolean; returnValue: string }).open = false;
if (value !== undefined) (node as { returnValue: string }).returnValue = value;
for (const fn of listeners.get('close') ?? []) fn();
},
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<string>();
found.set(key, {
onclick: null,
classList: {
add: (c: string) => void classes.add(c),
// Pointing at a square is a HOVER: it has to come off again when the cursor leaves.
remove: (c: string) => void classes.delete(c),
has: (c: string) => classes.has(c),
},
classes,
});
}
return found.get(key);
},
highlighted: () => [...found.values()].filter((f) => (f['classes'] as Set<string>).size > 0),
querySelectorAll(sel: string) {
const hit = cache.get(sel);
if (hit) return hit;
const out: Record<string, unknown>[] = [];
const re = sel.startsWith('[')
? /data-at="([^"]*)"/g
: new RegExp(`<button class="${sel.split('.')[1]}[^"]*"([^>]*)>`, 'g');
let m: RegExpExecArray | null;
while ((m = re.exec(html)) !== null) {
const data: Record<string, string> = {};
if (sel.startsWith('[')) data['at'] = m[1]!;
else {
const attr = /data-(\w+)="([^"]*)"/g;
let a: RegExpExecArray | null;
while ((a = attr.exec(m[1]!)) !== null) data[a[1]!] = a[2]!;
}
out.push({ dataset: data, onclick: null });
}
cache.set(sel, out);
return out;
},
};
Object.defineProperty(node, 'innerHTML', {
get: () => html,
set: (v: string) => {
html = v;
cache = new Map();
},
});
return node;
};
// STRICT: only ids that really exist in the served page. The stub used to conjure an element
// for any id asked for, so a `$('target')` left behind after removing #target from the HTML
// would pass here and throw on load in a browser — the page would simply never start.
// `[\w-]` and not `[a-zA-Z]`: a hyphenated id is valid HTML, and the narrower pattern skipped
// those silently — which would have let a genuinely MISSING `#tc-day` pass this test too.
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map(
(m) => m[1]!,
),
);
const g = globalThis as Record<string, unknown>;
// The page installs a tooltip layer on load, so the stub needs enough DOM to let it.
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make());
return els.get(id);
},
createElement: () => make(),
addEventListener: () => {},
// The shared rules block (`settings-form.ts`) addresses its radio groups by NAME through the
// document, since they live in two different screens. Empty is the right answer here: these
// suites drive the BOARD, not the New Game dialog.
querySelectorAll: () => [],
body: { appendChild: () => {} },
// Capture injected stylesheets. The board is SVG styled entirely by class, so a page that
// renders every card correctly and never loads BOARD_CSS draws them black on black — visibly
// broken, and invisible to a stub that ignores CSS.
head: { appendChild: (node: Record<string, unknown>) => void injected.push(node) },
};
g['location'] = { search: '?seed=555' };
const store = new Map<string, string>();
g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
// No parameter properties — `erasableSyntaxOnly` forbids them, since Node strips types rather
// than compiling them.
g['URLSearchParams'] = class {
search: string;
constructor(search: string) {
this.search = search;
}
get(k: string): string | null {
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
}
};
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
const actions = els.get('actions')!;
const grid = els.get('grid')!;
const hand = els.get('hand')!;
// Take the draw option, then pick a card's PLAY verb — the hand is the action surface now, so
// the subject is chosen on the card rather than in a separate list.
const clickFirst = (el: Record<string, unknown>, sel: string): boolean => {
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
const nodes = fn.call(el, sel);
const target = nodes[0];
if (!target) return false;
(target['onclick'] as (() => void) | null)?.();
return true;
};
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
// Specifically the PLAY verb: a hand usually holds cards that can only be discarded, and
// discarding highlights the Department piles rather than the board.
const clickVerb = (el: Record<string, unknown>, verb: string): boolean => {
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
const target = fn
.call(el, 'button.cardact')
.find((n) => (n['dataset'] as Record<string, string>)['verb'] === verb);
if (!target) return false;
(target['onclick'] as (() => void) | null)?.();
return true;
};
/**
* TRY EVERY PLAY BUTTON, NOT JUST THE FIRST — a card offering "play" does not necessarily play
* ONTO THE BOARD.
*
* This clicked the first play verb it found and then asserted the board had lit up. A train card
* plays to the timetable and a Department discard to the piles, so neither lights a square, and
* whether the first playable card in this deal happens to be a track or facility card is luck.
* Gitea#14's deck counts re-dealt seed 555, the first play verb landed on Train 2, and the test
* failed claiming the page highlighted nothing — when the page was right and the card simply had
* no square to point at.
*
* So it clicks each play button in turn until the board lights, which is the property the test
* is named for. It still fails loudly if NO card in hand can light a square.
*
* EACH BUTTON IS CLICKED EXACTLY ONCE. Picking a card is a toggle, so clicking one to check it
* and then clicking it again inside the loop would UNPICK it — which is how the first draft of
* this managed to fail on a deal whose very first play card was a good one.
*/
const playButtons = (el: Record<string, unknown>): Record<string, unknown>[] => {
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
return fn
.call(el, 'button.cardact')
.filter((n) => (n['dataset'] as Record<string, string>)['verb'] === 'play');
};
assert.ok(playButtons(hand).length > 0, 'no card in hand offers a play verb');
let litTheBoard = false;
for (const target of playButtons(hand)) {
(target['onclick'] as (() => void) | null)?.();
const drawn = String(grid['innerHTML']);
if ((grid['highlighted'] as () => unknown[])().length > 0 || /data-ghost="/.test(drawn)) {
litTheBoard = true;
break;
}
}
// Without the board stylesheet every shape is drawn black on a near-black background: the page
// looks empty even though the markup is perfect.
const styles = injected.map((n) => String(n['textContent'] ?? '')).join('\n');
assert.match(styles, /\.bs-card\s*\{/, 'the page never loaded the board styles — cards would be invisible');
assert.match(styles, /\.bs-rail\s*\{/, 'the page never loaded the rail styles');
const html = String(grid['innerHTML']);
// The board draws cards as addressable groups; legality is an added class or a drawn ghost.
assert.match(html, /data-cell="/, 'the board drew no addressable cards');
const lit = (grid['highlighted'] as () => unknown[])();
const ghosts = /data-ghost="/.test(html);
assert.ok(
litTheBoard || lit.length > 0 || ghosts,
'no card in hand, picked in turn, ever highlighted a square on the board',
);
/**
* POINTING AT THE SQUARE A BUTTON MEANS — wired on the emitted bundle, not asserted off the menu.
*
* The menu carrying a coordinate proves nothing about whether the page draws it, which is the
* same reason the highlight check above loads the real bundle. Reported by Jesse: the action list
* is a column of near-identical sentences separated by "(1,3)" against "(-1,3)", and picking the
* wrong one is a click to undo in solitaire and unrecoverable in a multiplayer game.
*/
const withSquare = (
(actions['querySelectorAll'] as (s: string) => Record<string, unknown>[])
.call(actions, 'button.act')
.filter((n) => (n['dataset'] as Record<string, string>)['square'])
);
assert.ok(withSquare.length > 0, 'no action button carries the square it acts on');
const btn = withSquare[0]!;
const key = (btn['dataset'] as Record<string, string>)['square']!;
assert.match(key, /^-?\d+,-?\d+$/, `"${key}" is not a grid coordinate`);
(btn['onmouseenter'] as (() => void) | null)?.();
const pointed = (grid['highlighted'] as () => Record<string, unknown>[])().filter((g) =>
(g['classes'] as Set<string>).has('bs-point'),
);
assert.equal(pointed.length, 1, `hovering the action for (${key}) marked ${pointed.length} squares`);
(btn['onmouseleave'] as (() => void) | null)?.();
assert.equal(
(grid['highlighted'] as () => Record<string, unknown>[])().filter((g) =>
(g['classes'] as Set<string>).has('bs-point'),
).length,
0,
'the square stayed lit after the cursor left the button',
);
});
it('remembers auto-focus, sound and zoom across a reload, separately from the save', async () => {
// A lighter DOM stub than the highlight test above — this never touches board highlighting or
// action buttons, only the settings-bearing elements and `localStorage` itself.
const els = new Map<string, Record<string, unknown>>();
const injected: Record<string, unknown>[] = [];
const make = (): Record<string, unknown> => {
let html = '';
const listeners = new Map<string, ((e?: unknown) => void)[]>();
const ownClasses = new Set<string>();
/**
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
* Office Area's segmented control (TODO #16): without it every render threw
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
* accessible control ships green and unexercised.
*/
const attrs: Record<string, string> = {};
const node: Record<string, unknown> = {
attrs,
setAttribute: (k: string, v: string) => void (attrs[k] = v),
getAttribute: (k: string) => attrs[k] ?? null,
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
title: '', returnValue: '', open: false,
/**
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
*
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
* display name is another player's text and must never be interpolated into markup. Without
* this the district panel throws on every render.
*/
appendChild: () => {},
addEventListener: (type: string, fn: (e?: unknown) => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal() {
(node as { open: boolean }).open = true;
},
close(value?: string) {
(node as { open: boolean; returnValue: string }).open = false;
if (value !== undefined) (node as { returnValue: string }).returnValue = value;
for (const fn of listeners.get('close') ?? []) fn();
},
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)),
},
// No real `<svg>` child: `applyZoom` looks for one, finds none on this stub, and no-ops —
// the same way it does on a board card the stub never renders. Sizing the actual SVG needs a
// real DOM (jsdom or a browser), which this test suite does not carry; what IS checked here —
// the persisted setting, the button labels, the click wiring — is everything observable
// without one. `querySelectorAll` empty rather than absent: several unrelated render steps
// (crew markers, hand buttons, department piles) iterate whatever their element returns.
querySelector: () => null,
querySelectorAll: () => [],
};
Object.defineProperty(node, 'innerHTML', {
get: () => html,
set: (v: string) => void (html = v),
});
return node;
};
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map(
(m) => m[1]!,
),
);
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make());
return els.get(id);
},
createElement: () => make(),
addEventListener: () => {},
// The shared rules block (`settings-form.ts`) addresses its radio groups by NAME through the
// document, since they live in two different screens. Empty is the right answer here: these
// suites drive the BOARD, not the New Game dialog.
querySelectorAll: () => [],
body: { appendChild: () => {} },
head: { appendChild: (node: Record<string, unknown>) => void injected.push(node) },
};
g['location'] = { search: '?seed=555' };
const store = new Map<string, string>();
// Seeded BEFORE import: settings load once, at module top level, from whatever is already there.
store.set(
'station-master.settings.v1',
JSON.stringify({ districtMode: 'open', soundOn: true, zoom: 1.25 }),
);
g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
g['URLSearchParams'] = class {
search: string;
constructor(search: string) {
this.search = search;
}
get(k: string): string | null {
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
}
};
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
// The seeded settings took effect on load, before any click.
assert.match(String(els.get('sound')!['textContent']), /sound/, 'sound started ON, as seeded');
assert.equal(els.get('zoomlabel')!['textContent'], '125%', 'zoom started at the seeded level');
// Zooming in from 125% goes to 150% and persists; the button disables at the top of the range.
(els.get('zoomin')!['onclick'] as () => void)();
assert.equal(els.get('zoomlabel')!['textContent'], '150%');
assert.equal(els.get('zoomin')!['disabled'], true, 'already at the highest preset');
assert.equal(
(JSON.parse(store.get('station-master.settings.v1')!) as { zoom: number }).zoom,
1.5,
'the new zoom level was written back to localStorage',
);
// Muting persists too, independently of zoom.
(els.get('sound')!['onclick'] as () => void)();
assert.match(String(els.get('sound')!['textContent']), /muted/);
assert.equal(
(JSON.parse(store.get('station-master.settings.v1')!) as { soundOn: boolean }).soundOn,
false,
);
});
it('falls back to the defaults when settings are missing or corrupt, rather than throwing', async () => {
const els = new Map<string, Record<string, unknown>>();
const make = (): Record<string, unknown> => {
/**
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
* Office Area's segmented control (TODO #16): without it every render threw
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
* accessible control ships green and unexercised.
*/
const attrs: Record<string, string> = {};
const node: Record<string, unknown> = {
attrs,
setAttribute: (k: string, v: string) => void (attrs[k] = v),
getAttribute: (k: string) => attrs[k] ?? null,
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
title: '', returnValue: '', open: false,
/**
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the lesson
* `setAttribute` taught these factories in 2026-08-30, learned again on 2026-09-16.
*
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
* display name is another player's text and must never be interpolated into markup. Without
* this the district panel throws on every render.
*/
appendChild: () => {},
addEventListener: () => {},
showModal() {},
close() {},
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
querySelector: () => null,
querySelectorAll: () => [],
};
let html = '';
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
return node;
};
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map(
(m) => m[1]!,
),
);
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make());
return els.get(id);
},
createElement: () => make(),
addEventListener: () => {},
// The shared rules block (`settings-form.ts`) addresses its radio groups by NAME through the
// document, since they live in two different screens. Empty is the right answer here: these
// suites drive the BOARD, not the New Game dialog.
querySelectorAll: () => [],
body: { appendChild: () => {} },
head: { appendChild: () => {} },
};
g['location'] = { search: '?seed=556' };
g['localStorage'] = {
// Neither a JSON parse error nor a thrown access should reach the page — a full or disabled
// localStorage must not take the game down with it, the same guard the save already has.
getItem: () => {
throw new Error('storage disabled');
},
setItem: () => {},
removeItem: () => {},
};
g['URLSearchParams'] = class {
search: string;
constructor(search: string) {
this.search = search;
}
get(k: string): string | null {
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
}
};
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
assert.match(String(els.get('sound')!['textContent']), /muted/, 'sound defaults OFF');
assert.equal(els.get('zoomlabel')!['textContent'], '100%', 'zoom defaults to 100%');
});
it('busts the cache on every module, so a deploy cannot half-load', () => {
// The first real deploy served a fresh index.html against a CACHED main.js: the HTML had
// dropped an element the old script still asked for, so the page threw `missing element:
// target` and never started. Filenames never change, so without a version query a static host
// will happily mix two builds.
const html = readFileSync(join(dist, 'index.html'), 'utf8');
const entry = /<script type="module" src="([^"]+)"/.exec(html)?.[1] ?? '';
assert.match(entry, /\?v=/, 'the entry script is not cache-busted');
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);
let checked = 0;
for (const f of files) {
const src = readFileSync(f, 'utf8');
for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*?from\s+['"](\.[^'"]+)['"]/gm)) {
assert.match(m[1]!, /\.js\?v=/, `${f} imports ${m[1]} without a version`);
checked++;
}
}
assert.ok(checked > 5, `only ${checked} relative imports seen — the check is too weak`);
});
it('resolves every busted import to a real file', () => {
// Appending a query must not break resolution: the path before `?` still has to exist.
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);
for (const f of files) {
const src = readFileSync(f, 'utf8');
for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*?from\s+['"](\.[^'"]+)['"]/gm)) {
const target = resolve(dirname(f), m[1]!.split('?')[0]!);
assert.ok(existsSync(target), `${f} imports ${m[1]}, which does not exist`);
}
}
});
it('renders from the Frame and the Menu, never from GameState', () => {
/**
* THE PROPERTY THAT MAKES A REMOTE CLIENT POSSIBLE.
*
* `main.ts` used to reach into `game.state` in eleven places — the phase, the outcome, another
* player's name, the hand count. That is free with the engine in the same process and impossible
* with a server, where the client holds no state at all: it has neither the deck order nor
* anyone else's hand, and could not be given them without handing over the game.
*
* Everything it needs now lives on `Frame` and `Menu`. This is a cheap guard on a property that
* is very easy to lose — one `game.state.clock.day` would compile, run, and quietly make the
* page unable to run against a server. See `docs/architecture/multiplayer.md` §5.
*/
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
const reads = [...src.matchAll(/game\.state[.[]/g)];
assert.equal(
reads.length, 0,
`main.ts reaches into game.state ${reads.length} time(s); it must render from Frame + Menu`,
);
});
it('asks only for elements the page actually has', () => {
// Cheap and total: compare every $('id') in the source against the ids in the served HTML.
// Getting this wrong does not degrade the page, it stops the game starting at all.
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
const html = readFileSync(join(dist, 'play.html'), 'utf8');
const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
// Created by the ending screen (`renderEnding`) before they are looked up, so they are never in
// the served HTML: `again` starts a new game, `results` reopens the results dialog, and the two
// `extend-*` buttons are the extension vote (Gitea#11).
for (const id of ['again', 'results', 'extend-yes', 'extend-no']) present.add(id);
for (const id of asked) {
assert.ok(present.has(id), `main.ts asks for #${id}, which the page does not contain`);
}
});
it('draws the same turn chart on all three screens', () => {
// A game gets drawn in three places — the playable page, the site's replay viewer and the
// standalone replay file — and where you are in the Day is the thing a player glances at most
// often. It lived only in the playable page: a replay reported the Day and phase as two plain
// strings with none of the violet "you are here" the live game uses, so the same position looked
// like a different game depending on which screen you were on.
//
// ONE renderer, asserted here by source rather than by eye, because three copies of a chart is
// exactly the kind of thing that drifts quietly.
for (const f of ['src/web/main.ts', 'src/web/replays.ts', 'src/sim/replay.ts']) {
const src = readFileSync(join(root, f), 'utf8');
assert.match(src, /turnChartHtml/, `${f} does not use the shared turn chart`);
assert.match(src, /TURNCHART_CSS/, `${f} does not ship the shared turn-chart styling`);
}
// And no screen may keep a private copy of the phase table.
for (const f of ['src/web/main.ts', 'src/web/replays.ts', 'src/sim/replay.ts']) {
const src = readFileSync(join(root, f), 'utf8');
assert.doesNotMatch(src, /key: 'shiftChange'/, `${f} has its own copy of the phase table`);
}
});
it('puts every verb on the card it belongs to', () => {
// THE HAND IS THE ACTION SURFACE. A card used to appear twice under two different models: as a
// subject to play, and as one flat button per Department to discard. For a four-card hand that
// discard block alone was twelve buttons repeating three choices four times.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const menu = actionMenu(game);
const held = game.state.decks.hands.get(0) ?? [];
assert.equal(menu.hand.length, held.length, 'every card in hand needs an entry');
for (const h of menu.hand) {
assert.ok(h.name.length > 0, 'a hand entry with no name');
// A verb is only offered when the engine would accept it.
for (let slot = 0; slot < 3; slot++) {
const idx = h.discard[slot];
if (idx === null || idx === undefined) continue;
const i = menu.options[idx]!;
assert.equal(i.type, 'card.discard');
assert.equal((i as { cardId: string }).cardId, h.cardId);
assert.equal((i as { toSlot: number }).toSlot, slot, 'a discard is keyed to the wrong Department');
}
if (h.playNow !== null) {
const i = menu.options[h.playNow]!;
assert.equal(i.type, 'card.play');
assert.equal((i as { placement?: unknown }).placement, undefined, 'playNow must need no square');
}
if (h.placeKey !== null) {
const item = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === h.placeKey);
assert.ok(item, 'placeKey points at no placeable entry');
assert.equal(item!.spots.length, h.spots);
}
}
});
it('previews a track card in hand even when it has nowhere legal to go', () => {
// REPORTED on a left-hand curve, which showed no preview at all. The shape was read off the
// card's legal PLACEMENTS, so a card with no legal square had nothing to draw — and that is
// exactly when a player most wants to see what the piece is.
const game = newGame(775569289);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const { cardId } = dealTrack(game, 'curved', 'left');
const entry = actionMenu(game).hand.find((h) => h.cardId === cardId);
assert.ok(entry, 'the curve is not in the hand menu');
assert.deepEqual(entry!.shapes, [['se'], ['nw']], 'both orientations must be previewable');
assert.equal(entry!.spots, 0, 'this is the case worth guarding: nowhere legal to lay it');
});
it('describes a curve as a curve, not as a turnout', () => {
// REPORTED: the curve read "east-west track with a 45° leg", which is a turnout — a road across
// the card plus a leg off it. A curve has ONE road and nothing runs past it, which is precisely
// why it may not be laid in the Running Track.
const game = newGame(775569289);
const { cardId } = dealTrack(game, 'curved', 'left');
const what = cardDescription(game.state, cardId);
assert.doesNotMatch(what, /east-west track with/, 'a curve is still described as a turnout');
assert.match(what, /single road/);
assert.match(what, /no track runs past it/);
// And a turnout must still say it HAS a road across it.
const t = dealTrack(game, 'turnout', 'left');
assert.match(cardDescription(game.state, t.cardId), /east-west track with/);
});
it('names the Mainline card a modifier would change, and what it becomes', () => {
// REPORTED: "Realignment on Mainline card 3" — a raw node index, with no clue which stretch of
// the Division it meant or what it would do, and no tooltip either, because the action list only
// attaches one when the label happens to contain an em-dash.
const game = newGame(775569289);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
// Pin a Mainline card Realignment can actually convert (curves -> plains). Terrain is dealt from
// the same RNG stream as the cards, so leaving this to the seed makes the test luck rather than
// a check — it broke the moment the opening deal changed how far that stream had advanced.
const node = game.state.division.nodes.find((n) => n.kind === 'mainline');
assert.ok(node && node.kind === 'mainline', 'no Mainline card to realign');
if (node.kind === 'mainline') { node.card = 'curves'; node.transits = []; }
for (const [id, c] of game.state.cards) {
if (c.kind.kind === 'mainlineModifier' && c.kind.key === 'realignment') {
game.state.decks.hands.set(0, [id]);
break;
}
}
const menu = actionMenu(game);
const labels = menu.direct
.filter((g) => g.title === 'Mainline modifiers')
.flatMap((g) => g.actions)
.map((a) => a.label);
assert.ok(labels.length > 0, 'Realignment was not offered at all');
for (const l of labels) {
assert.doesNotMatch(l, /Mainline card \d/, `still naming a raw node index: ${l}`);
assert.match(l, / — /, `no tooltip half, so the button gets none: ${l}`);
assert.match(l, /converts it to|nothing here to convert/, `does not say what it does: ${l}`);
}
});
it('never strands the player with a legal move and no way to make it', () => {
// REGRESSION, and a hard softlock. Moving train make-up onto the Division Yard chips took the
// "Making up …" group out of the action list, and the PASS option went with it — so a train
// being made up when the yard held nothing it could take had no control on screen at all.
// Reported at Stage 10 of seed 775569289, Train 10 waiting at the West Division Point.
//
// The invariant is the general one: every option the engine offers must be reachable through
// SOMETHING the page renders. Menu-level, so it holds for every phase rather than the handful a
// click-through happens to visit.
const reachable = (menu: ReturnType<typeof actionMenu>): Set<number> => {
const out = new Set<number>();
/**
* THE SAME EXCLUSION `renderActions()` (main.ts) APPLIES, not the raw menu.
*
* `menu.direct` alone is what the ENGINE offers; `renderActions()` then drops any group whose
* title matches the one `menu.makeUp` already covers, so a group counted here but filtered
* there is exactly the gap that stranded a pending Extra with `menu.makeUp` null and its own
* "Making up Extra X22…" title caught by the OLD broader regex this once was. Mirroring the
* real filter is what makes this test the one that would have caught that.
*/
const onScreen = menu.direct.filter(
(g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title,
);
for (const g of onScreen) for (const a of g.actions) out.add(a.index);
for (const g of menu.placeable) for (const it of g.items) for (const sp of it.spots) out.add(sp.index);
for (const h of menu.hand) {
if (h.playNow !== null) out.add(h.playNow);
for (const d of h.discard) if (d !== null && d !== undefined) out.add(d);
}
if (menu.makeUp) {
for (const c of menu.makeUp.cars) out.add(c.index);
if (menu.makeUp.pass !== null) out.add(menu.makeUp.pass);
}
return out;
};
for (const seed of [775569289, 555, 430]) {
const game = newGame(seed);
for (let i = 0; i < 900 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
if (menu.options.length === 0) break;
const got = reachable(menu);
assert.ok(
got.size > 0,
`seed ${seed}: ${menu.options.length} legal options and not one of them is on screen ` +
`(phase ${game.state.clock.phase}, Day ${game.state.clock.day} Stage ${game.state.clock.stage})`,
);
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
}
});
it('offers "where does this Extra start" on screen, not just in the menu', () => {
// REPORTED: the game "hung" mid-Freight-House-work with nothing to click. `newTrain.startExtra`
// was legal and present in `menu.direct` — the engine was never stuck — but its group is titled
// by `trainCardTitle`, which begins "Making up Extra X22…" exactly like the yard-chip panel's
// OWN group. `renderActions()` used to drop every group titled that way, on the assumption only
// the yard-chip panel used the prefix; with no tray yet being filled (`menu.makeUp` is null,
// since the Extra has not chosen where it starts yet), that group was the only thing offered and
// it vanished from the page — a legal decision with zero buttons.
const game = newGame(430);
// Solitaire: seat 0 played it, so seat 0 places it (§7).
game.state.pendingExtras.push({ trainNumber: 22, player: 0 }); // Extra X22 "Pee-Dee" — one caboose, per-diem.
game.state.clock.phase = 'newTrain';
game.state.clock.currentActor = 0;
assert.equal(currentActor(game), 0, 'a decision should be waiting');
const menu = actionMenu(game);
assert.equal(menu.makeUp, null, 'no tray is being filled — the Extra has not started yet');
const extraGroup = menu.direct.find((g) => g.actions.some((a) => menu.options[a.index]?.type === 'newTrain.startExtra'));
assert.ok(extraGroup, 'newTrain.startExtra should appear in some menu.direct group');
assert.match(extraGroup!.title, /^Making up /, 'this is exactly the title shape that used to collide');
// The actual filter `renderActions()` applies (main.ts) — reproduced here for the reason given
// on `reachable()` above: there is no DOM harness in this suite to call the real renderer.
const onScreen = menu.direct.filter(
(g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && g.title !== menu.makeUp?.title,
);
assert.ok(
onScreen.includes(extraGroup!),
'the Extra\'s "choose where it starts" group was filtered out along with the yard-chip panel',
);
});
it('renders every part of the menu it is given', () => {
// THE CHECK THAT WOULD HAVE CAUGHT THE SOFTLOCK ABOVE. The option was in the menu all along —
// `makeUp.pass` was computed correctly and simply never rendered, so the invariant on the menu
// shape passed while the page stranded the player.
//
// Coarse on purpose: it asks only that every field the menu offers is referenced by the page. A
// field nothing reads is either dead or a control that has gone missing, and both are worth a
// failing test.
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
for (const field of ['makeUp.title', 'makeUp.cars', 'makeUp.pass', 'h.playNow', 'h.discard', 'h.shapes']) {
assert.match(src, new RegExp(field.replace('.', '\\.')), `main.ts never reads menu ${field}`);
}
});
it('leaves the action list to what is not a card in hand', () => {
// Measured across a full game of seed 430: the widest action list was 22 buttons, of which up to
// 12 were the discard cross-product and up to 10 were "add loaded hopper"-style make-up buttons.
// Both now live on the objects already on screen — the card, and the Division Yard chip.
const game = newGame(430);
let worst = 0;
for (let i = 0; i < 400 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
const shown = menu.direct
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title))
.reduce((n, g) => n + g.actions.length, 0);
worst = Math.max(worst, shown);
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
// Raised 8 -> 10 -> 13 -> 14. The widest group is Switching every time, which is a crew's
// reachable squares — the list getting longer for a good reason rather than the cross-products
// this test was written to kill. Re-measured over five seeds (430, 99, 1234, 880009, 202) in all
// three opening deals after v0.4.7: 14 under `sixRandom`, 13 under the other two. The deck is
// seven cards smaller and Extras run both ways now, so which seeds reach which districts moved.
assert.ok(worst <= 14, `the action list still reaches ${worst} buttons`);
});
it('makes up ONE train at a time, and names that train', () => {
// REPORTED: the history said Train 9 was being made up at the East Division Point while the
// panel above it read "Making up Train 10 — 18 kinds it may take are highlighted". Two trains
// can be built in the same Stage (a timetabled train plus a Second Section or an Extra), and
// every option from every tray was collected into one panel titled with whichever tray came
// first out of the map. Reproduced at seed 99, Day 3 Stage 12: eighteen chips, two trays.
//
// The panel must belong to exactly one tray — otherwise clicking a car in the yard couples it
// to whichever train owns the first matching option, which may not be the one named.
for (const seed of [99, 430, 270861860]) {
const game = newGame(seed);
for (let i = 0; i < 600 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
if (menu.makeUp) {
const trays = new Set(
menu.makeUp.cars.map((c) => (menu.options[c.index] as { trayId: string }).trayId),
);
if (menu.makeUp.pass !== null) {
trays.add((menu.options[menu.makeUp.pass] as { trayId: string }).trayId);
}
assert.ok(trays.size <= 1, `seed ${seed}: the make-up panel covers ${trays.size} trains at once`);
for (const t of trays) {
assert.equal(t, menu.makeUp.trayId, `seed ${seed}: a car belongs to a train the panel does not name`);
}
const tray = game.state.trays.get(menu.makeUp.trayId);
assert.ok(tray, `seed ${seed}: the panel names a tray that does not exist`);
assert.ok(
menu.makeUp.title.includes(String(tray.trainNumber)),
`seed ${seed}: "${menu.makeUp.title}" does not name Train ${tray.trainNumber}`,
);
}
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
}
});
it('keys each make-up car to the yard chip that shows it', () => {
// Ten buttons reading "add loaded hopper" when the Division Yard is already on screen showing
// exactly those cars by type and load state. The yard is the surface.
const game = newGame(430);
for (let i = 0; i < 400 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
if (menu.makeUp && menu.makeUp.cars.length > 0) {
for (const car of menu.makeUp.cars) {
const intent = menu.options[car.index]!;
assert.equal(intent.type, 'newTrain.placeCar');
assert.equal((intent as { carType: string }).carType, car.carType);
assert.equal((intent as { loaded: boolean }).loaded, car.loaded);
}
assert.match(menu.makeUp.title, /Making up/, 'the make-up panel does not name the train');
return;
}
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
assert.fail('no train was ever made up, so this proves nothing');
});
it('offers ABS Signals the Mainline, not the Office Area', () => {
// REPORTED TWICE. First: the card says "any Mainline card" and every option offered was a
// square in the Office Area, because the candidate list handed grid cells to a check that read
// the column as a Division node index.
//
// Then the fix carried its own bug: a Mainline placement travelled as `{ row: -1, col: node }`,
// and row −1 is an ORDINARY DISTRICT ROW — the first one below the Running Track. So ABS
// Signals highlighted whichever district card sat at that column, and a real enhancement laid
// one row down was described as being out on the Mainline. A Mainline placement now carries a
// node and no coordinate at all, which is what this asserts.
const game = newGame(775569289);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
for (const [id, c] of game.state.cards) {
if (c.kind.kind === 'enhancement' && c.kind.key === 'absSignals') {
game.state.decks.hands.set(0, [id]);
break;
}
}
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((it) => it.spots);
assert.ok(spots.length > 0, 'ABS Signals was not offered anywhere');
for (const sp of spots) {
assert.match(sp.label, /out on the Mainline/, `not a Mainline card: ${sp.label}`);
assert.equal(sp.coord, null, 'a Mainline placement must not claim a square in the district');
const node = game.state.division.nodes[sp.node!];
assert.equal(node?.kind, 'mainline', `spot points at a ${node?.kind}, not a Mainline card`);
}
});
it('draws a train standing on a card, in order, with which way it points', () => {
// REPORTED: with a train in the station there was no way to see which cars it held or in what
// order, so the switching game could not be planned at all — "drop 1 car" says nothing when you
// cannot see what is on the back.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const id = 'crew';
game.state.trays.set(id, {
id, trainNumber: 10, trainIsExtra: false, engineAt: 0,
consist: [
{ type: 'tank', loaded: true },
{ type: 'boxcar', loaded: true },
{ type: 'caboose', loaded: false },
],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
} as never);
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
const train = cell.trains[0];
assert.ok(train, 'the card carries no train');
assert.deepEqual(train!.cars, ['loaded tank', 'loaded boxcar', 'caboose'], 'car order is lost');
assert.equal(train!.engineAt, 0, 'the engine has no place in the train');
assert.equal(train!.facing, 'e', 'which way it points is not carried');
const svg = officeSvg([cell], area.runningRow);
assert.match(svg, /bs-t-eng/, 'the engine is not drawn');
assert.match(svg, /bs-t-ld/, 'a loaded car is not drawn');
assert.match(svg, /cab</, 'the caboose is not named on the board');
});
it('draws the train the way it stands on the map — nose toward the way it faces', () => {
/**
* REPORTED at seed 270861860: Train 10 running EAST arrived at the Whistle Post drawn engine
* first at the WEST end, with the caboose at the east — which reads as an engine shoving its
* whole train ahead of it. "I'd expect it to look just like this, except with the engine and
* caboose reversed so that the engine is the eastmost of the train."
*
* `consist` is ordered nose first, and the renderer drew index 0 leftmost whatever the train was
* doing. A westbound train therefore came out right and an eastbound one came out mirrored.
*/
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const order = (facing: 'e' | 'w'): string[] => {
game.state.trays.set('crew', {
id: 'crew', trainNumber: 10, trainIsExtra: false, engineAt: 0,
consist: [
{ type: 'hopper', loaded: true },
{ type: 'hopper', loaded: false },
{ type: 'caboose', loaded: false },
],
direction: facing === 'e' ? 'east' : 'west', facing,
position: { at: 'grid', seat: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
} as never);
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
const svg = officeSvg([cell], area.runningRow);
// The three-letter labels in the order they are drawn, west to east.
return [...svg.matchAll(/class="bs-tcarlab"[^>]*>([^<]+)</g)].map((m) => m[1]!);
};
// Nose first is engine, hopper, hopper, caboose. Running west the engine leads at the west end;
// running east the same train is drawn the other way round.
assert.deepEqual(order('w'), ['◀', 'hop', 'hop', 'cab'], 'a westbound train is drawn backwards');
assert.deepEqual(order('e'), ['cab', 'hop', 'hop', '▶'], 'an eastbound train is not turned round');
});
it('shows A/D tracks on the Office card, one chip per track', () => {
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that
// decides whether the next arrival is an automatic collision (§8.3). The Roster Pass replaced
// the pips with one roster chip per A/D track (docs/plans/switching-paths.md), free or occupied.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const cell = view(game).cells.find((c) => c.kind === 'office')!;
assert.equal(cell.adTracks, 1, 'a Whistle Post has one A/D track');
assert.equal(cell.trains.length, 0, 'no train is standing yet');
const svg = officeSvg([cell], area.runningRow);
assert.match(svg, /bs-adchip/, 'no roster chip is drawn');
assert.match(svg, />free</, 'the one A/D track should read free with nothing standing on it');
});
it('draws every train standing at a busy Office, not just one', () => {
// REPORTED (docs/plans/switching-paths.md — "The Roster Pass"): the old single `train` field on
// a cell showed only whichever tray `s.trays` happened to yield first, so a second train holding
// at a Station was counted in the old A/D pips and never drawn at all.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
area.tier = 'terminal'; // 4 A/D tracks — room for every train this test parks
const at = area.officeCoord;
const trainNumbers = [10, 12, 14, 16];
for (const n of trainNumbers) {
const id = `t${n}`;
game.state.trays.set(id, {
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'boxcar', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
} as never);
area.adOccupancy.push(id);
}
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
assert.equal(cell.trains.length, 4, 'all four trains should be on the Office card');
assert.equal(cell.adTracks, 4, 'a Terminal has four A/D tracks');
const svg = officeSvg([cell], area.runningRow);
const chipCount = [...svg.matchAll(/class="bs-adchip/g)].length;
assert.equal(chipCount, 4, `expected 4 roster chips at a full Terminal, got ${chipCount}`);
assert.doesNotMatch(svg, />free</, 'every track is taken, so nothing should read free');
for (const n of trainNumbers) {
assert.match(svg, new RegExp(`data-crew="t${n}"`), `T${n} has no roster chip to click`);
}
});
it('draws the crew chip on a train standing away from the Office', () => {
// REPORTED: dropping a loaded boxcar off the back of Train 3 at the Freight House made "the
// train vanish off the face of the earth". The game state was never wrong — `selectedTrain` was
// only ever assigned inside the `cell.adTracks !== null` branch, so a train standing on any card
// OTHER than the Office (which is every card it stands on while actually being switched) got no
// badge at all: no label, no engine arrow, no cars. This is the Freight House from that report —
// an ordinary facility card, `adTracks: null`, one train parked on it mid-switch.
const cell = {
row: 1, col: 0, kind: 'facility', label: 'Freight House', running: false,
enhancements: [], enhancementsWhat: [], adTracks: null,
trains: [{
trayId: 'tray3', label: 'T3', cars: ['empty boxcar'], engineAt: 0, facing: 'w' as const,
what: 'Train 3 "Express"',
}],
cars: [], standingWest: 0, facility: null, links: ['ew'], what: '',
};
const svg = officeSvg([cell as never], 0);
assert.match(svg, /class="bs-crew"/, 'the crew badge is not drawn off the Office square');
assert.match(svg, />T3</, 'the train\'s own label is missing from the board');
});
it('draws a bare cut left-aligned, whatever the card\'s stored standingWest says', () => {
// "Settled questions" in docs/plans/switching-paths.md — the split only ever predicts something
// while a train stands on the card, and an earlier draft of the plan had this backwards. A stale
// standingWest surviving on an emptied card must not draw a gap nobody would recouple.
const cell = {
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
trains: [], adTracks: null,
cars: ['boxcar', 'loaded hopper', 'reefer'],
standingWest: 2, // stale — as if a train had once stood here and left
facility: null, links: ['ew'], what: '',
};
const svg = officeSvg([cell as never], 0);
const xs = [...svg.matchAll(/class="bs-slot[^"]*" x="([-\d.]+)"/g)].map((m) => Number(m[1]));
assert.equal(xs.length, 3, 'all three cars should be drawn');
const sorted = [...xs].sort((a, b) => a - b);
// Left-aligned means each slot sits exactly 30px past the one before it — no gap anywhere in
// the row for an engine that is not there.
for (let i = 1; i < sorted.length; i++) {
assert.equal(sorted[i]! - sorted[i - 1]!, 30, `a gap survived at index ${i} with no train present`);
}
});
it('reads one split off the shared card, not one per train', () => {
// The whole justification for moving `standingWest` onto the card rather than the tray
// (docs/plans/switching-paths.md): two trays holding at one Office must not be able to disagree
// about the same row of cars, because there is only one number for them to read.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
area.tier = 'station'; // 3 A/D tracks
const at = area.officeCoord;
const card = area.grid.get(`${at.row},${at.col}`)!;
card.standing = [{ type: 'boxcar', loaded: false }, { type: 'hopper', loaded: false }];
card.standingWest = 1;
for (const n of [10, 12]) {
const id = `t${n}`;
game.state.trays.set(id, {
id, trainNumber: n, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
} as never);
}
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
assert.equal(cell.trains.length, 2, 'both trains should be on the Office card');
assert.equal(cell.standingWest, 1, "the card's split is one number, not one per train");
});
it('names the cars a drop would set out, and which end they come off', () => {
// REPORTED: "drop 1 car(s)" — which car? And because a nose drop and a tail drop read
// identically, the action list's duplicate-label filter discarded one of them outright, so
// setting out from the front of the train could not be chosen at all.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const id = 'crew';
game.state.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 1,
consist: [{ type: 'boxcar', loaded: true }, { type: 'caboose', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: { row: area.runningRow, col: 1 } }, movesUsed: 0,
} as never);
const front = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true });
const back = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1 });
assert.match(front, /loaded boxcar/, `the front cut is not named: ${front}`);
assert.match(front, /off the front/);
assert.match(back, /caboose/, `the back cut is not named: ${back}`);
assert.match(back, /off the back/);
assert.notEqual(front, back, 'the two ends still read identically, so one is dropped from the list');
});
it('shows where a played train card landed on the timetable', () => {
// REPORTED: playing a train card rolls 1D12 for its departure Stage and the card simply left the
// hand — the answer arrived only as one line in the history panel. The twelve slots have been in
// the Frame all along and only the standalone replay ever drew them.
// Playing a train card is the mechanism under test, so it is played directly rather than hoped
// for over 900 arbitrary choices — which is what this did until the opening deal changed which
// cards a seed puts in reach.
const game = newGame(430);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const train = [...game.state.cards.entries()].find(
([, c]) => c.kind.kind === 'timetabledTrain' || c.kind.kind === 'extraTrain',
);
assert.ok(train, 'no train card in the deck');
game.state.decks.hands.set(0, [train![0]]);
assert.ok(submit(game, { type: 'card.play', cardId: train![0] }), 'the train card was refused');
const landed = game.scheduled;
assert.ok(landed !== null, 'no train was ever scheduled');
const f = view(game);
assert.ok(f.timetable[landed!] !== null, 'the slot the roll reported is empty');
// The roll gets a sound, because it is the one die a player rolls and it landed silently.
assert.ok(game.cues.includes('schedule'), 'scheduling a train made no sound');
// And the panel flashes the slot that changed, marking the moment rather than the state.
const html = timetableHtml(f, landed);
assert.match(html, /tt-slot[^"]*fresh/, 'the new slot is not flagged');
assert.equal((timetableHtml(f, null).match(/fresh/g) ?? []).length, 0, 'the flash must not persist');
assert.match(html, new RegExp(`T${f.timetable[landed!]}`), 'the train is not named in its slot');
});
it('draws the side panels from one place, on both screens', () => {
// A replay is the game being WATCHED rather than played, so it should look like the game. The
// site's viewer had three panels against the play page's eight — no facilities, no blockers, no
// cards, no yards — which meant it could not answer "why is nothing moving?", the question a
// replay mostly exists to answer.
for (const f of ['src/web/main.ts', 'src/web/replays.ts']) {
const src = readFileSync(join(root, f), 'utf8');
for (const fn of ['pilesHtml', 'yardHtml', 'blockedHtml', 'facilitiesHtml']) {
assert.match(src, new RegExp(fn), `${f} does not use the shared ${fn}`);
}
assert.match(src, /PANEL_CSS/, `${f} does not ship the shared panel styling`);
}
// The HAND is the one panel that differs, and deliberately: on the play page every card carries
// its own play and discard verbs, while a replay's hand is a read-only row of what was held.
assert.match(readFileSync(join(root, 'src/web/replays.ts'), 'utf8'), /handHtml/);
assert.match(readFileSync(join(root, 'src/web/main.ts'), 'utf8'), /button class="cardact/);
// And the viewer's page must actually have somewhere to put them.
const html = readFileSync(join(dist, 'replays.html'), 'utf8');
for (const id of ['vhand', 'vdepts', 'vdivyard', 'vclsyard', 'vblocked', 'vfacs']) {
assert.match(html, new RegExp(`id="${id}"`), `the replay viewer has no #${id} panel`);
}
});
it('lets a replay change pace while it is playing', () => {
// The interval is created with whatever the speed select held when play started. Nothing re-read
// it in the site's viewer, so choosing "extra slow" mid-replay did nothing at all and the pace
// looked stuck — the standalone replay had always restarted its timer on change.
for (const [file, id] of [['src/web/replays.ts', 'vspeed'], ['src/sim/replay.ts', 'speed']]) {
const src = readFileSync(join(root, file!), 'utf8');
assert.match(
src,
new RegExp(`\\$\\('${id}'\\)[^;]*\\.onchange`),
`${file} never reacts to a speed change`,
);
}
});
it('names who the game is waiting on when it stops to ask them something', () => {
/**
* REPORTED BY JESSE 2026-08-30: "waiting on shows 'nobody — the Division is running itself' BUT
* the system is actually waiting on the Superintendent."
*
* `Frame.actor` carried `clock.currentActor`, which is null for the whole Mainline Phase — so a
* game stopped dead on a §8.1 clearance ruling said nobody was holding it up, while it waited on
* a named person to click. `actingPlayer` had the answer the whole time; the Frame threw it
* away. Naming them is only half of it: three different interruptions can be pending, and
* "waiting on Bob" alone is a game that looks stuck to everyone except Bob.
*/
const base = { day: 2, stage: 5, clock: '2:20', phase: 'Mainline', phaseKey: 'mainline' };
/**
* AND AN AUTOMATIC PHASE NAMES THE WORK, NOT AN ABSENCE (playtest, 2026-09-16). It used to read
* "waiting on nobody — the Division is running itself": an answer by negation, on a line whose
* whole job is to say where the game is. `base` is the Mainline Phase, so this is the case Jesse
* described — the Division moving trains with nobody to wait for.
*/
const idle = turnChartHtml({ ...base, actor: null, awaiting: null }, null, 'Bob');
assert.match(idle, /the Division is moving trains/, 'an automatic phase should say what it is doing');
assert.doesNotMatch(idle, /waiting on/, 'nobody is being waited on, so the line must not claim it');
assert.doesNotMatch(idle, /nobody/, 'the line still answers by negation');
for (const [asks, train] of [
['a clearance ruling', 'Train 4'],
['the Yard Office offer', 'Train 7'],
['a Red Flag', 'Train X18'],
] as [string, string][]) {
const html = turnChartHtml({ ...base, actor: 1, awaiting: { asks, train } }, 'Bob', 'Bob');
assert.doesNotMatch(html, /running itself/, `"${asks}" still reported nobody`);
assert.match(html, /waiting on <b>Bob<\/b>/, `"${asks}" did not name who it waits on`);
assert.match(html, new RegExp(asks.replace(/ /g, ' ')), `"${asks}" did not say what is being asked`);
assert.match(html, new RegExp(train), `"${asks}" did not name the train it is about`);
}
});
it('puts the Fedora at the right-hand end of the phase row (TODO #29)', () => {
// It sat on a line of its own between the phases and everything above them, which put a thing
// that moves every third Stage in among the things that move every Stage. The row it belongs
// beside is the one whose last chip is Supervisor Shift — the phase that passes it.
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
const html = turnChartHtml(frame, 'Bob', 'Bob');
const row = /<div class="tc-row">([\s\S]*?)<\/div>\s*$/.exec(html)?.[1] ?? '';
assert.match(row, /<ol class="tc-phases">/, 'the phase row is not in the row wrapper');
assert.ok(row.indexOf('tc-super') > row.indexOf('tc-phases'), 'the Fedora is not after the phases');
assert.match(turnChartHtml(frame, 'Bob', null), /tc-row/, 'solitaire lost the row wrapper with the Fedora');
});
it('names the Superintendent at a table, and stays quiet about it in solitaire', () => {
/**
* REPORTED BY JESSE 2026-08-23, playing two-player on StartOS: seat 1 played a train card and
* seat 2 was asked to build the train. The engine was right — §7 makes a consist up starting
* with the Superintendent and working EASTWARD — but nothing on the board said who the
* Superintendent WAS, so the question could not be answered from the screen. The Frame has
* carried `superintendent` since v0.4.0 and only the standalone replay ever drew it.
*
* The rule text said "working left" until 2026-09-16. It is the same rule — `playerLeftOf` is
* increasing seat index — but "left" describes a table nobody is looking at, while the map on
* screen runs west to east, so at a real three-player game it read as plainly wrong: the second
* car went to the player sitting to the EAST. The word changed; the order did not.
*/
const frame = { day: 1, stage: 4, clock: '2:00', phase: 'New Train', phaseKey: 'newTrain', actor: 1 };
const table = turnChartHtml(frame, 'Bob', 'Bob');
assert.match(table, /Superintendent/, 'the Fedora is not named at a table');
assert.match(table, /class="tc-super"/, 'the Fedora has no chip of its own');
const solo = turnChartHtml(frame, 'Solitaire', null);
assert.doesNotMatch(solo, /class="tc-super"/, 'solitaire was told who the Superintendent is');
// And the phase that raised the question says who builds, where a player will be looking.
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
assert.match(
src,
/starting with the Superintendent and working eastward/,
'the New Train pill does not say whose turn the make-up round starts on',
);
});
it('offers the same five paces in both replay viewers', () => {
const standalone = readFileSync(join(root, 'src/sim/replay.ts'), 'utf8');
const viewer = readFileSync(join(root, 'src/web/replays.html'), 'utf8');
const paces = (src: string): string[] =>
[...src.matchAll(/<option value="\d+"[^>]*>([^<]+)<\/option>/g)].map((m) => m[1]!);
assert.deepEqual(paces(standalone), ['extra slow', 'slow', 'normal', 'fast', 'very fast']);
assert.deepEqual(paces(viewer), paces(standalone), 'the two replay viewers offer different paces');
});
it('gives every phase the engine can be in a pill on the turn chart', () => {
// The turn chart exists to say where in the Stage you are, so a phase with no pill is a hole
// exactly when the player most needs it. `Phase` is a closed union in the engine; this fails if
// one is added and the chart is not.
const engine = readFileSync(join(root, 'src/engine/state.ts'), 'utf8');
const decl = /export type Phase =([^;]+);/.exec(engine);
assert.ok(decl, 'the Phase union moved — this test cannot see it any more');
const phases = [...decl![1]!.matchAll(/'([a-zA-Z]+)'/g)].map((m) => m[1]!);
assert.ok(phases.length >= 5, `only found ${phases.length} phases`);
const src = readFileSync(join(root, 'src/sim/turnchart.ts'), 'utf8');
for (const p of phases) {
assert.match(src, new RegExp(`key: '${p}'`), `no turn-chart pill for the ${p} phase`);
}
// And every pill must carry a tooltip: an icon alone does not explain a phase. Comment lines are
// allowed to sit between the fields — a tip that needs explaining (the New Train round's order
// is one) should be able to carry that explanation next to itself.
const tips = [
...src.matchAll(
/key: '[a-zA-Z]+',\s*\n(?:\s*\/\/[^\n]*\n)*\s*label: '[^']+',\s*\n(?:\s*\/\/[^\n]*\n)*\s*tip: ["']/g,
),
];
assert.equal(tips.length, phases.length, 'a turn-chart pill has no tooltip');
const html = readFileSync(join(dist, 'play.html'), 'utf8');
assert.match(html, /id="turnchart"/, 'the page has no turn chart');
assert.doesNotMatch(html, /phase: <b id="phase">/, 'the old title-bar phase text is still there');
});
it('serves all three pages, each stamped and cache-busted', () => {
// The splash is the front door now; the game and the replay directory are separate pages.
for (const [name, entry] of [
['index.html', 'splash'],
['play.html', 'main'],
['replays.html', 'replays'],
] as const) {
const html = readFileSync(join(dist, name), 'utf8');
assert.match(html, new RegExp(`src="\\./web/${entry}\\.js\\?v=`), `${name} is not cache-busted`);
assert.doesNotMatch(html, /__BUILD__/, `${name} was published without a build stamp`);
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), `${name} fetches something external`);
}
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
// `?solitaire` marks the door's intent explicitly (2026-08-29) so a browser that remembers a
// multiplayer session cannot swallow it — see `start()`'s own comment on `wantsSolitaire`.
assert.match(splash, /href="\.\/play\.html\?solitaire"/, 'the splash does not link to the game');
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
});
it('cache-busts with a tag that varies per build even where there is no git', () => {
/**
* THE BUG THIS PINS COST TWO RELEASES. `buildStamp`'s no-git fallback was the literal `nogit`,
* and the `.s9pk` Dockerfile copies the working tree in WITHOUT `.git` — so every packaged
* release published `?v=nogit`, byte-identical to the one before it, and a returning player's
* browser refetched nothing. v0.7.5's setup screen and v0.7.6's fix to it both installed
* correctly on `phoenix.local` and neither reached the browser that asked for them.
*
* Asserted against the SCRIPT rather than a built page, because the property is about what the
* fallback does when `git rev-parse` fails, which a normal build here never exercises.
*/
const src = readFileSync(join(root, 'scripts/build-web.ts'), 'utf8');
const fallback = /let git = ([^;]+);/.exec(src)?.[1] ?? '';
assert.ok(fallback !== '', 'the no-git fallback moved and this test cannot see it any more');
assert.doesNotMatch(fallback, /^'nogit'$|^"nogit"$/, 'the no-git fallback is a constant again');
assert.match(fallback, /Date\.now\(\)/, 'the no-git fallback carries nothing that varies per build');
/**
* AND IT MUST NOT LEAD WITH THE VERSION — which is what fixing the constant first reached for.
*
* The stamp is `v${pkg.version} · ${git} · ${when}Z`, so a fallback of `${pkg.version}-${…}`
* spends the version twice, and does it on precisely the builds that take this path: every
* `.s9pk`, because the Dockerfile copies the tree in without `.git`. Reported from play —
* Jesse, 2026-09-16: *"the version number is in the header twice."* The uniqueness this test
* exists to defend comes from the timestamp, not from the version, so the two requirements do
* not compete.
*/
assert.doesNotMatch(
fallback,
/pkg\.version/,
'the no-git fallback repeats the version the stamp already prints in front of it',
);
});
it('lets a build-tagged URL be cached and nothing else', () => {
// The other half of the same bug: the pages carry the `?v=` tags but cannot be versioned in
// their own URL, so a cached `play.html` pins a player to the whole build it names. Only a
// request that actually carries `?v=` may be stored — an untagged image or the replay manifest
// has no way to announce a change.
const src = readFileSync(join(root, 'src/server/http.ts'), 'utf8');
assert.match(src, /'Cache-Control':\s*buildTagged\s*\?/, 'static responses no longer vary their caching');
assert.match(src, /immutable/, 'a tagged asset is not allowed to be cached at all');
assert.match(src, /serveStatic\(opts\.distDir, url\.pathname, res, url\.searchParams\.has\('v'\)\)/,
'the ?v= tag is not reaching serveStatic, so every response falls back to no-cache');
});
it('opens the multiplayer door from the splash, straight into the lobby', () => {
// This door sat `disabled` and labelled "Coming soon" from before the server existed until
// v0.5.2 — Phases 2-4 built a working lobby and nothing ever linked to it, so a player with a
// real server in front of them saw the same dead card as everyone else.
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
assert.match(splash, /href="\.\/play\.html\?lobby"/, 'the splash does not link to the lobby');
assert.doesNotMatch(splash, /Coming soon/, 'the multiplayer door still says it is unbuilt');
// The probe addresses all three by id; renaming one silently un-wires it, which is precisely
// the class of break that put "Coming soon" on a working feature for two releases.
for (const id of ['door-multiplayer', 'door-multiplayer-go', 'door-multiplayer-blurb']) {
assert.ok(splash.includes(`id="${id}"`), `the splash is missing #${id}`);
}
});
/**
* The probe decides whether the door above is real, so both of its answers are worth a test —
* and the OPEN one especially: a false "no server here" is invisible to the player and is the
* exact failure this release exists to remove.
*/
it('closes the multiplayer door only when nothing answers the health probe', async () => {
const served = new Set(
[...readFileSync(join(dist, 'index.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
);
const loadSplash = async (fetchImpl: () => Promise<unknown>): Promise<Record<string, unknown>> => {
const els = new Map<string, Record<string, unknown>>();
const make = (): Record<string, unknown> => {
const classes = new Set<string>();
return {
textContent: '', innerHTML: '', hidden: false, href: './play.html?lobby',
classList: { add: (c: string) => void classes.add(c), remove: (c: string) => void classes.delete(c), has: (c: string) => classes.has(c) },
removeAttribute: (k: string) => { if (k === 'href') delete (els.get('door-multiplayer') ?? {})['href']; },
addEventListener: () => {}, appendChild: () => {},
};
};
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make());
return els.get(id);
},
createElement: () => make(), addEventListener: () => {},
querySelectorAll: () => [],
body: { appendChild: () => {} }, head: { appendChild: () => {} },
};
g['fetch'] = fetchImpl;
await import(`file://${join(dist, 'web/splash.js')}?t=${Date.now()}${Math.random()}`);
// The probe settles a microtask or two after load; nothing in the page waits on it.
await new Promise((r) => setTimeout(r, 0));
return els.get('door-multiplayer')!;
};
const answered = await loadSplash(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ ok: true, service: 'station-master' }) }),
);
assert.equal(
(answered['classList'] as { has: (c: string) => boolean }).has('disabled'), false,
'the door closed even though a Station Master server answered',
);
const silent = await loadSplash(() => Promise.reject(new Error('nothing there')));
assert.equal(
(silent['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
'the door stayed open with no server behind it',
);
assert.equal(silent['href'], undefined, 'the closed door is still clickable');
// A host that answers EVERY path 200 — with its own index page, or with some unrelated
// service's JSON — sails straight past an `ok` check, so the body has to name itself. Both
// shapes below reach the door by a DIFFERENT route through the probe than the rejection
// above does, which is the whole reason they are here: an earlier version of this test
// asserted only the rejection path and passed with the naming check deleted outright.
const unnamed = await loadSplash(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({ service: 'something-else', ok: true }) }),
);
assert.equal(
(unnamed['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
'a 200 from some other service was taken for a Station Master server',
);
const unparseable = await loadSplash(() =>
Promise.resolve({ ok: true, json: () => Promise.reject(new Error('not json')) }),
);
assert.equal(
(unparseable['classList'] as { has: (c: string) => boolean }).has('disabled'), true,
'a 200 that is not JSON at all was taken for a server',
);
});
/**
* `?lobby` is what the door above hands to `main.ts`. Asserted on the BUILT bundle rather than
* the source, because the one thing that broke here was a browser-vs-Node API difference
* (`params.has` against the stub below), which only a load of the real output catches.
*/
it('routes ?lobby straight to the lobby screen instead of dealing a solitaire game', async () => {
const shown: string[] = [];
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
);
const els = new Map<string, Record<string, unknown>>();
const make = (id: string): Record<string, unknown> => ({
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false, innerHTML: '',
title: '', hidden: false, classList: { add: () => {}, remove: () => {}, has: () => false, toggle: () => {} },
appendChild: () => {}, addEventListener: () => {}, removeAttribute: () => {},
querySelector: () => null, querySelectorAll: () => [],
setAttribute: (k: string, v: string) => { if (k === 'hidden') shown.push(`${id}=${v}`); },
});
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make(id));
return els.get(id);
},
createElement: () => make('?'), addEventListener: () => {},
querySelectorAll: () => [],
body: { appendChild: () => {} }, head: { appendChild: () => {} },
};
g['location'] = { search: '?lobby' };
const store = new Map<string, string>();
g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
g['URLSearchParams'] = class {
search: string;
constructor(search: string) { this.search = search; }
get(k: string): string | null {
// A bare flag with no `=value` is still present — `?lobby` is exactly that shape.
if (new RegExp(`[?&]${k}(?=$|[&=])`).test(this.search)) {
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? '';
}
return null;
}
};
// The lobby screen opens an EventSource as soon as it is shown; nothing here drives a game.
g['EventSource'] = class { close(): void {} addEventListener(): void {} };
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
assert.equal(els.get('lobby')!['hidden'], false, 'the lobby screen stayed hidden under ?lobby');
assert.equal(els.get('gameui')!['hidden'], true, 'it dealt a solitaire game instead of opening the lobby');
});
it('serves a page that loads the game as a module', () => {
const html = readFileSync(join(dist, 'play.html'), 'utf8');
assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?v=[^"]*)?">/);
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), 'page fetches something external');
for (const id of ['grid', 'division', 'actions', 'log', 'facs', 'hand', 'blocked']) {
assert.ok(html.includes(`id="${id}"`), `page is missing #${id}`);
}
});
});
describe('the Division map shows the whole route', () => {
const divisionFor = (players: number): string => {
const s = createEngineGame({
id: `div-${players}`,
seed: 7,
config: {
mode: players === 1 ? 'solitaire' : 'competitive',
days: 5,
minCombinedRevenue: 0,
maxCollisionsPerDay: 0,
maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
});
return divisionSvg(snapshot(s, [], null).division);
};
it('draws a signal on a Mainline card carrying ABS Signals, not only a tooltip', () => {
/**
* REPORTED FROM A TABLE, Day 1 Stage 1 of v0.8.0.16: "when played on the trestle, there was no
* on-the-card indication. It's only when you look at the tooltip for trestle that you see that
* ABS exists." The same complaint the Heavy Grade wedge below answers, and it matters more
* here — ABS is what decides whether running a second train onto that card is safe.
*/
const s = createEngineGame({
id: 'div-abs',
seed: 7,
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['A', 'B'],
});
const before = divisionSvg(snapshot(s, [], null).division);
assert.ok(!before.includes('bs-abs'), 'a signal is drawn before ABS Signals was ever played');
const node = s.division.nodes.find((n) => n.kind === 'mainline');
assert.ok(node, 'this division has no Mainline card');
(node as { absSignals?: boolean }).absSignals = true;
const after = divisionSvg(snapshot(s, [], null).division);
assert.ok(after.includes('bs-abs-mast'), 'the card carrying ABS Signals draws no signal mast');
assert.ok(after.includes('bs-abs-lit'), 'the signal has no lit aspect');
// Exactly one card carries it, so the mark cannot be a row-wide decoration.
assert.equal((after.match(/bs-abs-mast/g) ?? []).length, 1, 'the signal is drawn on more than one card');
// And it stays in the tooltip too — the mark says THAT, the tip still says what it does.
assert.ok(after.includes('ABS Signals'), 'the tooltip stopped naming ABS Signals');
});
it('draws the Mainline modifiers on the card, not only in the tooltip', () => {
/**
* The other half of the ABS report (Jesse, 2026-09-21): "Brakeman, Airbrakes, Helpers and
* Realignment should also be drawn on the card, not just the tooltip."
*
* REALIGNMENT IS NOT IN THIS LIST ON PURPOSE. It never sits on a card — `reduce` takes the
* `became` branch and changes `node.card` outright — so a realigned card already announces
* itself by being a different card. Asserted below so the absence is a recorded finding rather
* than something that looks forgotten.
*/
const s = createEngineGame({
id: 'div-mods',
seed: 7,
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['A', 'B', 'C', 'D'],
});
const node = s.division.nodes.find((n) => n.kind === 'mainline');
assert.ok(node, 'this division has no Mainline card');
assert.ok(!divisionSvg(snapshot(s, [], null).division).includes('bs-mod'), 'a tag is drawn with no modifier on');
(node as { modifiers?: string[] }).modifiers = ['brakeman', 'airbrakes', 'helpers'];
const svg = divisionSvg(snapshot(s, [], null).division);
for (const tag of ['BRK', 'AIR', 'HLP']) {
assert.ok(svg.includes(tag), `the ${tag} modifier is not drawn on the card`);
}
// And the tooltip still names them in full — the tag says THAT, the tip says WHICH.
assert.match(svg, /Brakeman/, 'the tooltip stopped naming the modifiers');
// Realignment converts the card instead of sitting on it, so it must never produce a tag.
(node as { modifiers?: string[] }).modifiers = ['realignment'];
assert.ok(
!divisionSvg(snapshot(s, [], null).division).includes('bs-mod'),
'Realignment drew a tag, but it changes the card rather than standing on it',
);
});
it('draws which way a Heavy Grade climbs, instead of only saying it in the tooltip', () => {
/**
* REPORTED BY JESSE 2026-08-30: "heavy grade mainline card tooltip states climbs east, but card
* doesn't show it." The Frame has carried `gradeUp` since the Division map was rebuilt, and the
* tip has read "climbs east" all along — but the one Mainline card whose orientation the PLAYER
* sets, and the one where Helpers and Brakeman mean opposite things at opposite ends, drew
* nothing to distinguish the two.
*
* Asserted on the geometry rather than on a screenshot: what can actually go wrong here is the
* wedge pointing the wrong way or hanging off the card, and both are numbers.
*/
const card = (gradeUp: string | null): DivisionView =>
({
kind: 'ml', label: 'Heavy Grade', trains: [], capacity: 1,
modifiers: [], gradeUp, regions: 3, what: 'three regions',
} as unknown as DivisionView);
const peakOf = (svg: string): [number, number][] | null => {
const m = /<polygon class="bs-grade" points="([^"]+)"/.exec(svg);
return m ? (m[1]!.split(' ').map((p) => p.split(',').map(Number) as [number, number])) : null;
};
// EAST IS RIGHT on this map (Gitea#18), which is the whole reason a wedge can be read without a
// compass — so the apex belongs at the greater x when the grade climbs east, and the lesser when
// it climbs west.
const east = peakOf(divisionSvg([card('east')]));
assert.ok(east, 'a Heavy Grade climbing east draws no wedge at all');
const [ea, eb, epeak] = east;
assert.equal(epeak![0], Math.max(ea![0], eb![0]), 'the wedge climbs the wrong way for east');
const west = peakOf(divisionSvg([card('west')]));
assert.ok(west, 'a Heavy Grade climbing west draws no wedge at all');
const [wa, wb, wpeak] = west;
assert.equal(wpeak![0], Math.min(wa![0], wb![0]), 'the wedge climbs the wrong way for west');
// Every other Mainline card is flat and must stay unmarked, or the wedge stops meaning anything.
assert.equal(peakOf(divisionSvg([card(null)])), null, 'a card with no grade drew one anyway');
/**
* THE ARROW LIVES INSIDE THE WEDGE, with room to spare.
*
* The first pass ran it up the hypotenuse, which starts at the wedge's thin corner where there
* is no height to draw in, so the head sat over the edge and read as clipped — reported exactly
* that way. A bounding-box check would have passed it: every point was on the card. What matters
* is containment in the TRIANGLE, and a margin big enough to see, so both are asserted.
*/
for (const dir of ['east', 'west'] as const) {
const svg = divisionSvg([card(dir)]);
const wedge = peakOf(svg)!;
const [w1, w2, apex] = wedge;
const yBase = w1![1];
const xMin = Math.min(w1![0], w2![0]);
const xMax = Math.max(w1![0], w2![0]);
const arrow = /<polygon class="bs-gradeup" points="([^"]+)"/.exec(svg)?.[1];
assert.ok(arrow, `the ${dir} grade draws no arrow`);
const av = arrow.trim().split(/\s+/).map((p) => p.split(',').map(Number) as [number, number]);
for (const [ax, ay] of av) {
// 0 at the thin corner, 1 at the apex — the wedge's height scales with it.
const t = apex![0] === xMax ? (ax - xMin) / (xMax - xMin) : (xMax - ax) / (xMax - xMin);
const ceiling = yBase - (yBase - apex![1]) * t;
assert.ok(ax >= xMin && ax <= xMax, `the ${dir} arrow leaves the wedge sideways at ${ax}`);
assert.ok(
yBase - ay >= 2 && ay - ceiling >= 2,
`the ${dir} arrow comes within 2px of the wedge edge at ${ax},${ay} — it will read as clipped`,
);
}
/**
* IT LIES ALONG THE WEDGE'S OWN SLOPE, AND IS CENTRED IN THE FORM.
*
* Derived from the wedge rather than hardcoded, so resizing the wedge cannot leave the arrow
* at a stale angle — the assertion is "parallel", which is the property that makes the gap to
* the hypotenuse constant along the arrow instead of closing at one end.
*
* Measured on the AXIS: the two tail vertices straddle it by the shaft's half-thickness, so
* measuring from a corner under-reads by a few degrees. That mistake cost a wrong reading once
* already.
*/
const tail: [number, number] = [(av[0]![0] + av[6]![0]) / 2, (av[0]![1] + av[6]![1]) / 2];
const tip = av[3]!;
const dx = tip[0] - tail[0];
const climb = (Math.atan2(-(tip[1] - tail[1]), Math.abs(dx)) * 180) / Math.PI;
const slope = (Math.atan2(yBase - apex![1], xMax - xMin) * 180) / Math.PI;
assert.ok(
Math.abs(climb - slope) < 0.5,
`the ${dir} arrow climbs at ${climb.toFixed(1)}° but the wedge rises at ${slope.toFixed(1)}°`,
);
assert.equal(dx > 0, dir === 'east', `the ${dir} arrow points away from the summit`);
// Centred on the TRIANGLE's centroid — 2/3 along the base toward the apex, 1/3 up — not on
// the bounding box, which would put it in the thin corner where there is no height for it.
const midX = (tail[0] + tip[0]) / 2;
const midY = (tail[1] + tip[1]) / 2;
const cX = apex![0] === xMax ? xMin + ((xMax - xMin) * 2) / 3 : xMax - ((xMax - xMin) * 2) / 3;
const cY = yBase - (yBase - apex![1]) / 3;
assert.ok(Math.abs(midX - cX) < 1, `the ${dir} arrow is off the centroid horizontally`);
assert.ok(Math.abs(midY - cY) < 1, `the ${dir} arrow is off the centroid vertically`);
}
});
it('expands each Office into its Running Track, Limits to Limits', () => {
// The map used to collapse a whole district into one "Office" box, so the track a train
// actually runs along was invisible on the only view that shows where trains are.
const s = createEngineGame({
id: 'div-run',
seed: 7,
config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
const f = snapshot(s, [], null);
const office = f.division.find((n) => n.kind === 'office');
assert.ok(office, 'no Office node in the Division');
assert.ok(office!.running && office!.running.length >= 3, 'the Office carries no Running Track');
// The through route runs BETWEEN the Limits, so both ends must be Limits cards (§2.1).
assert.equal(office!.running![0]!.kind, 'limits', 'the district does not start at a Limits sign');
assert.equal(office!.running![office!.running!.length - 1]!.kind, 'limits', 'the district does not end at a Limits sign');
// And the columns must run west to east without a hole.
const cols = office!.running!.map((c) => c.col);
for (let i = 1; i < cols.length; i++) {
assert.ok(cols[i]! > cols[i - 1]!, 'the Running Track is not ordered west to east');
}
});
it('draws 1 to 4 players as ONE row, west to east, without overlapping or spilling', () => {
/**
* Gitea#18. This used to check "a row, two facing rows, a horseshoe and a square" — the route
* was laid out around a table, on the reasoning that players sit around one. It cost three
* reports, and the one that decided it was that **east stopped being to the right**: a player's
* east could be drawn south, west or north depending which lane their district landed in, on a
* map whose whole job is saying which way a train is going.
*
* So the property is now stronger and much simpler to state — every cell on one row, ordered
* west to east — which is exactly what makes "east is right" true and is the thing that would
* silently regress if anyone reintroduced lanes. The overlap and canvas checks are kept: the
* layout is geometry with no visual feedback loop.
*/
for (const players of [1, 2, 3, 4]) {
const svg = divisionFor(players);
const vb = /viewBox="0 0 (\d+) (\d+)"/.exec(svg);
assert.ok(vb, `${players}p produced no viewBox`);
const W = Number(vb![1]);
const H = Number(vb![2]);
const rects = [...svg.matchAll(/class="bs-dcell bs-d(\w+)[^"]*"[^>]*><rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)]
.map((m) => ({ kind: m[1]!, x: +m[2]!, y: +m[3]!, w: +m[4]!, h: +m[5]! }));
// WDP · (ML · Office) × players · ML · EDP — including the Mainline card before the East
// Division Point, which the issue's own sketch left out.
assert.equal(rects.length, 2 * players + 3, `${players}p drew ${rects.length} cells`);
assert.equal(rects[0]!.kind, 'dp', `${players}p does not start at a Division Point`);
assert.equal(rects[rects.length - 1]!.kind, 'dp', `${players}p does not end at a Division Point`);
assert.equal(rects[rects.length - 2]!.kind, 'ml', `${players}p has no Mainline card before the East DP`);
// ONE ROW: every cell at the same y, and x strictly increasing.
const ys = new Set(rects.map((r) => r.y));
assert.equal(ys.size, 1, `${players}p drew ${ys.size} rows — the Division must be one`);
for (let i = 1; i < rects.length; i++) {
assert.ok(
rects[i]!.x > rects[i - 1]!.x,
`${players}p: cell ${i} is not east of the one before it — east is no longer to the right`,
);
}
for (let i = 0; i < rects.length; i++) {
const a = rects[i]!;
assert.ok(
a.x >= 0 && a.y >= 0 && a.x + a.w <= W && a.y + a.h <= H,
`${players}p: a cell falls outside the canvas`,
);
for (let j = i + 1; j < rects.length; j++) {
const b = rects[j]!;
const hit = a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
assert.ok(!hit, `${players}p: two cells overlap`);
}
}
}
});
it('draws no office-area detail on the Division map, but keeps the trains', () => {
// Gitea#18: "Division map should not show any office area detail (no limits, no running track,
// etc.)" — an Office used to expand into its whole Running Track, Limits to Limits, so this map
// carried every straight, turnout and Limits sign of every district and grew sideways as
// districts were built. One cell per district now.
//
// The trains stay: "trains within the office area should definitely be represented on the
// division map", split into the A/D register and the crews switching below it.
const s = createEngineGame({
id: 'div-collapse', seed: 7,
config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
const area = areaOf(s, 0);
area.tier = 'terminal';
const place = (id: string, n: number, coord: { row: number; col: number }, ad: boolean): void => {
s.trays.set(id, {
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'boxcar', loaded: true }],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord }, movesUsed: 0,
} as never);
if (ad) area.adOccupancy.push(id);
};
place('ad1', 9, area.officeCoord, true);
place('sw1', 7, { row: area.runningRow - 1, col: 0 }, false);
const svg = divisionSvg(snapshot(s, [], null).division);
// ONE district cell, not one per Running Track card.
const districts = [...svg.matchAll(/class="bs-dcell bs-drun/g)].length;
assert.equal(districts, 1, `the district drew as ${districts} cells`);
assert.doesNotMatch(svg, /Limits/, 'a Limits sign reached the Division map');
// Both trains are on it, in two registers — the A/D one above the crew switching below.
const chips = [...svg.matchAll(/class="bs-train" data-tip="(T\d+)[^"]*"><rect x="[\d.]+" y="([\d.]+)"/g)]
.map((m) => ({ label: m[1]!, y: +m[2]! }));
const ad = chips.find((c) => c.label === 'T9');
const sw = chips.find((c) => c.label === 'T7');
assert.ok(ad, 'the train holding an A/D track is not on the map');
assert.ok(sw, 'the crew switching in the district is not on the map');
assert.ok(sw.y > ad.y, 'the switching crew should be drawn BELOW the A/D register, not beside it');
});
it('keeps every roster chip inside the Office cell it belongs to, at any occupancy', () => {
// "Two Trains, One Card": sizing the cell by OCCUPANCY moved the East Division Point sideways
// every time an A/D track filled or cleared. Sizing by CAPACITY (docs/plans/switching-paths.md)
// holds the map still, and every chip has to actually fit inside the cell that capacity bought.
const s = createEngineGame({
id: 'div-chips', seed: 7,
config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
const area = areaOf(s, 0);
area.tier = 'terminal'; // 4 A/D tracks
for (const n of [10, 12, 14, 16]) {
const id = `t${n}`;
s.trays.set(id, {
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'boxcar', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
} as never);
area.adOccupancy.push(id);
}
const svg = divisionSvg(snapshot(s, [], null).division);
const cells = [...svg.matchAll(/class="bs-dcell bs-drun[^"]*"[^>]*><rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)]
.map((m) => ({ x: +m[1]!, w: +m[3]! }));
assert.ok(cells.length > 0, 'no Running Track cell drawn');
const office = cells.reduce((a, b) => (b.w > a.w ? b : a));
assert.ok(office.w > 78, `the Office cell did not widen for its 4 A/D tracks: ${office.w}`);
const chips = [...svg.matchAll(/class="bs-train"[^>]*><rect x="([-\d.]+)" y="[-\d.]+" width="([\d.]+)"/g)]
.map((m) => ({ x: +m[1]!, w: +m[2]! }));
assert.equal(chips.length, 4, `expected 4 train chips, got ${chips.length}`);
for (const c of chips) {
assert.ok(
c.x >= office.x - 0.01 && c.x + c.w <= office.x + office.w + 0.01,
`a chip at x=${c.x} w=${c.w} spills outside the Office cell [${office.x}, ${office.x + office.w}]`,
);
}
});
it('draws the Division as a line with two ends, never as a loop', () => {
// Seating players around a table invites exactly one misreading: that the route joins up. It
// does not. Both ends are labelled as ends, not as an entrance and an exit: odd trains run west
// and even run east, so each Division Point is a way on AND a way off.
for (const players of [1, 2, 3, 4]) {
const svg = divisionFor(players);
assert.equal((svg.match(/class="bs-stop"/g) ?? []).length, 2, `${players}p has no pair of buffer stops`);
assert.match(svg, /west end · in and out/, `${players}p does not label the west end`);
assert.match(svg, /east end · in and out/, `${players}p does not label the east end`);
/**
* AND THE LABELS MUST BE ON THE CANVAS.
*
* REPORTED: both were clipped. They hung off the OUTSIDE of the end cells, anchored away from
* the board, so fitting them meant padding wider than the caption — which the board did not
* have, and which would have spent that width on two captions rather than on the map. Centred
* under their own Division Point they cost nothing and cannot be cut off.
*/
const vb = /viewBox="0 0 (\d+) (\d+)"/.exec(svg);
const W = Number(vb![1]);
const ends = [...svg.matchAll(/class="bs-end" x="([\d.]+)" y="([\d.]+)" text-anchor="middle">([^<]+)</g)];
assert.equal(ends.length, 2, `${players}p: an end label is not centred under its cell`);
for (const e of ends) {
const x = Number(e[1]);
const half = e[3]!.length * 3; // ~6px a character in the 10px monospace this uses
assert.ok(x - half >= -24 && x + half <= W + 24, `${players}p: "${e[3]}" is clipped at x=${x} of ${W}`);
}
}
});
/**
* #94 — A RED FLAG IS A THING STANDING ON THE BOARD, AND IT WAS DRAWN NOWHERE.
*
* `maneuver.redFlags` sets `node.redFlag` on an Office's Division node, and from then on
* `redFlagStop` holds the next train arriving from that side until the flag is spent. It is a
* standing token that stops trains — the same kind of object as a train or an A/D track, not a
* transient event.
*
* It was announced once in the log and then existed only in the engine. The office node in
* `DivisionView` carried no field for it and `board-svg.ts` never mentioned one, so a player who
* set a flag out three Stages ago had nothing on screen saying it was still there, and an opponent
* who missed the line never knew at all. Then a train stops short and the only explanation has
* scrolled away.
*
* The same failure as Gitea#21 and #22: the engine is right and the screen is silent about what it
* is acting on. Asserted on both halves, because either alone would have looked fixed — the
* projection has to carry it AND the map has to draw it.
*/
describe('#94 — a Red Flag standing at the Limits is on the board and on the map', () => {
const withFlag = (side: 'east' | 'west' | null): ReturnType<typeof snapshot> => {
const s = createEngineGame({
id: 'redflag',
seed: 11,
config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0,
maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
const office = s.division.nodes.find((n) => n.kind === 'office');
assert.ok(office && office.kind === 'office', 'no Office node in the Division');
// Exactly what `redFlagsSet`'s reducer does (`apply.ts`).
if (side) (office as { redFlag?: string }).redFlag = side;
return snapshot(s, [], null);
};
it('carries the flag on the office node, and its side', () => {
const node = withFlag('east').division.find((n) => n.kind === 'office');
assert.ok(node, 'no office node in the Division view');
assert.equal(
(node as { redFlag?: string | null }).redFlag,
'east',
'the Division view does not carry the Red Flag standing at this Office',
);
});
it('carries nothing when no flag is out — it must not draw one by default', () => {
const node = withFlag(null).division.find((n) => n.kind === 'office');
const flag = (node as { redFlag?: string | null }).redFlag;
assert.ok(flag === null || flag === undefined, `an Office with no flag reported "${flag}"`);
});
it('draws it on the map, and says which side it guards', () => {
const svg = divisionSvg(withFlag('west').division);
assert.match(svg, /bs-flag/, 'the Red Flag is not drawn on the Division map');
// The side is the whole of the information: a flag guards ONE approach, and a player deciding
// whether to run a train needs to know which.
assert.match(svg, /RED FLAG[^"]*[Ww]est/, 'the map does not say which side the flag guards');
});
it('draws none when none is out', () => {
assert.ok(!/bs-flag/.test(divisionSvg(withFlag(null).division)), 'a flag was drawn with none set');
});
});
/**
* GITEA#22 — and the same invariant, applied to the trains standing on a card.
*
* The mirror itself is proved on the view in `mainline-cards.test.ts`. This is the other end of
* it: that the number the view hands over actually reaches the canvas as a position, so the chip
* a player looks at is on the correct half. The report was about the PICTURE, and a view that is
* right behind a renderer that ignores it would read to a player as no fix at all.
*
* Asserted on x, the way the Heavy Grade wedge above is: east is right on this map, so a
* westbound train that has just entered belongs to the RIGHT of one that is nearly across, and
* an eastbound pair in the same state belongs the other way round.
*/
it('draws a westbound train on the half of the card it is actually standing on (Gitea#22)', () => {
const card = (trains: { label: string; region: number }[]): DivisionView =>
({
kind: 'ml', label: 'Curves', capacity: 1, modifiers: [], gradeUp: null, regions: 2,
what: 'two regions',
trains: [trains.map((t) => ({ ...t, cars: [], facing: 'w' }))],
} as unknown as DivisionView);
const xOf = (svg: string, label: string): number => {
const m = new RegExp(`<text class="bs-tlab" x="([\\d.]+)"[^>]*>${label}[^<]*<`).exec(svg);
assert.ok(m, `no chip drawn for ${label}`);
return Number(m![1]);
};
// The two regions the view now reports for the seed 550943578 card: TX17 had just entered
// westbound (the east box, index 1) and T5 was nearly across (the west box, index 0).
const svg = divisionSvg([card([{ label: 'TX17', region: 1 }, { label: 'T5', region: 0 }])]);
assert.ok(
xOf(svg, 'TX17') > xOf(svg, 'T5'),
'the train that has just entered westbound was not drawn east of the one nearly across',
);
// And the halves are genuinely distinct — a renderer that centred both would satisfy nothing
// above but would still tell a player nothing.
assert.notEqual(xOf(svg, 'TX17'), xOf(svg, 'T5'), 'both chips were drawn at the same x');
});
});
describe('every square the menu offers can actually be clicked (regression)', () => {
it('draws a target for it, inside the canvas', () => {
// REGRESSION. Empty squares were drawn by a separate `ghostSvg` and spliced into the SVG after
// the fact. It sized its origin over cells PLUS spots while `officeSvg` sized itself over cells
// alone, so the two disagreed the moment a legal square lay outside the played cards — which is
// every square that would EXTEND the district, i.e. most of them.
//
// Seed 555 offered three placements for a straight; the empty square was drawn at y=99 on a
// canvas 103 tall. The list said three, the board showed two, and the third could not be
// clicked at all. Ghosts are drawn by `officeSvg` in the same pass now, so there is one origin
// and one canvas.
let checked = 0;
for (const seed of [555, 42, 909, 7, 88, 1234]) {
const game = newGame(seed);
const draw = actionMenu(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw');
if (draw) submit(game, draw);
// Track is drawn from the deck now, so an opening hand often holds nothing placeable at all.
// Open the district with a turnout, then hold the curve that its 45° leg calls for: that curve
// is offered a square OFF the Running Track, which is the case that used to draw off-canvas.
const { option: turnout } = dealTrack(game, 'turnout', 'left');
if (turnout) submit(game, turnout);
// Hold BOTH the curve its leg calls for and a straight: the curve is offered the square off
// the Running Track, the straight the growth points on it. A curve may no longer be laid in
// the running row at all — it has no east-west road and would dead-end the main — so on its
// own it now offers a single square and the bounds check would barely exercise anything.
const curve = dealTrack(game, 'curved', 'left');
const straight = dealTrack(game, 'straight', 'none');
game.state.decks.hands.set(0, [curve.cardId, straight.cardId]);
const menu = actionMenu(game);
const f = view(game);
for (const item of menu.placeable.flatMap((g) => g.items)) {
// Only the spots that ARE on this board: a Mainline placement has no square to draw.
const onBoard = item.spots.flatMap((sp) => (sp.coord === null ? [] : [sp.coord]));
const uniq = [...new Map(onBoard.map((c) => [`${c.row},${c.col}`, c])).values()];
const ghosts = uniq.filter((c) => !f.cells.some((x) => x.row === c.row && x.col === c.col));
const svg = officeSvg(f.cells, f.runningRow, ghosts);
const vb = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg);
assert.ok(vb, 'the board produced no viewBox');
const W = Number(vb![1]);
const H = Number(vb![2]);
for (const c of uniq) {
checked++;
const key = `${c.row},${c.col}`;
const isCard = f.cells.some((x) => x.row === c.row && x.col === c.col);
const target = isCard ? `data-cell="${key}"` : `data-ghost="${key}"`;
assert.ok(svg.includes(target), `${item.subject}: (${key}) is offered but has no target on the board`);
// `data-tip` sits between the id and the transform on a real card, so match loosely.
const m = new RegExp(
'data-(?:cell|ghost)="' + key + '"[^>]*transform="translate\\((-?[\\d.]+),(-?[\\d.]+)\\)"',
).exec(svg);
assert.ok(m, `${item.subject}: (${key}) has no position`);
const x = Number(m![1]);
const y = Number(m![2]);
assert.ok(
x >= 0 && y >= 0 && x + 166 <= W + 0.01 && y + 96 <= H + 0.01,
`${item.subject}: (${key}) is drawn off-canvas at (${x}, ${y}) on ${W}x${H} — a legal move that cannot be clicked`,
);
}
}
}
assert.ok(checked >= 12, `only ${checked} squares checked — the test would be vacuous`);
});
});
describe('the sounds fire on the events they name', () => {
it('one cue per Stage boundary, the bell replacing the whistle at a Day', () => {
// The model names WHAT happened and the page decides what it sounds like. Getting this wrong is
// not a silent failure — it is a whistle every few seconds, or a bell that never rings — so the
// count is checked against the clock rather than trusted.
const game = newGame(555);
const cues: Record<string, number> = {};
let stageBoundaries = 0;
let dayBoundaries = 0;
for (let i = 0; i < 4000; i++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0 || options.length === 0) break;
const before = { d: game.state.clock.day, s: game.state.clock.stage };
submit(game, options[groups[0]!.actions[0]!.index]!);
for (const c of game.cues) cues[c] = (cues[c] ?? 0) + 1;
game.cues.length = 0;
if (game.state.clock.day !== before.d) dayBoundaries++;
if (game.state.clock.stage !== before.s || game.state.clock.day !== before.d) stageBoundaries++;
}
assert.ok(stageBoundaries > 20, `only ${stageBoundaries} Stages elapsed — the game did not run`);
assert.equal(cues['day'] ?? 0, dayBoundaries, 'the Day bell does not match the Days played');
assert.equal(
(cues['stage'] ?? 0) + (cues['day'] ?? 0),
stageBoundaries,
'Stage boundaries and Stage-or-Day cues disagree — some Stage ended silently, or one sounded twice',
);
assert.ok((cues['train'] ?? 0) > 0, 'no train was ever announced');
});
it('says nothing at all before the first Stage has ended', () => {
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening.
const game = newGame(555);
assert.deepEqual(game.cues, [], 'the game announced a Stage ending before one had');
});
});
describe('the hand limit is a limit, not a toll on drawing (regression)', () => {
it('only blocks the end of a turn when more than three cards are held', () => {
// REGRESSION. The page set "must play a card" on ANY draw, so a player who had already played
// two cards and drew back to three was still forced to spend one. §6.2 is a hand LIMIT —
// "reduce his hand to no more than three cards" — and the engine says the same: `draw.end` is
// refused with HAND_LIMIT and otherwise allowed.
const game = newGame(555);
const hand = game.state.decks.hands.get(0)!;
hand.length = 0;
assert.equal(overHandLimit(game), false, 'an empty hand blocked the turn from ending');
const ids = [...game.state.cards.keys()].slice(0, 5);
hand.push(...ids.slice(0, 3));
assert.equal(overHandLimit(game), false, 'three cards is the limit, not over it');
hand.push(ids[3]!);
assert.equal(overHandLimit(game), true, 'four cards must be played down');
// A Red Flag raises the limit by one (§6.2).
game.state.decks.redFlags.set(0, true);
assert.equal(overHandLimit(game), false, 'a Red Flag allows a fourth card');
hand.push(ids[4]!);
assert.equal(overHandLimit(game), true, 'five cards is over even with a Red Flag');
});
it('agrees with the engine about when the turn may end', () => {
// Two statements of one rule is how they drift. This asserts they cannot.
const game = newGame(909);
for (let i = 0; i < 400; i++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0 || options.length === 0) break;
if (game.state.clock.phase === 'localOps' && turnOf(game.state, 0).option === 'draw') {
const engineAllows = options.some((o) => o.type === 'draw.end');
assert.equal(
engineAllows,
!overHandLimit(game),
'the page and the engine disagree about whether the turn may end',
);
}
submit(game, options[groups[0]!.actions[0]!.index]!);
}
});
});
describe('a train says what its card calls for', () => {
it('explains a consist that offers only one kind of car', () => {
// Reported from playtesting on seed 22222: Extra X22 offered a caboose and nothing else, with
// no reason given. The rules are right — "Pee-Dee" is a per-diem train whose consist is one
// caboose and no cars — but the screen never said so, which reads as a broken game.
const game = newGame(22222);
const titles = new Set<string>();
for (let i = 0; i < 3000; i++) {
if (currentActor(game) === null) break;
const { options, groups } = actionGroups(game);
if (groups.length === 0 || options.length === 0) break;
for (const g of groups) if (g.kind === 'newTrain.') titles.add(g.title);
submit(game, options[groups[0]!.actions[0]!.index]!);
}
assert.ok(titles.size > 0, 'no train was ever made up');
for (const t of titles) {
assert.match(t, /its card calls for/, `a train was made up with no consist explained: ${t}`);
assert.ok(/“[^”]+”/.test(t), `the train is not named: ${t}`);
}
});
});
describe('the three places a game is drawn stay in step', () => {
// A game is drawn on the playable page, in the standalone replay file, and in the website's
// replay viewer. They had drifted: the viewer offered three speeds where the standalone offered
// five, and had neither the sound nor the auto-hide the other two grew. Anything a player learns
// on one screen should hold on the others.
it('offers the same playback speeds in both replay viewers', () => {
const site = readFileSync(join(dist, 'replays.html'), 'utf8');
const standalone = renderHtml(record(880009, 'standard', 600));
const speeds = (html: string): string[] =>
[...html.matchAll(/<option value="\d+"[^>]*>([a-z ]+)<\/option>/g)].map((m) => m[1]!.trim());
const a = speeds(site);
const b = speeds(standalone);
assert.ok(a.length >= 5, `the site viewer offers only ${a.length} speeds: ${a.join(', ')}`);
assert.deepEqual(
new Set(a),
new Set(b),
`the two viewers offer different speeds — site: ${a.join(', ')} / standalone: ${b.join(', ')}`,
);
for (const wanted of ['extra slow', 'very fast']) {
assert.ok(a.includes(wanted), `the site viewer has no "${wanted}" speed`);
}
});
it('offers sound and auto-hide everywhere a game is drawn', () => {
const pages: [string, string][] = [
['play.html', readFileSync(join(dist, 'play.html'), 'utf8')],
['replays.html', readFileSync(join(dist, 'replays.html'), 'utf8')],
['the standalone replay', renderHtml(record(880009, 'standard', 600))],
];
for (const [name, html] of pages) {
assert.match(html, /id="v?sound"/, `${name} has no sound control`);
assert.match(html, /id="v?districttoggle"/, `${name} has no auto-hide control`);
assert.match(html, /id="v?districtsummary"/, `${name} shows nothing when the district is folded`);
assert.match(html, /district\.folded/, `${name} has no rule to fold the district away`);
}
});
it('asks the replay viewer for no element its page lacks', () => {
// The same total check the playable page gets: a `$('vfoo')` left behind after removing #vfoo
// throws on load, and the page simply never starts.
const src = readFileSync(join(root, 'src/web/replays.ts'), 'utf8');
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
const html = readFileSync(join(dist, 'replays.html'), 'utf8');
const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
for (const id of asked) {
assert.ok(present.has(id), `replays.ts asks for #${id}, which replays.html does not contain`);
}
});
});
describe('the Day rolling over says so (Gitea#10)', () => {
// "As the game rolls off the end of the day, you get a dialog saying such. Hard to keep track of
// time." A Day turns inside the phases that run themselves, so it passes between one click and
// the next — the phase banner and the announcement flash are both gone in a few seconds.
const frameAt = (day: number, days = 5): Frame => {
const s = createEngineGame({
id: 'dayend', seed: 4021,
config: {
mode: 'solitaire', days, minCombinedRevenue: 12, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
s.clock.day = day;
return snapshot(s, [], null);
};
it('names the Day that ENDED, not the one starting', () => {
// Written from the frame after the rollover, so an off-by-one here would congratulate a player
// on finishing a Day they have not played yet.
const html = dayEndHtml(frameAt(3));
assert.ok(html.includes('Day 2 has ended'), `wrong Day named:\n${html}`);
assert.ok(html.includes('Day 3 of 5'), 'the Day now beginning is not named');
assert.ok(html.includes('3 Days left'), `the Days remaining are wrong:\n${html}`);
});
it('draws the Home Office deck, face down, with its count', () => {
/**
* `f.deck` has carried the face-down count since the Frame existed and NOTHING drew it — the
* exact display gap `test/display-gaps.test.ts` sweeps for, surviving in the panel that draws
* every other pile. Asked for by Jesse 2026-09-10, who also wanted somewhere for a draw to
* flash: taking a card off this deck is the commonest move nobody can see.
*/
const s = createEngineGame({
id: 'piles',
seed: 5,
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 60,
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Joe', 'Bot 1'],
});
const f = snapshot(s, [], null);
assert.ok(f.deck > 0, 'the deal should leave cards in the Home Office deck');
const html = pilesHtml(f);
assert.ok(html.includes('Home Office'), `no Home Office pile:\n${html}`);
assert.ok(html.includes(`>${f.deck}<`), 'the face-down count is not shown');
// Face down means the card slot must NOT name a card — that is the whole point of the pile.
assert.ok(html.includes('facedown'), 'the Home Office pile is not marked face down');
assert.ok(html.includes('face down'), 'the card slot should say so rather than naming a card');
// It comes first: a card travels out of here, then onto a Department or the Salvage Yard.
assert.ok(
html.indexOf('Home Office') < html.indexOf('Dept 1'),
'the draw deck should be read before the piles cards land on',
);
});
it('lights only the pile a watched move touched', () => {
const s = createEngineGame({
id: 'piles2',
seed: 5,
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 60,
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Joe', 'Bot 1'],
});
const f = snapshot(s, [], null);
assert.equal(pilesHtml(f).includes('pilelit'), false, 'nothing is lit when nothing was watched');
const home = pilesHtml(f, ['home']);
assert.equal((home.match(/pilelit/g) ?? []).length, 1, 'exactly one pile should light');
assert.ok(
home.indexOf('pilelit') < home.indexOf('Dept 1'),
'a Home Office draw must light the Home Office pile, not a Department',
);
const dept2 = pilesHtml(f, ['dept1']);
assert.equal((dept2.match(/pilelit/g) ?? []).length, 1);
assert.ok(dept2.indexOf('Dept 2') > dept2.indexOf('Dept 1'), 'order sanity');
// Two piles can move at once — a Department draw that refills from the deck.
assert.equal((pilesHtml(f, ['home', 'dept0']).match(/pilelit/g) ?? []).length, 2);
});
it('reports the ENDED Day\'s collisions, not the fresh Day\'s zero', () => {
/**
* Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it: *"It shows a total of two
* collisions, but zero today. Since we just finished day one, that does seem to be a
* contradiction."*
*
* The cause is a one-line ordering fact: `advance.ts` increments the Day and then zeroes
* `collisionsToday`, and this dialog is drawn from the frame whose Day went UP — so it read the
* fresh Day's zero and printed it beside a running total that could not agree with it. The count
* is captured at the rollover now, and the dialog names the Day rather than saying "today".
*/
const s = createEngineGame({
id: 'collide',
seed: 5,
config: {
mode: 'competitive',
days: 5,
minCombinedRevenue: 60,
maxCollisionsPerDay: 3,
maxCollisionsTotal: 10,
pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Joe', 'Bot 1'],
});
// The state as the rollover out of Day 1 leaves it: two collisions happened, `today` is reset.
s.clock.day = 2;
s.collisionsPrevDay = 2;
s.collisionsToday = 0;
s.collisionsTotal = 2;
const html = dayEndHtml(snapshot(s, [], null));
assert.ok(html.includes('Day 1 has ended'), `wrong Day named:\n${html}`);
assert.ok(html.includes('<b>2</b> on Day 1'), `the ended Day's collisions are wrong:\n${html}`);
assert.ok(html.includes('<b>2</b> in all'), `the running total is wrong:\n${html}`);
assert.doesNotMatch(html, /<b>0<\/b> today/, `still reporting the fresh Day's zero:\n${html}`);
// The contradiction itself: a Day-end dialog must never claim fewer in all than on that Day.
assert.doesNotMatch(html, /<b>0<\/b> on Day 1/, 'reported no collisions on a Day that had two');
});
it('counts the last Day as the last Day rather than promising more', () => {
const html = dayEndHtml(frameAt(6));
assert.ok(html.includes('Day 5 has ended'), 'the final Day is misnamed');
assert.ok(html.includes('last Day on the timetable'), `still offering Days to run:\n${html}`);
assert.ok(!html.includes('Days left'), 'promises more Days after the last one');
});
it('scores the table against the COMBINED target, not one player against it', () => {
// `minCombinedRevenue` is a floor for the whole table. Showing one player's Revenue against a
// four-player target reads as hopeless when the table may be well ahead.
const f = frameAt(2);
f.players = [
{ index: 0, seat: 0, name: 'Ada', revenue: 4, hand: 3 },
{ index: 1, seat: 1, name: 'Bo', revenue: 7, hand: 2 },
];
f.viewer = 0;
const html = dayEndHtml(f);
assert.ok(html.includes('<b>11</b>'), `combined Revenue is not 4 + 7:\n${html}`);
assert.ok(html.includes('<b>12</b>'), 'the target is not shown');
// Revenue order, so the leader is first: Bo (7) above Ada (4).
assert.ok(html.indexOf('Bo') < html.indexOf('Ada'), 'the standings are not in Revenue order');
assert.ok(html.includes('(you)'), 'the viewer is not marked in the standings');
});
it('says nothing about collisions in a game that is not scored on them', () => {
// §3.4's collision checks run in competitive and co-op ONLY, and `0` on a dial turns that check
// off besides. A solitaire game carries the default dials and enforces neither, so a collision
// budget on screen there would be a rule this game does not have.
const solo = frameAt(2);
assert.ok(!dayEndHtml(solo).includes('Collision'), 'reports a collision budget in solitaire');
const coop = frameAt(2);
coop.mode = 'coop';
coop.maxCollisionsTotal = 3;
coop.collisionsTotal = 1;
assert.ok(dayEndHtml(coop).includes('Collision'), 'hides collisions in a game scored on them');
const noDials = frameAt(2);
noDials.mode = 'coop';
noDials.maxCollisionsTotal = 0;
noDials.maxCollisionsPerDay = 0;
assert.ok(!dayEndHtml(noDials).includes('Collision'), 'reports collisions with both dials off');
});
it('gives the page the dialog to fill', () => {
const html = readFileSync(join(dist, 'play.html'), 'utf8');
for (const id of ['dayenddlg', 'dayendbody', 'resultsdlg', 'resultsbody']) {
assert.ok(html.includes(`id="${id}"`), `play.html has no #${id}`);
}
});
});
// ---------------------------------------------------------------------------
describe('configWith', () => {
/**
* The Revenue floor is a function of how long the game is, so a helper that takes `days` and
* defaults the floor has to derive it. It fell back to `SOLO_CONFIG`'s five-Day constant until
* 2026-08-30, which let the two fields disagree: a one-Day game was asked to clear 15, a floor a
* five-Day game averages barely half of. Found by a probe that passed only `days` — which is how
* the next caller would reach for it.
*/
it('derives the Revenue floor from the days it was given', () => {
assert.equal(configWith({ days: 1 }).minCombinedRevenue, 3, 'a one-Day game kept the five-Day floor');
assert.equal(configWith({ days: 10 }).minCombinedRevenue, 30, 'a ten-Day game kept the five-Day floor');
});
it('leaves the default day count exactly where it was', () => {
// The fix must not move the floor anybody is actually playing against: SOLO_CONFIG's own floor
// is this same formula at DEFAULT_DAYS, so the default path is unchanged.
assert.equal(configWith({}).minCombinedRevenue, SOLO_CONFIG.minCombinedRevenue);
assert.equal(configWith({ days: SOLO_CONFIG.days }).minCombinedRevenue, SOLO_CONFIG.minCombinedRevenue);
});
it('still lets a caller name a floor that has nothing to do with the length', () => {
// Deriving is the DEFAULT, not a rule — the New Game dialog and the lobby both let a player set
// a floor directly, and that has to survive being passed through here.
assert.equal(configWith({ days: 10, minCombinedRevenue: 4 }).minCombinedRevenue, 4);
assert.equal(configWith({ days: 3, minCombinedRevenue: 0 }).minCombinedRevenue, 0, 'an off floor was re-derived');
});
});
describe('the end-of-game results screen (Gitea#16)', () => {
/**
* A finished game, built by running the clock off the end of a real one rather than by hand — the
* screen reads `official`, and only the engine writes that.
*/
const finished = (over: Partial<GameConfig> = {}, names = ['Solitaire']): Frame => {
const s = createEngineGame({
id: 'results', seed: 4021,
config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 12,
maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
...over,
},
playerNames: names,
});
s.clock.day = s.config.days + 1;
s.clock.stage = STAGES_PER_DAY;
s.clock.phase = 'shiftChange';
advanceEngine(s);
return snapshot(s, [], null);
};
it('asks the extension question ON the results dialog, not only behind it (Gitea#11)', () => {
/**
* REPORTED BY JESSE 2026-08-30: "Solitaire game ended. I did not have an option to extend the
* game by a day."
*
* The engine and the Frame were right all along — a solitaire game at the end of its timetable
* reaches `awaitingExtension` with an uncast vote, asserted below. What went wrong is that
* `renderEnding` writes its two buttons into `#actions` and then opens `#resultsdlg`, which is
* MODAL: the question was underneath a dialog whose only control was Close. Gitea#11 was
* verified over the HTTP API, which renders no dialog, so the browser path never was.
*/
const f = finished();
assert.equal(f.status, 'awaitingExtension', 'a solitaire game no longer pauses to ask');
assert.equal(f.extensionVotes[f.viewer], null, 'the viewer has somehow already voted');
const html = readFileSync(join(dist, 'play.html'), 'utf8');
for (const id of ['rs-extend-yes', 'rs-extend-no']) {
assert.ok(html.includes(`id="${id}"`), `the results dialog cannot ask: no #${id}`);
}
// And they are wired: shown only while a vote is pending, and casting the same intent the
// `#actions` buttons do. Read from the source, since the dialog needs a DOM to drive.
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
const fn = /function showResults\(f: Frame\): void \{[\s\S]*?\n\}/.exec(src)?.[0] ?? '';
assert.ok(fn !== '', 'showResults moved and this test cannot see it');
assert.match(fn, /awaitingExtension/, 'the dialog does not know the game is asking');
assert.match(fn, /'game\.extend'/, 'the dialog offers no way to answer');
assert.match(fn, /yes\.hidden = !asking/, 'the buttons are not hidden on a game that is over');
});
it('never prints a raw enum at the player', () => {
// The bug the issue opens on: `GAME OVER — revenueFloor` is an internal identifier, shown at
// the one moment the game has the player's whole attention.
for (const f of [finished(), finished({ minCombinedRevenue: 0 })]) {
const html = resultsHtml(f);
for (const raw of ['revenueFloor', 'daysElapsed', 'collisionFloor']) {
assert.ok(!html.includes(raw), `the results screen prints the raw reason "${raw}"`);
}
}
});
it('says why the game ended, in a sentence, with the game\'s own numbers in it', () => {
const html = resultsHtml(finished());
assert.ok(html.includes('closed short'), `the revenue-floor ending is not explained:\n${html}`);
assert.ok(html.includes('<b>12</b>'), 'the floor that was missed is not named');
});
it('reports the rules the game was actually dealt under', () => {
const html = resultsHtml(finished({ mode: 'coop' }, ['Ada', 'Bo']));
assert.ok(html.includes('The rules in play'), 'the rules section is missing');
assert.ok(html.includes('Co-op'), 'the mode is not reported');
assert.ok(html.includes('per load'), 'the pay rates are not reported');
});
it('reports the railroad from the tally, not from nothing', () => {
const html = resultsHtml(finished());
assert.ok(html.includes('Trains through the Division'), 'the tally section is missing');
});
it('reports BOTH halves of the work pipeline, and cards discarded', () => {
/**
* FOUND 2026-08-30 auditing which `Frame` fields nothing reads. Two `Tally` members were
* counted by the engine and drawn by nothing:
*
* - `unloadsBegun`. §9.1 makes loading and unloading the same shape — begun, then carried
* through — and the screen printed "Loads still in the pipeline" for one side and nothing for
* the other, so it reported half of a symmetric mechanism.
* - `cardsDiscarded`. Gitea#9 made throwing a Timetabled train away a legal, deliberate move,
* so a discard is a CHOICE, listed beside draws and plays rather than left out of them.
*
* The tally is overridden rather than played into, because reaching a part-finished unload and
* a discard in the same seeded game is incidental to what is being checked here.
*/
const f = finished();
/**
* THE OFFICIAL REPORT'S TALLY IS THE ONE DRAWN, not the Frame's — `resultsHtml` renders
* `tallyHtml(report?.tally ?? f.tally)` and `report` is `f.official`, which the engine writes
* the moment any game ends. Overriding only `f.tally` here changed nothing at all and the test
* failed for a reason that had nothing to do with the fix, which is worth pinning in passing:
* a finished game reports the numbers frozen at the official ending, not the live ones.
*/
const withTally = (over: Partial<Frame['tally']>): Frame => ({
...f,
tally: { ...f.tally, ...over },
official: f.official ? { ...f.official, tally: { ...f.official.tally, ...over } } : f.official,
});
const html = resultsHtml(withTally({ unloadsBegun: 5, unloadsCompleted: 2, cardsDiscarded: 3 }));
assert.ok(html.includes('Unloads still in the pipeline'), 'the unloading pipeline is not reported');
assert.ok(html.includes('Cards discarded'), 'a deliberate discard is counted and never reported');
// The in-flight count is begun-minus-completed, matching the loading line beside it.
assert.match(
html,
/Unloads still in the pipeline<\/[a-z]+><[^>]*>3</,
'the unloading pipeline does not report begun-minus-completed',
);
// And a game with nothing in flight and nothing discarded says neither — `push` drops a zero.
const quiet = resultsHtml(withTally({ unloadsBegun: 2, unloadsCompleted: 2, cardsDiscarded: 0 }));
assert.ok(!quiet.includes('Unloads still in the pipeline'), 'an empty pipeline was reported as a fact');
assert.ok(!quiet.includes('Cards discarded'), 'a game with no discards claimed to have some');
});
it('names the winner the OFFICIAL result named, not whoever leads now (Gitea#11)', () => {
const f = finished({ mode: 'competitive', minCombinedRevenue: 0 }, ['Ada', 'Bo']);
// Ada won at the timetable; Bo overtakes during extended play. The frozen result stands.
f.official = {
day: 5,
outcome: { result: 'win', winner: 0, reason: 'daysElapsed' },
revenues: [9, 2],
collisionsTotal: 0,
tally: f.tally,
};
f.extraDays = 3;
f.players = [
{ index: 0, seat: 0, name: 'Ada', revenue: 9, hand: 3 },
{ index: 1, seat: 1, name: 'Bo', revenue: 40, hand: 2 },
];
const html = resultsHtml(f);
assert.ok(html.includes('Ada takes the Division'), `the official winner is not the headline:\n${html}`);
assert.ok(html.includes('winner'), 'nobody is marked as the winner in the standings');
assert.ok(html.includes('After the timetable'), 'extended play is not reported at all');
assert.ok(html.includes('3 more Days'), 'the extra Days are not counted');
assert.ok(
html.indexOf('Ada takes the Division') < html.indexOf('After the timetable'),
'the informational section is above the official result',
);
});
it('leaves out the extended-play section entirely when nothing was extended', () => {
assert.ok(!resultsHtml(finished()).includes('After the timetable'));
});
});
describe('every file the build needs is actually in the repo (regression)', () => {
it('does not gitignore a source page', () => {
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
// repo root — unanchored, so it also matched `src/web/replays.html`, which is a SOURCE file.
// That page had therefore NEVER been committed: a fresh clone was missing it, and the build
// copies it unconditionally. The pattern is anchored to the root now.
//
// Asked of git rather than read out of .gitignore: the question is whether the file survives a
// clone, and only git can answer that. `check-ignore -q` exits 0 when ignored, 1 when not.
for (const page of ['src/web/index.html', 'src/web/play.html', 'src/web/replays.html']) {
let ignored: boolean;
try {
execFileSync('git', ['check-ignore', '-q', page], { cwd: root, stdio: 'pipe' });
ignored = true;
} catch {
ignored = false;
}
assert.equal(ignored, false, `${page} is gitignored — it would be missing from a fresh clone`);
}
});
});
describe('the tray is an engine plus its Rolling Stock', () => {
it('shows where the engine sits, because it may pull, push, or do both', () => {
// A Crew Tray is an engine and its cars, and the engine may be PULLING (ahead of everything),
// PUSHING (behind everything) or in the middle doing both — which decides which end cars couple
// onto (§A.3) and which way the train can shove.
//
// REGRESSION on a dead field: `engineFront` was a boolean written in three places and read in
// NONE, so the engine had a position the game recorded and never used, and every consist was
// drawn as an anonymous row of cars. It is an index into the consist now.
const s = createEngineGame({
id: 'eng', seed: 1038389,
config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
// A tray built by hand, so every position can be checked rather than hoping one turns up.
// Trays are created when a train is made up, so a fresh game has none.
const trayId = s.freeTrays.pop()!;
const tray = {
id: trayId,
trainNumber: 9,
trainIsExtra: false,
engineAt: 0,
consist: [
{ type: 'boxcar', loaded: false },
{ type: 'hopper', loaded: true },
],
direction: 'east',
position: { at: 'divisionPoint', side: 'west' },
} as never;
s.trays.set(trayId, tray);
const west = s.division.nodes.find((n) => n.kind === 'divisionPoint');
if (west && west.kind === 'divisionPoint') west.holding.push(trayId);
const consistFor = (at: number): string[] => {
(tray as unknown as { engineAt: number }).engineAt = at;
const f = snapshot(s, [], null);
for (const node of f.division) {
for (const t of node.trains.flat()) {
if (t.consist.includes('ENG')) return t.consist;
}
}
return [];
};
assert.equal(consistFor(0)[0], 'ENG', 'an engine at 0 is not drawn pulling');
assert.equal(consistFor(2)[2], 'ENG', 'an engine behind the cars is not drawn pushing');
const middle = consistFor(1);
assert.equal(middle[1], 'ENG', 'an engine between the cars is not drawn in the middle');
assert.equal(middle.length, 3, 'the engine displaced a car instead of sitting between them');
});
it('shows both yards, split loaded and empty', () => {
// Rolling stock is finite and the Classification Yard returns to service only when the Division
// Yard is BARE, so watching the Division Yard run down is real information — and neither yard
// was shown anywhere.
const s = createEngineGame({
id: 'yards', seed: 1038389,
config: {
mode: 'solitaire', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 0, maxCollisionsTotal: 0, pvpCardsAllowed: false,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
const f = snapshot(s, [], null);
assert.ok(f.yards.divisionTotal > 0, 'the Division Yard is dealt nothing');
assert.equal(
f.yards.division.reduce((n, r) => n + r.loaded + r.empty, 0),
f.yards.divisionTotal,
'the Division Yard breakdown does not add up to its total',
);
assert.equal(
f.yards.classification.reduce((n, r) => n + r.loaded + r.empty, 0),
f.yards.classificationTotal,
'the Classification Yard breakdown does not add up to its total',
);
// The page must ask for the elements it fills.
const html = readFileSync(join(dist, 'play.html'), 'utf8');
for (const id of ['divyard', 'clsyard', 'divtot', 'clstot']) {
assert.ok(html.includes(`id="${id}"`), `play.html has no #${id}`);
}
});
});
// ---------------------------------------------------------------------------
describe('the lobby screen', () => {
/**
* DRIVEN THROUGH THE EMITTED BUNDLE, like the New Game dialog below it. The lobby had no test of
* its own at all before 2026-08-23 — every rule in Jesse's game-type design (a type fills the form,
* editing a rule makes it Custom, a parameter does not, clicking a type resets the rules under it)
* lived only in the code that implements it.
*/
const open = async (
search: string,
responses: Record<string, unknown> = {},
stored: Record<string, string> = {},
) => {
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
);
const els = new Map<string, Record<string, unknown>>();
type Radio = {
value: string;
checked: boolean;
disabled: boolean;
onchange: (() => void) | null;
closest: () => unknown;
};
/**
* A radio knows its row, because a disabled choice is dimmed by dimming the whole label — a bare
* `disabled` dot reads as a broken control (Jesse, 2026-08-23).
*/
const group = (values: string[], initial: string): Radio[] =>
values.map((value) => ({
value,
checked: value === initial,
disabled: false,
onchange: null,
closest: () => ({
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
querySelector: () => ({ appendChild: () => {} }),
}),
}));
const groups: Record<string, Radio[]> = {
'lb-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
'lb-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
'lb-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
'ss-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
'ss-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
'ss-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
};
const matching = (sel: string): Radio[] => {
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
const found = groups[name] ?? [];
return sel.endsWith(':checked') ? found.filter((r) => r.checked) : found;
};
const make = (id: string): Record<string, unknown> => {
const classes = new Set<string>();
let html = '';
/**
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
* Office Area's segmented control (TODO #16): without it every render threw
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
* accessible control ships green and unexercised.
*/
const attrs: Record<string, string> = {};
const node: Record<string, unknown> = {
attrs,
setAttribute: (k: string, v: string) => void (attrs[k] = v),
getAttribute: (k: string) => attrs[k] ?? null,
id, value: '', textContent: '', title: '', placeholder: '', className: '',
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null,
checked: false, disabled: false, hidden: false, open: false, returnValue: '',
scrollTop: 0, scrollHeight: 0,
classList: {
add: (c: string) => void classes.add(c),
remove: (c: string) => void classes.delete(c),
contains: (c: string) => classes.has(c),
has: (c: string) => classes.has(c),
toggle: (c: string, on?: boolean) => void (on ?? !classes.has(c) ? classes.add(c) : classes.delete(c)),
},
addEventListener: () => {}, showModal: () => {}, close: () => {}, focus: () => {},
querySelectorAll: (sel: string) => matching(sel),
querySelector: (sel: string) => matching(sel)[0] ?? null,
/**
* A STUB THAT CANNOT MODEL A METHOD SHIPS THE CONTROL GREEN AND UNEXERCISED — the same
* lesson `setAttribute` taught this factory in 2026-08-30, learned again on 2026-09-16.
*
* The Office Area's per-seat buttons are built with `createElement` and appended, because a
* display name is another player's text and must never be interpolated into markup. Without
* this the district panel threw on every render, which took out 21 tests across three suites
* — and the thing it was hiding was a control nothing had ever exercised.
*/
appendChild: () => {},
};
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
return node;
};
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make(id));
return els.get(id);
},
createElement: () => make('style'), addEventListener: () => {},
querySelectorAll: (sel: string) => matching(sel),
body: { appendChild: () => {} }, head: { appendChild: () => {} },
};
g['location'] = { search, origin: 'http://box.local', pathname: '/play.html', reload: () => {} };
const store = new Map<string, string>(Object.entries(stored));
g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
g['URLSearchParams'] = NodeURLSearchParams;
g['EventSource'] = class { close(): void {} addEventListener(): void {} };
g['confirm'] = () => true;
// The page holds a beat on the handoff curtain and auto-hides its banners, both through
// `window.setTimeout` — a stub with no `window` cannot enter a game at all.
g['window'] = { setTimeout: (fn: () => void, ms: number) => setTimeout(fn, ms), clearTimeout };
/** Every request the screen makes, so a test can read what it asked for. */
const sent: { url: string; body: unknown }[] = [];
g['fetch'] = async (url: string, init?: { body?: string }) => {
const body = init?.body === undefined ? undefined : JSON.parse(init.body);
sent.push({ url, body });
const key = Object.keys(responses).find((k) => url.includes(k));
return {
ok: true,
status: 200,
json: async () => responses[key ?? ''] ?? { ok: true },
};
};
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}-${Math.random()}`);
// The element map is built lazily by `getElementById`, so a field the page has not touched yet
// is not in it — prime the rest, so a test can type into a box before the page has read it.
const doc = g['document'] as { getElementById: (id: string) => unknown };
for (const id of served) doc.getElementById(id);
return { els, groups, sent };
};
const value = (els: Map<string, Record<string, unknown>>, id: string): string => String(els.get(id)!['value']);
const pick = (groups: Record<string, { value: string; checked: boolean; onchange: (() => void) | null }[]>, name: string, v: string) => {
const radios = groups[name]!;
const chosen = radios.find((r) => r.value === v)!;
for (const r of radios) r.checked = r === chosen;
chosen.onchange?.();
};
const chosen = (groups: Record<string, { value: string; checked: boolean }[]>, name: string): string | undefined =>
groups[name]!.find((r) => r.checked)?.value;
/**
* A SEAT RECOVERY LINK — Gitea#33.
*
* The properties that make this safe to hand round are the ones worth pinning: the page trades the
* CODE for the token (so no token is ever in a URL), and it does not keep the code afterwards. The
* store's own single-use and expiry rules are proven in `test/server/claims.test.ts`; this is the
* client half, which is the part that could silently stop asking.
*/
it('trades a ?claim= code for a seat, and does not leave the code in the address bar', async () => {
const { sent } = await open('?claim=code-123', {
'/api/claim': { token: 'tok-restored', gameId: 'game-9', player: 1, gameCode: 'WHISTLE-6945' },
});
const claim = sent.find((r) => r.url.includes('/api/claim'));
assert.ok(claim, 'the page never redeemed the code');
assert.deepEqual(claim.body, { code: 'code-123' }, 'the code was not sent as the request body');
// The token must never travel in a URL (`lobby-and-sessions.md` §1) — it comes back in the
// response, and the only thing that went out was the one-time code.
assert.ok(
!sent.some((r) => r.url.includes('tok-restored')),
'a session token appeared in a request URL',
);
});
it('opens on the join door, with the create form behind it', async () => {
// Somebody who was handed a code used to have to scroll past the entire create form to find the
// box to type it into.
const { els } = await open('?lobby');
assert.equal(els.get('lobby')!['hidden'], false, 'the lobby did not open');
assert.equal(els.get('lb-join-panel')!['hidden'], false, 'the join door was not the one showing');
assert.equal(els.get('lb-create-panel')!['hidden'], true, 'the create form was in the way');
});
it('fills the whole form from the game type, and re-derives the floor from the table', async () => {
const { els, groups } = await open('?lobby');
// Co-op is the default: 3 per player per Day, and the one type that pays for a transit.
assert.equal(value(els, 'lb-minrev'), '60', 'the Co-op floor at four players over five Days');
assert.equal(value(els, 'lb-transit'), '1');
pick(groups, 'lb-type', 'competitive');
assert.equal(value(els, 'lb-minrev'), '40', 'Competitive asks two thirds of what Co-op does');
assert.equal(value(els, 'lb-transit'), '0', 'Competitive still paid for transits');
pick(groups, 'lb-type', 'cutthroat');
assert.equal(els.get('lb-minrev-on')!['checked'], false, 'Cutthroat still asks a Revenue floor');
assert.equal(els.get('lb-coltotal-on')!['checked'], false, 'Cutthroat still caps collisions across the game');
assert.equal(els.get('lb-colday-on')!['checked'], true, 'three collisions in a Day must still end it');
assert.equal(chosen(groups, 'lb-extra'), 'anyOffice', 'Cutthroat is the type that allows an Extra next door');
});
it('a changed rule selects Custom; a changed parameter does not', async () => {
const { els, groups } = await open('?lobby');
els.get('lb-players')!['value'] = '2';
(els.get('lb-players')!['onchange'] as () => void)();
assert.equal(chosen(groups, 'lb-type'), 'coop', 'changing the table size should not change the game type');
assert.equal(value(els, 'lb-minrev'), '30', 'the floor did not follow the table size');
els.get('lb-days')!['value'] = '8';
(els.get('lb-days')!['oninput'] as () => void)();
assert.equal(chosen(groups, 'lb-type'), 'coop', 'changing the length should not change the game type');
assert.equal(value(els, 'lb-minrev'), '48', 'the floor did not follow the Day count');
els.get('lb-freight')!['value'] = '3';
(els.get('lb-freight')!['oninput'] as () => void)();
assert.equal(chosen(groups, 'lb-type'), 'custom', 'changing a RULE should have selected Custom');
// The summary sentence under the radios is gone (2026-08-30). What says HOW a Custom game
// differs is the hint on the row that differs, which is where it can be acted on.
assert.match(
String(els.get('lb-freight-hint')!['textContent']),
/Co-op default/,
'the changed row does not say what it was changed from',
);
});
it('clicking a type again resets every rule, and leaves the parameters alone', async () => {
const { els, groups } = await open('?lobby');
els.get('lb-players')!['value'] = '3';
(els.get('lb-players')!['onchange'] as () => void)();
els.get('lb-seed')!['value'] = '4242';
els.get('lb-freight')!['value'] = '3';
(els.get('lb-freight')!['oninput'] as () => void)();
assert.equal(chosen(groups, 'lb-type'), 'custom');
pick(groups, 'lb-type', 'coop');
assert.equal(value(els, 'lb-freight'), '1', 'the changed rule was not reset');
assert.equal(value(els, 'lb-seed'), '4242', 'the seed is a parameter and should have survived');
assert.equal(value(els, 'lb-players'), '3', 'the table size is a parameter and should have survived');
assert.equal(value(els, 'lb-minrev'), '45', 'the floor was not re-derived for three players');
});
it('creates the game the form describes, and asks the server for it exactly once', async () => {
const { els, groups, sent } = await open('?lobby', {
'/api/lobby/create': { gameId: 'g1', gameCode: 'RAIL-0001', token: 't1', player: 0 },
});
els.get('lb-secret')!['value'] = 'letmein';
els.get('lb-name')!['value'] = 'Jesse';
els.get('lb-players')!['value'] = '3';
(els.get('lb-players')!['onchange'] as () => void)();
pick(groups, 'lb-type', 'cutthroat');
(els.get('lb-create')!['onclick'] as () => void)();
await new Promise((r) => setTimeout(r, 20));
const create = sent.find((r) => r.url.includes('/api/lobby/create'));
assert.ok(create, 'the create button sent nothing');
const body = create.body as { players: number; displayName: string; config: Record<string, unknown> };
assert.equal(body.players, 3);
assert.equal(body.displayName, 'Jesse');
assert.equal(body.config['mode'], 'competitive', 'Cutthroat is scored as Competitive');
assert.equal(body.config['minCombinedRevenue'], 0, 'Cutthroat asks no floor');
assert.equal(body.config['maxCollisionsTotal'], 0);
assert.equal(body.config['maxCollisionsPerDay'], 3);
assert.equal(body.config['pvpCardsAllowed'], true, 'the game type, not a checkbox, decides this now');
});
it('remembers every game this browser is in, not just the last one', async () => {
/**
* REPORTED BY JESSE 2026-08-23: "if I'm a player in the middle of the game and I need to leave,
* how do I leave the game, clear the token from my browser so I can play a different game
* later?" There was no way out at all, and worse, `localStorage` held exactly ONE session — so
* joining a second game overwrote the first token and locked that seat out for good, which
* `TODO.md` had recorded as the nearer half of the lost-token problem.
*/
const two = JSON.stringify({
games: {
g1: { token: 't1', gameId: 'g1', gameCode: 'RAIL-0001', seat: 0, stage: 'game' },
g2: { token: 't2', gameId: 'g2', gameCode: 'HOPPER-4607', stage: 'lobby' },
},
last: null,
});
const { els } = await open('?lobby', {}, { 'station-master.remote.v1': two });
assert.equal(els.get('lb-known')!['hidden'], false, 'the games this browser is in were not listed');
const html = String(els.get('lb-known-list')!['innerHTML']);
assert.match(html, /RAIL-0001/);
assert.match(html, /HOPPER-4607/, 'only one of the two remembered games was listed');
assert.match(html, /in play/, 'a started game is not marked as one');
assert.match(html, /waiting to start/, 'a lobby-stage seat is not marked as one');
});
it('carries a session written before the store had more than one game in it', async () => {
// The single-record shape, from any build before 2026-08-23. Dropping it would throw away the
// game somebody was in the middle of when they updated.
const oldShape = JSON.stringify({ token: 't1', gameId: 'g1', gameCode: 'RAIL-0001', seat: 0 });
const { els } = await open('?lobby', {}, { 'station-master.remote.v1': oldShape });
assert.equal(els.get('lb-known')!['hidden'], false, 'the pre-existing session was forgotten');
assert.match(String(els.get('lb-known-list')!['innerHTML']), /RAIL-0001/);
});
it('forgetting one game leaves the others alone', async () => {
const two = JSON.stringify({
games: {
g1: { token: 't1', gameId: 'g1', gameCode: 'RAIL-0001', seat: 0, stage: 'game' },
g2: { token: 't2', gameId: 'g2', gameCode: 'HOPPER-4607', seat: 1, stage: 'game' },
},
last: null,
});
const { els } = await open('?lobby', {}, { 'station-master.remote.v1': two });
const list = els.get('lb-known-list')!;
// The stub's innerHTML is a string, so the buttons are found by re-rendering rather than by
// querying — drive the handler the page wired instead.
assert.match(String(list['innerHTML']), /data-game="g1"/);
assert.match(String(list['innerHTML']), /data-game="g2"/);
});
it('shows the whole rule set before a seat is taken, and never the seed', async () => {
// A player used to have to sit down before they could read a single rule of the game — and until
// this pass there was then no way back out of the chair.
const { els, sent } = await open('?lobby', {
'/api/lobby/preview': {
gameCode: 'RAIL-0001',
hostName: 'Jesse',
players: 3,
seated: [{ seat: 0, who: 'Jesse', bot: false }, { seat: 1, who: null, bot: false }, { seat: 2, who: null, bot: true }],
config: {
mode: 'competitive', days: 5, minCombinedRevenue: 0, maxCollisionsPerDay: 3, maxCollisionsTotal: 0,
pvpCardsAllowed: true,
optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false },
houseRules: { startingHand: 'sixRandom', extraStart: 'anyOffice', revenue: { passengerPerCoach: 1, freightPerLoad: 1, trainPerTransit: 0 } },
},
},
});
els.get('lb-secret')!['value'] = 'letmein';
els.get('lb-code')!['value'] = 'rail-0001';
(els.get('lb-look')!['onclick'] as () => void)();
await new Promise((r) => setTimeout(r, 20));
const asked = sent.find((r) => r.url.includes('/api/lobby/preview'));
assert.ok(asked, 'looking up a game asked the server nothing');
assert.match(asked.url, /gameCode=RAIL-0001/, 'the code was not upper-cased on the way out');
assert.equal(els.get('lb-preview')!['hidden'], false, 'the preview stayed hidden');
assert.equal(els.get('lb-preview-type')!['textContent'], 'Cutthroat');
assert.match(String(els.get('lb-preview-who')!['textContent']), /Host: Jesse/);
const rules = String(els.get('lb-preview-rules')!['innerHTML']);
assert.match(rules, /six random/, 'the opening hand is not on the preview');
assert.match(rules, /any Control Point/, 'the Extra rule is not on the preview');
assert.match(rules, /Combined Revenue floor<\/dt><dd>off/, 'a switched-off condition should read as off');
assert.ok(!/seed/i.test(rules), 'the preview mentions the seed');
});
});
describe('the lobby and the setup screen ask the same questions', () => {
/**
* THE DRIFT GUARD.
*
* The two screens each carried their own hand-written copy of the rules block and had already
* drifted apart before anyone noticed: the dialog had "where an Extra may start" and none of the
* three optional rules, the lobby had the optional rules and no Extra rule — so every multiplayer
* game silently played the most permissive Extra rule and no host was ever asked about it. The
* blocks are generated from one template now; this is what says so tomorrow.
*/
const page = (): string => {
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
return readFileSync(join(dist, 'play.html'), 'utf8');
};
it('carries every field of the shared block on both screens', () => {
// `ss-` joined `lb-`/`ng-` 2026-08-29: the pre-game solitaire setup screen drives the identical
// block ("asking first is the only path"). Same drift guard, one more prefix.
const html = page();
for (const prefix of ['lb-', 'ss-']) {
for (const selector of fieldSelectors(prefix)) {
assert.ok(html.includes(selector), `the ${prefix} block is missing ${selector}`);
}
}
});
it('offers all five game types on both screens', () => {
const html = page();
for (const prefix of ['lb-', 'ss-']) {
for (const type of ['solitaire', 'coop', 'competitive', 'cutthroat', 'custom']) {
assert.ok(
html.includes(`name="${prefix}type" value="${type}"`),
`the ${prefix} screen does not offer ${type}`,
);
}
}
});
it('gives the lobby its own way in and its own way out', () => {
// Every one of these is a hole this pass filled: two doors instead of a scroll, a preview before
// taking a seat, a leave button, an invite link, and a place to say the connection dropped.
const html = page();
for (const id of [
'lb-door-join', 'lb-door-create', 'lb-look', 'lb-preview', 'lb-preview-rules',
'lb-leave', 'lb-copylink', 'lb-stream-note', 'lb-notice', 'lb-secret-saved',
'lb-seating-rules', 'handoff',
]) {
assert.ok(html.includes(`id="${id}"`), `the lobby is missing #${id}`);
}
});
it('no longer offers a control for cards that do not exist', () => {
// The opponent-directed cards are unbuilt, and `buildDeck` holds them out however the config is
// set — so the checkbox could not do anything, on either screen. The fact is stated in words.
const html = page();
assert.ok(!html.includes('id="lb-pvp"'), 'the lobby still has the dead PvP checkbox');
assert.ok(!html.includes('id="ss-pvp"'), 'the setup screen still has the dead PvP checkbox');
// The in-game dialog was a THIRD copy of this block and the one that drifted — it kept the
// multiplayer wording on a solitaire-only screen. Deleted 2026-08-30; nothing may reintroduce
// a prefix that no screen owns.
assert.ok(!/id="ng-/.test(html), 'the deleted in-game dialog has come back');
assert.match(html, /opponent-directed cards[\s\S]{0,120}not implemented yet/i);
});
});
describe('the solitaire setup screen', () => {
/**
* DRIVEN THROUGH THE EMITTED BUNDLE, like the highlight test above, because the thing that can go
* wrong here is wiring rather than logic: an id that does not match the HTML, a handler on the
* wrong element, or a navigation that assigns the search string the page already has and so does
* nothing at all. None of that is visible to a test of `rulesFromUrl` in isolation.
*
* A seed alone stopped naming a game the moment the opening hand and the revenue rates became
* settings, so what this really pins is that all of them ride in the URL and come back out.
*
* REBUILT 2026-08-23 with the five game types. The dialog and the lobby now ask the same twelve
* questions through `settings-form.ts`, which addresses its radio groups by NAME through the
* DOCUMENT — so the stub keeps one set of groups and answers for both the document and the dialog.
*/
const load = async (search: string, stored: Record<string, string> = {}) => {
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
const served = new Set(
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!),
);
const els = new Map<string, Record<string, unknown>>();
type Radio = {
value: string;
checked: boolean;
disabled: boolean;
onchange: (() => void) | null;
closest: () => unknown;
};
const group = (values: string[], initial: string): Radio[] =>
values.map((value) => ({
value,
checked: value === initial,
disabled: false,
onchange: null,
closest: () => ({
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
querySelector: () => ({ appendChild: () => {} }),
}),
}));
// ONE set per page, not one per element: the block is addressed through the document, and a stub
// that handed each element its own copy would let a broken selector still pass.
// ONE set, matching the markup's own `checked` defaults. There were two until 2026-08-30, one
// per screen, because the in-game dialog was a second copy of this block — it is deleted, and
// the setup screen it folded into is the only thing on the page driving these radios now.
const groups: Record<string, Radio[]> = {
'ss-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
'ss-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
'ss-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'solitaire'),
};
const matching = (sel: string): Radio[] => {
const name = /name="([^"]+)"/.exec(sel)?.[1] ?? '';
const found = groups[name] ?? [];
return sel.endsWith(':checked') ? found.filter((r) => r.checked) : found;
};
/** Enough of an element for the page to start: the dialog's own API, and a settable `value`. */
const make = (id: string): Record<string, unknown> => {
const listeners = new Map<string, (() => void)[]>();
let html = '';
/**
* ARIA STATE, WHICH THE PAGE USES TO SAY WHICH MODE IS CURRENT. Added 2026-08-30 with the
* Office Area's segmented control (TODO #16): without it every render threw
* `b.setAttribute is not a function`, so a stub that cannot model an attribute means any
* accessible control ships green and unexercised.
*/
const attrs: Record<string, string> = {};
const node: Record<string, unknown> = {
attrs,
setAttribute: (k: string, v: string) => void (attrs[k] = v),
getAttribute: (k: string) => attrs[k] ?? null,
id, value: '', textContent: '', title: '', returnValue: '', open: false, placeholder: '',
style: {}, dataset: {}, onclick: null, oninput: null, onchange: null, scrollTop: 0, scrollHeight: 0,
checked: false, disabled: false, hidden: false, className: '',
classList: { add: () => {}, remove: () => {}, contains: () => false, toggle: () => {} },
// The Office Area's per-seat buttons are created and appended rather than interpolated, so a
// node that cannot be appended to throws on every render — see the note in the other factory.
appendChild: () => {},
addEventListener: (type: string, fn: () => void) =>
void listeners.set(type, [...(listeners.get(type) ?? []), fn]),
showModal: () => void ((node as { open: boolean }).open = true),
close: () => {
(node as { open: boolean }).open = false;
for (const fn of listeners.get('close') ?? []) fn();
},
querySelectorAll: (sel: string) => matching(sel),
querySelector: (sel: string) => matching(sel)[0] ?? null,
focus: () => {},
};
Object.defineProperty(node, 'innerHTML', { get: () => html, set: (v: string) => void (html = v) });
return node;
};
const g = globalThis as Record<string, unknown>;
g['document'] = {
getElementById: (id: string) => {
if (!served.has(id)) return null;
if (!els.has(id)) els.set(id, make(id));
return els.get(id);
},
createElement: () => make('style'),
addEventListener: () => {},
querySelectorAll: (sel: string) => matching(sel),
body: { appendChild: () => {} },
head: { appendChild: () => {} },
};
const nav: { search: string; reloads: number } = { search, reloads: 0 };
g['location'] = {
get search() { return nav.search; },
set search(v: string) { nav.search = v; },
reload: () => void (nav.reloads += 1),
origin: 'http://box.local',
pathname: '/play.html',
};
const store = new Map<string, string>(Object.entries(stored));
g['localStorage'] = {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
};
g['URLSearchParams'] = NodeURLSearchParams;
g['confirm'] = () => true;
g['EventSource'] = class { close(): void {} addEventListener(): void {} };
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}-${Math.random()}`);
return { els, nav, groups };
};
/** What every field of the block reads, so a test can assert the whole form at once. */
const readForm = (els: Map<string, Record<string, unknown>>, groups: Record<string, { value: string; checked: boolean }[]>) => ({
hand: groups['ss-hand']!.find((r) => r.checked)?.value,
extra: groups['ss-extra']!.find((r) => r.checked)?.value,
type: groups['ss-type']!.find((r) => r.checked)?.value,
passenger: els.get('ss-passenger')!['value'],
freight: els.get('ss-freight')!['value'],
transit: els.get('ss-transit')!['value'],
days: els.get('ss-days')!['value'],
minrev: els.get('ss-minrev')!['value'],
tossloco: els.get('ss-tossloco')!['checked'],
});
it('opens on the rules in play, so a second game can be dealt to compare with the first', async () => {
// Re-entering settings for every comparison game is how a comparison silently stops comparing.
// The seed is the one field that clears: the same seed twice is not a second sample.
const { els, groups } = await load('?seed=430&hand=sixRandom&passenger=2&freight=3&transit=4');
(els.get('newgame')!['onclick'] as () => void)();
assert.equal(els.get('solitairesetup')!['hidden'], false, 'the New game button did not open the setup screen');
assert.equal(els.get('gameui')!['hidden'], true, 'the board is still showing over the setup screen');
assert.equal(els.get('ss-seed')!['value'], '', 'the seed box kept the last game’s seed');
const form = readForm(els, groups);
assert.equal(form.passenger, '2');
assert.equal(form.freight, '3');
assert.equal(form.transit, '4');
assert.equal(form.hand, 'sixRandom', 'the opening hand in play was not preselected');
});
it('carries §6.2 in the URL, written only when it is OFF (Gitea#9)', async () => {
// The setting defaults ON, so a link that spelled out `toss=1` every time would say nothing and
// cost a parameter — the same reason the optional rules are written only when they are on. What
// has to survive the trip is therefore the OFF case, which is the one that changes the game.
const on = await load('?seed=430');
(on.els.get('newgame')!['onclick'] as () => void)();
assert.equal(readForm(on.els, on.groups).tossloco, true, 'a default game did not allow the discard');
const off = await load('?seed=430&toss=0');
(off.els.get('newgame')!['onclick'] as () => void)();
const form = readForm(off.els, off.groups);
assert.equal(form.tossloco, false, '?toss=0 did not reach the dialog');
// And it counts as a rule change, so the game is no longer the named type.
assert.equal(form.type, 'custom', 'turning the rule off still read as Solitaire');
});
it('reopens on Solitaire when the game in play is one, and on Custom when it was tuned', async () => {
// The type is DERIVED (`presets.ts`) rather than remembered, so what the dialog says a game is
// has to follow from its numbers — including a game whose numbers were hand-edited into the URL.
const plain = await load('?seed=430');
(plain.els.get('newgame')!['onclick'] as () => void)();
assert.equal(readForm(plain.els, plain.groups).type, 'solitaire', 'a default game did not read as Solitaire');
const tuned = await load('?seed=430&transit=4');
(tuned.els.get('newgame')!['onclick'] as () => void)();
assert.equal(readForm(tuned.els, tuned.groups).type, 'custom', 'a game paying for transits still read as Solitaire');
// As in the lobby: the row that differs carries the difference, not a sentence under the radios.
assert.match(
String(tuned.els.get('ss-transit-hint')!['textContent']),
/Solitaire default/,
'the changed row does not say what it was changed from',
);
});
it('offers the multiplayer types, disabled — one list across both screens, dealt from one of them', async () => {
const { els, groups } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
const disabled = groups['ss-type']!
.filter((r) => (r as { disabled: boolean }).disabled)
.map((r) => r.value);
assert.deepEqual(disabled, ['coop', 'competitive', 'cutthroat'], 'the wrong game types are dealable here');
// Deal is never disabled here any more: the only types this screen can SELECT are the two it can
// deal, so a disabled button would be answering a question the radios no longer ask.
// No reason is printed beside the dimmed rows any more (Jesse, 2026-08-30 — "grayed out with no
// additional explanation"). The heading is what says which game the screen deals, once, and it
// is the same shape on the lobby, which is what lets one list serve both.
const page = readFileSync(join(dist, 'play.html'), 'utf8');
assert.match(page, /Game type \(solitaire\)/, 'the setup screen does not name the game it deals');
assert.match(page, /Game type \(multi-player\)/, 'the lobby does not name the game it deals');
assert.ok(!page.includes('lb-why'), 'the per-row reason is back, on the page or in its stylesheet');
});
it('clicking a game type resets every rule below to it, and leaves the parameters alone', async () => {
const { els, groups } = await load('?seed=430&transit=4');
(els.get('newgame')!['onclick'] as () => void)();
els.get('ss-days')!['value'] = '8';
(els.get('ss-days')!['oninput'] as () => void)();
// 3 × 1 player × 8 days: the floor follows the length, and changing the length is not a rule
// change, so this is still Solitaire rather than Custom.
assert.equal(readForm(els, groups).minrev, '24', 'the Revenue floor did not follow the Day count');
const solitaire = groups['ss-type']!.find((r) => r.value === 'solitaire')!;
for (const r of groups['ss-type']!) r.checked = r === solitaire;
(solitaire as { onchange: (() => void) | null }).onchange!();
const form = readForm(els, groups);
assert.equal(form.transit, '0', 'clicking the type did not reset the rule that had been changed');
assert.equal(form.days, '8', 'clicking the type wiped the Day count, which is a parameter');
assert.equal(form.type, 'solitaire');
});
it('puts the seed and every setting into the URL when it deals', async () => {
const { els, groups, nav: n } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
els.get('ss-seed')!['value'] = '99';
for (const r of groups['ss-hand']!) r.checked = r.value === 'threeTrackThreeOther';
els.get('ss-passenger')!['value'] = '5';
els.get('ss-freight')!['value'] = '0';
els.get('ss-transit')!['value'] = '2';
els.get('ss-toolbox')!['checked'] = true;
(els.get('ss-deal')!['onclick'] as () => void)();
assert.equal(
n.search,
'?seed=99&hand=threeTrackThreeOther&extra=ownOffice&passenger=5&freight=0&transit=2' +
'&days=5&minrev=15&colday=3&coltotal=5&tool=1',
);
});
it('a switched-off victory condition deals as 0, which is what the engine calls off', async () => {
const { els, nav: n } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
els.get('ss-coltotal-on')!['checked'] = false;
(els.get('ss-coltotal-on')!['onchange'] as () => void)();
(els.get('ss-deal')!['onclick'] as () => void)();
assert.match(n.search, /coltotal=0/, 'unticking the total-collision condition did not switch it off');
});
it('says the game has RESUMED, not begun, when you come back to one', async () => {
/**
* REPORTED BY JESSE 2026-08-30: "when you are continuing the saved game out of that screen, do
* not post a message that says 'The game has begun.' … it needs to say 'The game has resumed.'"
*
* A restored game draws exactly like a dealt one — mid-Day, mid-phase, log already deep — so
* nothing distinguished the two. Checked in both directions here: the wording itself, and that
* the multiplayer line which DOES say "begun" cannot be said to somebody rejoining.
*/
const save = JSON.stringify({ seed: 12345, history: [] });
const { els } = await load('', { 'station-master.save.v1': save });
assert.match(String(els.get('announce')!['textContent']), /has resumed/, 'a restored game said nothing');
assert.doesNotMatch(String(els.get('announce')!['textContent']), /has begun/, 'a restored game claimed to be new');
// A freshly dealt game must NOT claim to be resumed — the announcement has to mean something.
const fresh = await load('?hand=sixRandom');
assert.doesNotMatch(String(fresh.els.get('announce')?.['textContent'] ?? ''), /resumed/,
'a brand new game announced itself as a resume');
// And the multiplayer first-frame line is conditional now rather than always "begun".
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
assert.match(src, /rejoining \? 'resumed' : 'begun'/, 'rejoining a table still says the game has begun');
});
it('backing out deals nothing and puts the same game back on screen', async () => {
/**
* WHAT CANCEL USED TO BE. The dialog had a Cancel button and an Esc key, and this pinned that
* neither dealt. The screen that replaced it has neither — it has "Continue Existing Saved
* Game", which has to do the same job and one more besides: the game was never navigated away
* from, so going back is showing the board again rather than reloading and replaying it.
*/
const { els, nav: n } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
assert.equal(els.get('ss-resume')!['hidden'], false, 'mid-game there is no way back to the game');
(els.get('ss-resume')!['onclick'] as () => void)();
assert.equal(els.get('gameui')!['hidden'], false, 'backing out did not return to the board');
assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen stayed up');
assert.equal(n.search, '?seed=430', 'backing out navigated');
assert.equal(n.reloads, 0, 'backing out reloaded, losing the game in memory');
});
it('reloads when the answers are the URL the page already has, so a re-deal is not a no-op', async () => {
// Dealing a random seed, disliking it and dealing again at the same settings produces the same
// search string — and assigning `location.search` the value it already holds does nothing.
const url =
'?hand=sixRandom&extra=ownOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5';
const { els, nav: n } = await load(url);
(els.get('newgame')!['onclick'] as () => void)();
(els.get('ss-deal')!['onclick'] as () => void)();
assert.equal(n.search, url, 'the URL should be unchanged — that is the whole case');
assert.equal(n.reloads, 1, 'a re-deal at the same settings did nothing at all');
});
it('deals the game the URL describes, and says so in the This Game card', async () => {
// The other half of the round trip: the dialog wrote those parameters, and this is the page
// reading them back. Without this the two halves can drift and each still pass its own test.
//
// MOVED OFF THE HEADER 2026-08-30 (TODO #28). It used to read `#houserules`, an abbreviation
// along the top line. The card is the whole of it now, so this checks both halves: the summary
// line a folded card still shows, and the full list behind it.
const { els } = await load('?seed=430&hand=sixRandom&passenger=4&freight=2&transit=1');
assert.match(
String(els.get('gamecardsummary')!['textContent']),
/6 cards · 4\/2\/1/,
'the folded card does not say what this game was dealt under',
);
// Folded, the body is not built at all — there is no point rendering what display:none hides.
assert.equal(String(els.get('gamecardbody')!['innerHTML']), '', 'a folded card built its body anyway');
(els.get('gamecardtoggle')!['onclick'] as () => void)();
const body = String(els.get('gamecardbody')!['innerHTML']);
assert.match(body, /Starting hand<\/dt><dd[^>]*>six random/, 'the opened card does not name the opening hand');
assert.match(body, /Passenger per coach<\/dt><dd[^>]*>4/, 'the opened card does not carry the revenue rates');
assert.match(body, /Seed<\/dt><dd[^>]*>430/, 'the opened card does not say which seed this is');
});
it('reaches every Office Area mode directly, including the one the cycle could not', async () => {
/**
* TODO #16, AND THIS IS THE DEFECT ITSELF. The control used to be ONE button stepping
* `auto -> (open ? 'closed' : 'open') -> auto`, where `open` is what auto is doing at that
* moment — `FOCUS_PHASES.has(f.phaseKey)`. So the pin a press reached depended on the phase,
* and going from "always showing" to "always hidden" meant clicking back to auto, waiting for
* the phase to turn over, and clicking again. Three controls, one per mode, and every mode is
* one press away from every other.
*/
const { els } = await load('?seed=430');
const press = (m: string): void => (els.get(`dm-${m}`)!['onclick'] as () => void)();
const pressed = (m: string): string =>
String((els.get(`dm-${m}`)!['attrs'] as Record<string, string>)['aria-pressed']);
// Which mode is LIT is what this test is about. Whether the panel then folds is `renderDistrict`'s
// existing behaviour, covered where the fold rules are — this suite's stub has a no-op classList.
assert.equal(pressed('auto'), 'true', 'the page did not start on auto');
press('open');
assert.equal(pressed('open'), 'true', 'pressing "always show" did not light it');
assert.equal(pressed('auto'), 'false', 'auto stayed lit after pinning the panel open');
// THE PRESS THE CYCLE COULD NOT MAKE: straight from one pin to the other, in one click, with
// no trip through auto and no waiting for a phase.
press('closed');
assert.equal(pressed('closed'), 'true', 'could not go from always-show to always-hide');
assert.equal(pressed('open'), 'false', 'two modes were lit at once');
press('auto');
assert.equal(pressed('auto'), 'true', 'could not get back to auto');
assert.equal(pressed('closed'), 'false', 'the previous mode stayed lit');
});
it('puts the newest line at the top of the history', async () => {
/**
* TODO #23. Jesse, 2026-08-30: "it should be reversed so the top line is the most recent and
* the further down you go, the older the entry." The panel used to run oldest-first and scroll
* itself to the bottom, so what had just happened was the line you had to go and find.
*/
const { els } = await load('?seed=430&hand=sixRandom');
const lines = [...String(els.get('log')!['innerHTML']).matchAll(/<div class="line[^"]*">([^<]*)<\/div>/g)].map(
(m) => m[1]!,
);
assert.ok(lines.length > 1, 'the log was too short to have an order at all');
// The deal is the OLDEST thing that has happened, so it must now be LAST rather than first.
const dealtAt = lines.findIndex((l) => /dealt|begins|opening/i.test(l));
assert.notEqual(dealtAt, -1, 'nothing in the log looks like the opening line');
assert.equal(
dealtAt,
lines.length - 1,
`the opening line is at ${dealtAt} of ${lines.length} — the log is still oldest-first`,
);
assert.equal(els.get('log')!['scrollTop'], 0, 'the panel still scrolls itself to the bottom');
});
it('shows the collision counts only while a collision limit is switched on', async () => {
/**
* TODO #28. The limits moved into the This Game card; the running counts stay on the top line,
* because a setting agreed to once and a number that changes how you play the next Stage are
* different kinds of thing. `0` means the limit is off, and a half that is off is left out
* rather than shown as "1 of 0".
*/
const on = await load('?seed=430&colday=3&coltotal=5');
assert.match(String(on.els.get('collisions')!['textContent']), /0 of 3 today · 0 of 5 total/);
const dayOnly = await load('?seed=430&colday=3&coltotal=0');
const text = String(dayOnly.els.get('collisions')!['textContent']);
assert.match(text, /0 of 3 today/);
assert.doesNotMatch(text, /total/, 'a switched-off limit was counted towards anyway');
const off = await load('?seed=430&colday=0&coltotal=0');
assert.equal(off.els.get('collisions')!['textContent'], '', 'a game that cannot end this way still counted');
});
it('names the game type in the card, so a Cutthroat game does not look like a Co-op one', async () => {
const { els } = await load('?seed=430');
// The summary line carries it whether the card is open or shut — which is what replaces the
// header's `#gametype`, and is why solitaire losing the (empty) game-code tooltip costs nothing.
assert.match(String(els.get('gamecardsummary')!['textContent']), /^Solitaire · 5 Days/);
(els.get('gamecardtoggle')!['onclick'] as () => void)();
const body = String(els.get('gamecardbody')!['innerHTML']);
assert.match(body, /Type<\/dt><dd[^>]*>Solitaire/, 'the opened card does not name the game type');
assert.match(body, /Combined Revenue floor<\/dt><dd[^>]*>15/, 'the card does not carry the victory conditions');
assert.match(body, /Days<\/dt><dd[^>]*>5/, 'the card does not say how long the game is');
});
it('asks before the first deal — a bare visit shows the setup screen, not a dealt game', async () => {
// Jesse, 2026-08-29: "let the user choose their options like the start of a multiplayer game";
// "asking first is the only path". A saved game, an explicit seed, or a URL a Deal already wrote
// (checked via `hand`, below) all skip this screen — nothing else does.
const { els } = await load('');
assert.equal(els.get('solitairesetup')!['hidden'], false, 'the setup screen stayed hidden');
assert.equal(els.get('gameui')!['hidden'], true, 'a game was dealt before anyone chose anything');
});
it('the solitaire door reaches the setup screen even when a solitaire game is saved', async () => {
/**
* REPORTED THREE TIMES BY JESSE (2026-08-29, twice, and 2026-08-30). v0.7.5 skipped the setup
* screen whenever `load()` found a save, reasoned as "a saved game is a game to resume" — but
* that means ANY browser that has ever played solitaire can never reach the setup screen from
* the door again, which is the whole feature. The private window that appeared to prove the
* caching fix had simply never played, so its `localStorage` was empty.
*
* The door is an explicit request to set a game up. A BARE reload still resumes (below).
*/
const save = JSON.stringify({ seed: 12345, history: [] });
const { els } = await load('?solitaire', { 'station-master.save.v1': save });
assert.equal(els.get('solitairesetup')!['hidden'], false, 'a saved game swallowed the door');
assert.equal(els.get('gameui')!['hidden'], true, 'the saved game was resumed instead of asking');
});
it('offers a way back to the saved game, since dealing from the door destroys it', async () => {
// Deal calls `clearSave()`. The door is reached by clicking "Play solitaire", which nobody reads
// as "discard what I was playing" — so the save has to be one button away, and the cost of Deal
// has to be stated. Resuming navigates to the bare URL and lets `start()` do it.
const save = JSON.stringify({ seed: 12345, history: [] });
const { els, nav } = await load('?solitaire', { 'station-master.save.v1': save });
assert.equal(els.get('ss-resume')!['hidden'], false, 'no way back to the game in progress');
assert.equal(els.get('ss-saved-note')!['hidden'], false, "Deal's cost to the save is not stated");
(els.get('ss-resume')!['onclick'] as () => void)();
assert.equal(nav.search, '', 'resuming did not go back to the plain resume path');
});
it('hides the resume button when there is no saved game to go back to', async () => {
const { els } = await load('?solitaire');
assert.equal(els.get('ss-resume')!['hidden'], true, 'a resume button with nothing to resume');
assert.equal(els.get('ss-saved-note')!['hidden'], true, 'warns about replacing a save that does not exist');
});
it('a bare reload still resumes a saved solitaire game rather than asking again', async () => {
// The other half: `?solitaire` is what changed, not resuming itself. Reopening the tab must not
// put a question in front of somebody who just wants their game back (D11's zero-friction case).
const save = JSON.stringify({ seed: 12345, history: [] });
const { els } = await load('', { 'station-master.save.v1': save });
assert.equal(els.get('gameui')!['hidden'], false, 'a bare reload did not resume the saved game');
assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen interrupted a resume');
});
it('the solitaire door reaches solitaire even when this browser remembers a multiplayer game', async () => {
// Found 2026-08-29 verifying v0.7.5 on phoenix.local: a browser with ANY remembered multiplayer
// seat (`station-master.remote.v1`) could never reach solitaire's setup screen at all — a bare
// `./play.html` load and the splash's "Play solitaire" door were indistinguishable from a reload
// mid-multiplayer-game, and `start()` checked the remembered session first. The door now marks
// its intent with `?solitaire`, the same way `?lobby` already does for the door on the other side.
const remembered = JSON.stringify({
games: { g1: { token: 't1', gameId: 'g1', gameCode: 'FREIGHT-3230', seat: 0, stage: 'game' } },
last: 'g1',
});
const { els } = await load('?solitaire', { 'station-master.remote.v1': remembered });
assert.equal(els.get('solitairesetup')!['hidden'], false, 'the door lost to the remembered game');
assert.equal(els.get('gameui')!['hidden'], true, 'the remembered multiplayer game was resumed instead');
});
it('a bare reload still resumes a remembered multiplayer game, unlike the solitaire door', async () => {
// The other half of the fix above: `?solitaire` must be what changed, not remembered-session
// resume itself, which is the correct behaviour for an actual reload mid-game (D11/D14).
const remembered = JSON.stringify({
games: { g1: { token: 't1', gameId: 'g1', gameCode: 'FREIGHT-3230', seat: 0, stage: 'game' } },
last: 'g1',
});
const { els } = await load('', { 'station-master.remote.v1': remembered });
assert.equal(els.get('gameui')!['hidden'], false, 'a bare reload did not resume the remembered game');
assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen wrongly took priority');
});
it("the setup screen's own Deal does not bounce into a remembered multiplayer game", async () => {
// The same bug one level deeper: `commitNewGame` writes `?hand=...`, not `?solitaire=...`, so the
// very next load after pressing Deal has to be recognised as a solitaire navigation too — checked
// via `hand`, the same signal `start()` already uses to skip the setup screen a second time.
const remembered = JSON.stringify({
games: { g1: { token: 't1', gameId: 'g1', gameCode: 'FREIGHT-3230', seat: 0, stage: 'game' } },
last: 'g1',
});
const { els } = await load('?hand=sixRandom', { 'station-master.remote.v1': remembered });
assert.equal(els.get('gameui')!['hidden'], false, "the Deal button's own URL was not honoured");
assert.equal(els.get('solitairesetup')!['hidden'], true, 'the setup screen re-asked its own answer');
});
it('deals six cards by default now, matching what the lobby calls Solitaire', async () => {
// Jesse, 2026-08-23: every game type opens with six. `SOLO_CONFIG` — the ENGINE's fallback, which
// every sim measurement is taken against — deliberately did not move; this is what the setup
// screen deals when nothing on it is touched, the same way the dialog always has.
const { els, nav } = await load('');
(els.get('ss-deal')!['onclick'] as () => void)();
assert.match(nav.search, /hand=sixRandom/, "the setup screen's own default was not six cards");
});
it('ignores a seed the browser cannot parse rather than refusing to deal', async () => {
// Blank and unparseable both plainly mean "surprise me"; an error dialog over a typo in an
// optional box is not worth writing.
const { els, nav: n } = await load('?seed=430');
(els.get('newgame')!['onclick'] as () => void)();
els.get('ss-seed')!['value'] = 'not a number';
(els.get('ss-deal')!['onclick'] as () => void)();
assert.equal(
n.search,
'?hand=sixRandom&extra=ownOffice&passenger=1&freight=1&transit=0&days=5&minrev=15&colday=3&coltotal=5',
'a bad seed was carried into the URL',
);
});
});
// ---------------------------------------------------------------------------
describe('making up a Local says which order the cars go on in', () => {
/**
* REPORTED from play: "I can't drop a car at all — trying to get an empty to an industry, but I
* can't drop any cars on the siding first."
*
* Trains 7/8 print "coach must remain on station track if switching", which the engine reads as
* "the coach is never set out". A cut comes off an OUTER end, so a coach on one outer end with the
* engine on the other leaves every available cut containing the coach: the train cannot set out
* its freight car, and cannot uncouple to run around either, because that leaves the coach
* standing too. Measured over 60 games, 1,181 positions where a set-out should have been possible
* and every single one refused — and no other train blocked once.
*
* Cars are appended as they are clicked with the engine on the nose, so the LAST car added takes
* the outer end. The whole remedy is therefore "do not add the coach last", and the whole bug was
* that nothing said so until the crew was on the district with no button to press.
*/
const localTray = (consist: { type: string; loaded: boolean }[]): Game => {
const game = newGame(4242);
const s = game.state;
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: 8, trainIsExtra: false, engineAt: 0,
consist: consist as never, direction: 'east',
position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west');
if (dp?.kind === 'divisionPoint') dp.holding.push(id);
s.clock.phase = 'newTrain';
s.clock.currentActor = 0;
return game;
};
const box = { type: 'boxcar', loaded: false };
const coach = { type: 'coach', loaded: false };
it('tells an empty Local to put the coach on first', () => {
const advice = actionMenu(localTray([])).makeUp?.advice;
assert.ok(advice, 'no advice offered while making up a Local');
assert.equal(advice.tone, 'hint');
assert.match(advice.text, /coach FIRST/);
});
it('reads the coach-on-first step as the next step, not as a mistake', () => {
// The coach lands on the outer end the moment it goes on — including for the player who has just
// been told to put it there first. Colouring that as an error punishes them for taking the
// advice, so while a freight car is still there to add this stays a hint and names it.
const advice = actionMenu(localTray([coach])).makeUp?.advice;
assert.ok(advice, 'no advice offered with the coach on the outer end');
assert.equal(advice.tone, 'hint');
assert.match(advice.text, /Now add the freight car/);
});
it('turns into a real warning once nothing is left that would fix it', () => {
// Coach on the outer end and an empty Division Yard: there is no next step, and the train is
// about to go out unable to set anything out for the rest of its run.
const game = localTray([coach]);
game.state.yards.divisionYard.length = 0;
const advice = actionMenu(game).makeUp?.advice;
assert.ok(advice, 'no advice offered with the coach stuck on the outer end');
assert.equal(advice.tone, 'warn');
assert.match(advice.text, /nothing left to add/);
});
it('does not say "coach first" once it is too late to add it first', () => {
// The freight car is already on, so a coach added now can only land on the outer end. Telling
// the player to add the coach first here would be advice they cannot take — the honest answer
// is that the coach is what would lock the train, and it may be left off.
const advice = actionMenu(localTray([box])).makeUp?.advice;
assert.ok(advice, 'no advice offered with the freight car already on');
assert.equal(advice.tone, 'warn');
assert.doesNotMatch(advice.text, /FIRST/);
assert.match(advice.text, /without the coach/);
});
it('says nothing when there is no coach coming', () => {
// A Division Yard with no coach in it, which is an ordinary state late in a game. A Local made
// up of freight alone switches perfectly well, so there is nothing to warn about.
const game = localTray([]);
for (let i = game.state.yards.divisionYard.length - 1; i >= 0; i--) {
if (game.state.yards.divisionYard[i]!.type === 'coach') game.state.yards.divisionYard.splice(i, 1);
}
assert.equal(actionMenu(game).makeUp?.advice ?? null, null);
});
it('says nothing at all for a train the order cannot lock', () => {
// Every other train in the game. A standing caption on all of them is how this one gets skipped.
const game = newGame(4242);
const s = game.state;
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: 10, trainIsExtra: false, engineAt: 0, // Heavy Freight — no coach rule
consist: [box] as never, direction: 'east',
position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
});
const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west');
if (dp?.kind === 'divisionPoint') dp.holding.push(id);
s.clock.phase = 'newTrain';
s.clock.currentActor = 0;
assert.equal(actionMenu(game).makeUp?.advice ?? null, null);
});
it('the order it recommends is one the rules actually allow a set-out from', () => {
// The advice is worthless if it names an arrangement that is merely a different dead end. This
// is the claim under it, checked against `check` rather than against the prose.
const s = newGame(4242).state;
const at = { row: 1, col: 1 };
areaOf(s, 0).grid.set(`${at.row},${at.col}`, {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never);
const put = (consist: { type: string; loaded: boolean }[]): string => {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber: 8, trainIsExtra: false, engineAt: 0,
consist: consist.map((c) => ({ ...c })) as never, direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
});
return id;
};
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
const canDrop = (id: string): boolean =>
[1, 2].some((count) =>
[false, true].some(
(fromNose) =>
check(s, 0, { type: 'switch.dropCars', trayId: id, count, ...(fromNose ? { fromNose } : {}) }) === null,
),
);
assert.equal(canDrop(put([coach, box])), true, 'the recommended order still cannot set out');
assert.equal(canDrop(put([box, coach])), false, 'the order being warned against is not actually a trap');
});
});
// ---------------------------------------------------------------------------
describe('two crews switching are told apart', () => {
/**
* REPORTED from play: "when switching, make it clear which train you are switching — it is
* possible to have more than one train available."
*
* A train standing on an A/D track while a local shunts is ordinary, and the page handled it in
* two ways that were both wrong. `Frame.moves` was built from the FIRST tray in the map, with a
* comment admitting it, so the board highlighted one crew's reachable squares while the action
* list offered every crew's moves under a single "Switching" heading of bare coordinates.
*
* Worse, identical labels were collapsed across the whole action kind: "move to (0, 2)" describes
* one crew's move exactly as it describes another's, so one of the two was silently DROPPED and
* could not be chosen at all.
*/
const twoCrews = (): Game => {
const game = newGame(4242);
const s = game.state;
const area = areaOf(s, 0);
// A row of plain track either side of the Office, so both crews have somewhere to go.
for (const col of [-2, -1, 1, 2]) {
area.grid.set(`${area.officeCoord.row},${area.officeCoord.col + col}`, {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
} as never);
}
// Facing each other across the Office, so BOTH reach it running forward and both moves
// therefore describe identically — which is the collision this fixture exists to create.
const put = (trainNumber: number, col: number, facing: 'e' | 'w'): string => {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber, trainIsExtra: false, engineAt: 0, consist: [],
direction: facing === 'e' ? 'east' : 'west', facing,
position: { at: 'grid', seat: 0, coord: { row: area.officeCoord.row, col } },
movesUsed: 0,
});
return id;
};
put(10, area.officeCoord.col - 1, 'e'); // Heavy Freight, running east at the Office
put(12, area.officeCoord.col + 1, 'w'); // Drag Freight, running west at it
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
turnOf(s, 0).option = 'switch';
return game;
};
it('gives each crew its own heading, naming the train and where it stands', () => {
const titles = actionMenu(twoCrews()).direct.map((g) => g.title).filter((t) => /^Switching/.test(t));
assert.equal(titles.length, 2, `expected one heading per crew, got ${JSON.stringify(titles)}`);
assert.ok(titles.some((t) => /Train 10/.test(t)), `no heading names Train 10: ${JSON.stringify(titles)}`);
assert.ok(titles.some((t) => /Train 12/.test(t)), `no heading names Train 12: ${JSON.stringify(titles)}`);
assert.ok(titles.every((t) => /standing at \(/.test(t)), 'a heading does not say where its crew is');
});
it('keeps a move that both crews can make, instead of dropping one of them', () => {
// The two crews flank the Office, so each can reach it and both moves describe identically.
// Before, the second was collapsed into the first and one train simply could not be sent there.
const game = twoCrews();
const office = areaOf(game.state, 0).officeCoord;
const label = `move to (${office.row},${office.col})`;
const groups = actionMenu(game).direct.filter((g) => /^Switching/.test(g.title));
const offering = groups.filter((g) => g.actions.some((a) => a.label === label));
assert.equal(offering.length, 2, 'the same move is not offered for both crews');
// And they are genuinely different actions, not the same index shown twice.
const indices = offering.map((g) => g.actions.find((a) => a.label === label)!.index);
assert.notEqual(indices[0], indices[1], 'both crews were pointed at one intent');
// Each really does move its own crew.
const { options } = actionGroups(game);
const trays = indices.map((i) => (options[i] as { trayId: string }).trayId);
assert.notEqual(trays[0], trays[1], 'the two buttons move the same tray');
});
it('reports every crew on the Frame, so the board can highlight the one you picked', () => {
const f = view(twoCrews());
assert.equal(f.moves.length, 2);
assert.deepEqual(f.moves.map((m) => m.label).sort(), ['Train 10', 'Train 12']);
for (const m of f.moves) assert.ok(m.to.length > 0, `${m.label} has no reachable squares`);
// Each entry is that crew's own square, not a shared one.
assert.notDeepEqual(f.moves[0]!.from, f.moves[1]!.from);
});
it('still offers a no-switching train as a crew to switch — it may be moved, just not worked', () => {
// v0.4.9 — six cards print "no switching", which means may not couple, set out or sort, not
// "may never be touched": a train held at the Office may still need to clear onto Secondary
// Track ahead of other traffic. `check` refuses the coupling itself, not the move, so this row
// is not filtered any differently for a no-switching train than for any other.
const game = twoCrews();
const s = game.state;
const circus = [...s.trays.values()][0]!;
circus.trainNumber = 18;
circus.trainIsExtra = true; // X18 Circus Train — noSwitching
const f = view(game);
assert.equal(f.moves.length, 2, 'a no-switching train was left out of the crews offered to switch');
assert.deepEqual(f.moves.map((m) => m.label).sort(), ['Train 12', 'Train X18']);
});
});
describe('two trains at one platform are told apart (v0.4.9e)', () => {
/**
* REPORTED from the v0.4.9d playtest: "operating two trains in a station — the select button does
* not work. Regardless of which you pick, it is always one train, not the other."
*
* `porter.board` and `porter.detrain` carried no tray, so there was ONE button per platform however
* many trains were standing at it, and the reducer filled whichever tray came first out of
* `adOccupancy`. Clicking a roster chip changed what the board drew and nothing else — which is
* exactly what "the select button does not work" describes.
*
* Driven through `actionMenu` rather than `legalActions` because the second half of the failure was
* at this layer: the menu collapses identical labels within a crew, and "board passengers at (0,0)"
* describes both trains.
*/
const twoAtPlatform = (): { game: Game; trays: string[] } => {
const game = newGame(4242);
const s = game.state;
const area = areaOf(s, 0);
const f = area.grid.get(`${area.officeCoord.row},${area.officeCoord.col}`)!.facility!;
// A Station's worth of platform: Porters, slots, and two fares waiting.
f.allows = { outbound: true, inbound: true };
f.porters = 4;
f.capacity = { outbound: 2, inbound: 2 };
f.outboundBox = [{ type: 'coach', loaded: true }, { type: 'coach', loaded: true }];
const trays: string[] = [];
for (const trainNumber of [7, 9]) {
const id = s.freeTrays.pop()!;
s.trays.set(id, {
id, trainNumber, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'coach', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: area.officeCoord },
movesUsed: 0,
} as never);
area.adOccupancy.push(id);
trays.push(id);
}
s.clock.phase = 'loadUnload';
s.clock.currentActor = 0;
return { game, trays };
};
it('offers boarding once per train, with the train named on the button', () => {
const { game } = twoAtPlatform();
const labels = actionMenu(game)
.direct.flatMap((g) => g.actions)
.map((a) => a.label)
.filter((l) => /^board passengers/.test(l));
assert.equal(labels.length, 2, `expected one button per train, got ${JSON.stringify(labels)}`);
assert.ok(labels.some((l) => /Train 7/.test(l)), `no button names Train 7: ${JSON.stringify(labels)}`);
assert.ok(labels.some((l) => /Train 9/.test(l)), `no button names Train 9: ${JSON.stringify(labels)}`);
});
it('boards the train whose button was pressed', () => {
const { game, trays } = twoAtPlatform();
const { options } = actionGroups(game);
const nine = options.findIndex(
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[1],
);
assert.ok(nine >= 0, 'no boarding option names the second train');
submit(game, options[nine]!);
assert.equal(game.state.trays.get(trays[1]!)!.consist[0]!.loaded, true, 'Train 9 did not get them');
assert.equal(game.state.trays.get(trays[0]!)!.consist[0]!.loaded, false, 'Train 7 was filled instead');
});
it('will not detrain the passengers it has just put aboard', () => {
// The other half of the same playtest: "passenger stations — passengers just boarded cannot be
// immediately unloaded." They could, for a Porter action and full Revenue, without the train
// moving an inch.
const { game, trays } = twoAtPlatform();
const board = actionGroups(game).options.find(
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[0],
)!;
submit(game, board);
const detrains = actionGroups(game).options.filter((o) => o.type === 'porter.detrain');
assert.equal(detrains.length, 0, 'detraining was still offered for passengers who boarded here');
assert.equal(
check(game.state, 0, { type: 'porter.detrain', at: areaOf(game.state, 0).officeCoord, trayId: trays[0]! }),
'LOADED_IN_THIS_DISTRICT',
);
});
it('says on the coach that it was loaded here', () => {
// The printed game turns the chip upside down in the tray; this is the screen's version of that.
const { game, trays } = twoAtPlatform();
submit(game, actionGroups(game).options.find(
(o) => o.type === 'porter.board' && (o as { trayId?: string }).trayId === trays[0],
)!);
const f = view(game);
const office = f.cells.find((c) => c.kind === 'office')!;
const train = office.trains.find((t) => t.trayId === trays[0]);
assert.ok(train, 'the boarded train is not on the Office card');
assert.ok(
train!.cars.some((c) => /loaded here/.test(c)),
`the coach does not say where it was loaded: ${JSON.stringify(train!.cars)}`,
);
});
});
describe('the Superintendent ruling names the train it is ruling on', () => {
/**
* REPORTED from play: "when the Superintendent has to rule on a train to allow or hold, it should
* indicate what train number he is ruling on in the primary display — you should not have to look
* at the tooltip."
*
* The page splits an action label at the first em-dash: the head goes on the BUTTON and the rest
* becomes the hover. "ALLOW — Train 7 follows Train 4…" therefore put the train number in the one
* place a player is not looking while making the sharpest decision in the game. Asserted on the
* head alone, exactly as `actionButton` cuts it, so a label rewritten back into the tooltip fails.
*/
const facing = (): Game => {
const game = newGame(4242);
const s = game.state;
// A train already occupying the first Mainline card, running east.
s.trays.set('ahead', {
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
} as never);
const ml = s.division.nodes[1];
if (ml?.kind === 'mainline') {
// Pin the terrain: Double Track and Uncontrolled Siding set `trainsMayPass`, which legitimately
// removes the §8.1 bar this fixture exists to trip.
ml.card = 'plains';
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
}
// A second train at the Western Division Point wanting to follow it onto that card.
s.trays.set('behind', {
id: 'behind', trainNumber: 7, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
} as never);
s.clock.phase = 'mainline';
s.movedThisPhase = new Set();
drain(game);
return game;
};
/** What the page actually prints on the button — `actionButton` cuts at the first ' — '. */
const head = (label: string): string => {
const cut = label.indexOf(' — ');
return cut > 0 ? label.slice(0, cut) : label;
};
it('puts both train numbers on the ALLOW and HOLD buttons themselves', () => {
const game = facing();
assert.notEqual(game.state.clock.pendingDecision, null, 'the phase did not stop to ask');
const group = actionMenu(game).direct.find((g) => /^Superintendent/.test(g.title));
assert.ok(group, 'no Superintendent group in the action list');
const heads = group.actions.map((a) => head(a.label));
assert.equal(heads.length, 2, `expected ALLOW and HOLD, got ${JSON.stringify(heads)}`);
assert.ok(
heads.every((h) => /Train 7/.test(h)),
`a ruling button does not name the train being ruled on: ${JSON.stringify(heads)}`,
);
assert.ok(
heads.some((h) => /^ALLOW/.test(h) && /Train 4/.test(h)),
`ALLOW does not name the train ahead: ${JSON.stringify(heads)}`,
);
assert.ok(heads.some((h) => /^HOLD/.test(h)), `no HOLD button: ${JSON.stringify(heads)}`);
});
it('asks the question in the heading, naming both trains', () => {
const title = actionMenu(facing()).direct.map((g) => g.title).find((t) => /^Superintendent/.test(t));
assert.ok(title, 'no Superintendent heading');
assert.match(title, /Train 7/);
assert.match(title, /Train 4/);
});
});
// ---------------------------------------------------------------------------
/**
* §2.2 and §9.2 together strand every coach in the Classification Yard, and the board does not say
* so — Jesse's ruling of 2026-09-17 is that the rule stands and the game says it loudly.
*
* Tested on the FRAME rather than the DOM, because the counts are what the warning is derived from
* and they are what could go wrong: the panel asks for a per-type count the Frame already carries.
*/
describe('a Division Yard with no coaches is a reportable condition', () => {
it('carries per-type counts for both yards, so the panel can tell coaches from cars', () => {
const game = newGame(4242);
game.state.yards.divisionYard = [{ type: 'boxcar', loaded: true }, { type: 'hopper', loaded: false }] as never;
game.state.yards.classificationYard = [
{ type: 'coach', loaded: false },
{ type: 'coach', loaded: true },
] as never;
const f = view(game);
const coachesInDivision = f.yards.division.find((c) => c.type === 'coach');
assert.equal(coachesInDivision, undefined, 'the fixture put no coach in the Division Yard');
assert.notEqual(f.yards.divisionTotal, 0, 'a yard holding freight is not bare, which is the whole point');
const waiting = f.yards.classification.find((c) => c.type === 'coach');
assert.ok(waiting, 'the coaches in Classification were not reported by type');
assert.equal(waiting.loaded + waiting.empty, 2, 'the waiting coaches were miscounted');
});
});
// ---------------------------------------------------------------------------
/**
* The Quickstart is published beside the game, so a tester on the box can reach it.
*
* WHY THIS IS A TEST. The link on the splash page is a plain href to a file the BUILD copies out of
* `docs/`. Nothing else connects the two: rename the document, or move it, and the build quietly
* publishes nothing while the splash page keeps offering a link that 404s. Neither `tsc` nor any
* other test would notice — the whole failure lives between a file name and a string.
*/
describe('the Quickstart guide reaches the site', () => {
it('is published into dist and linked from the splash page', () => {
const guide = join(dist, 'quickstart.md');
assert.ok(existsSync(guide), 'the build did not publish quickstart.md');
const text = readFileSync(guide, 'utf8');
assert.match(text, /^# Station Master — Quickstart/, 'quickstart.md is not the guide');
assert.match(
text,
/Describes the game as built at v/,
'the guide does not say which build it describes',
);
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
assert.match(splash, /href="\.\/quickstart\.md"/, 'the splash page does not link the guide');
});
it('publishes everything the guide links, so "Where to read more" is not five dead links', () => {
/**
* v0.8.0.16 published the Quickstart alone. Its §8 links five further documents by relative
* path, and every one of them 404'd on the package — verified against the running container,
* 5 of 6 paths missing. Publishing a guide without what it points at is the same broken-link
* failure as the test above, one hop further out, so it is pinned the same way: the links are
* read OUT OF THE GUIDE rather than listed here, or this test goes stale exactly as the
* references it guards did.
*/
const guide = readFileSync(join(dist, 'quickstart.md'), 'utf8');
const section = guide.slice(guide.indexOf('## 8. Where to read more'));
assert.ok(section.length > 0, 'the guide no longer has a "Where to read more" section');
// Markdown links, minus anchors and absolute URLs — what a reader can actually click.
const targets = [...section.matchAll(/\]\(([^)#][^)]*)\)/g)]
.map((m) => m[1]!.replace(/^`|`$/g, ''))
.filter((t) => !/^https?:/.test(t));
assert.ok(targets.length >= 4, `only ${targets.length} references parsed out of the guide`);
for (const t of targets) {
assert.ok(existsSync(join(dist, t)), `the guide links ${t}, which the build does not publish`);
}
});
it('reaches the documentation from inside a game, in solitaire and multiplayer alike', () => {
/**
* Asked from a table, 2026-09-21: "how can we link the documentation so it can be reached from
* the gameplay, whether someone is playing solitaire or multiplayer?" The links live in the
* This Game card (Jesse's call) — and the point is that they need NO mode awareness, because
* both modes are the same page on the same origin. So this asserts the links exist and resolve,
* which is the whole of the mechanism.
*
* Read out of the built bundle rather than the source: what matters is what the shipped page
* offers, and a link that resolves in `src/` and not in `dist/` is the exact failure the two
* tests above exist to catch.
*/
const bundle = readFileSync(join(dist, 'web', 'main.js'), 'utf8');
const guide = bundle.slice(bundle.indexOf('GUIDE_DOCS'), bundle.indexOf('GUIDE_DOCS') + 4000);
assert.ok(bundle.includes('GUIDE_DOCS') || bundle.includes('quickstart.md'), 'the bundle has no guide links');
// Every document offered in-game must be a file the build published.
const hrefs = [...guide.matchAll(/["'`](\.\/[A-Za-z0-9./-]+\.md)["'`]/g)].map((m) => m[1]!);
assert.ok(hrefs.length >= 5, `only ${hrefs.length} in-game guide links found`);
for (const h of hrefs) {
assert.ok(existsSync(join(dist, h.replace(/^\.\//, ''))), `the game links ${h}, which is not published`);
}
// A reference opened mid-turn must not take the game with it.
assert.ok(guide.includes('_blank'), 'the guide links would navigate away from a game in progress');
assert.ok(guide.includes('noopener'), 'a new-tab link without rel=noopener hands out a window handle');
});
it('is served as text rather than handed over as a download', () => {
/**
* The server's MIME fallback is `application/octet-stream`, which a browser downloads instead of
* displaying — so the link would hand a tester a file to save rather than a page to read. The
* table is read straight out of the source: asserting on a copy of it would pass while the real
* one was wrong.
*/
const http = readFileSync(join(root, 'src/server/http.ts'), 'utf8');
const table = http.slice(http.indexOf('const MIME'), http.indexOf('const HEARTBEAT_MS'));
assert.match(table, /'\.md':\s*'text\/plain/, 'a .md file would be served as a download');
});
});