Found by Jesse playing v0.7.5 on phoenix.local: a browser that had ever held a multiplayer seat could not reach the new solitaire setup screen at all. start() checked a browser-remembered multiplayer session before ever looking at solitaire's own state, and a bare ./play.html load could not tell "clicked Play solitaire" apart from "reloaded mid multiplayer game" — the same problem ?lobby already solved for the door on the other side, never applied to this one. The door now links to ./play.html?solitaire, and start() treats that, an explicit ?seed=, or the setup screen's own ?hand= (written by every Deal) as proof this navigation means solitaire — checked ahead of the remembered-session lookup rather than only below it. 862 tests pass, three new. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
4638 lines
236 KiB
TypeScript
4638 lines
236 KiB
TypeScript
/**
|
||
* 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 { ENHANCEMENT_RULES, STAGES_PER_DAY } from '../src/engine/content.ts';
|
||
import { dayEndHtml, facilitiesHtml, 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 {
|
||
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('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('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)[]>();
|
||
const node: Record<string, unknown> = {
|
||
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>();
|
||
const node: Record<string, unknown> = {
|
||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||
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)),
|
||
},
|
||
// 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> => {
|
||
const node: Record<string, unknown> = {
|
||
textContent: '', style: {}, dataset: {}, onclick: null, disabled: false,
|
||
title: '', returnValue: '', open: false,
|
||
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 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 left" — 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.
|
||
*/
|
||
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 left/,
|
||
'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('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('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}`);
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
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('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('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('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('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'),
|
||
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||
'ng-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 = '';
|
||
const node: Record<string, unknown> = {
|
||
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,
|
||
};
|
||
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;
|
||
|
||
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');
|
||
assert.match(String(els.get('lb-type-note')!['textContent']), /scored as Co-op/);
|
||
assert.match(String(els.get('lb-type-note')!['textContent']), /1 setting differs from Co-op/);
|
||
});
|
||
|
||
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 dialog 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 all three 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-', 'ng-', '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 all three screens', () => {
|
||
const html = page();
|
||
for (const prefix of ['lb-', 'ng-', '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="ng-pvp"'), 'the dialog still has the dead PvP checkbox');
|
||
assert.ok(!html.includes('id="lb-pvp"'), 'the lobby still has the dead PvP checkbox');
|
||
assert.match(html, /opponent-directed cards[\s\S]{0,120}not implemented yet/i);
|
||
});
|
||
});
|
||
|
||
describe('the New Game dialog', () => {
|
||
/**
|
||
* 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.
|
||
const groups: Record<string, Radio[]> = {
|
||
'ng-hand': group(['threeRandom', 'sixRandom', 'threeTrackThreeOther'], 'sixRandom'),
|
||
'ng-extra': group(['divisionPointsOnly', 'ownOffice', 'anyOffice'], 'anyOffice'),
|
||
'ng-type': group(['solitaire', 'coop', 'competitive', 'cutthroat', 'custom'], 'coop'),
|
||
// The pre-game setup screen (Gitea, "asking first is the only path", 2026-08-29) drives the
|
||
// same shared block under the `ss-` prefix — one set here too, matching the markup's own
|
||
// `checked` defaults rather than the dialog's (Solitaire, not Co-op: there is no live game to
|
||
// reopen on, so the static default IS the Solitaire default).
|
||
'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 = '';
|
||
const node: Record<string, unknown> = {
|
||
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: () => {} },
|
||
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['ng-hand']!.find((r) => r.checked)?.value,
|
||
extra: groups['ng-extra']!.find((r) => r.checked)?.value,
|
||
type: groups['ng-type']!.find((r) => r.checked)?.value,
|
||
passenger: els.get('ng-passenger')!['value'],
|
||
freight: els.get('ng-freight')!['value'],
|
||
transit: els.get('ng-transit')!['value'],
|
||
days: els.get('ng-days')!['value'],
|
||
minrev: els.get('ng-minrev')!['value'],
|
||
tossloco: els.get('ng-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)();
|
||
|
||
const dlg = els.get('newgamedlg')!;
|
||
assert.equal(dlg['open'], true, 'the New game button did not open the dialog');
|
||
assert.equal(els.get('ng-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');
|
||
assert.match(
|
||
String(tuned.els.get('ng-type-note')!['textContent']),
|
||
/1 setting differs from Solitaire/,
|
||
'the note did not say what differs',
|
||
);
|
||
});
|
||
|
||
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['ng-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.
|
||
assert.ok(
|
||
readFileSync(join(dist, 'play.html'), 'utf8').includes('id="ng-multiplayer-note"'),
|
||
'nothing on the dialog says where a multiplayer game comes from',
|
||
);
|
||
});
|
||
|
||
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('ng-days')!['value'] = '8';
|
||
(els.get('ng-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['ng-type']!.find((r) => r.value === 'solitaire')!;
|
||
for (const r of groups['ng-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)();
|
||
|
||
const dlg = els.get('newgamedlg')!;
|
||
els.get('ng-seed')!['value'] = '99';
|
||
for (const r of groups['ng-hand']!) r.checked = r.value === 'threeTrackThreeOther';
|
||
els.get('ng-passenger')!['value'] = '5';
|
||
els.get('ng-freight')!['value'] = '0';
|
||
els.get('ng-transit')!['value'] = '2';
|
||
els.get('ng-toolbox')!['checked'] = true;
|
||
dlg['returnValue'] = 'deal';
|
||
(dlg['close'] as () => void)();
|
||
|
||
assert.equal(
|
||
n.search,
|
||
'?seed=99&hand=threeTrackThreeOther&extra=anyOffice&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)();
|
||
const dlg = els.get('newgamedlg')!;
|
||
els.get('ng-coltotal-on')!['checked'] = false;
|
||
(els.get('ng-coltotal-on')!['onchange'] as () => void)();
|
||
dlg['returnValue'] = 'deal';
|
||
(dlg['close'] as () => void)();
|
||
assert.match(n.search, /coltotal=0/, 'unticking the total-collision condition did not switch it off');
|
||
});
|
||
|
||
it('deals nothing on cancel, and nothing on Esc', async () => {
|
||
// Esc closes a <dialog> with an empty returnValue and fires no submit at all, so "not deal" has
|
||
// to be the test rather than "cancel" — the two arrive identically.
|
||
for (const returnValue of ['cancel', '']) {
|
||
const { els, nav: n } = await load('?seed=430');
|
||
(els.get('newgame')!['onclick'] as () => void)();
|
||
const dlg = els.get('newgamedlg')!;
|
||
dlg['returnValue'] = returnValue;
|
||
(dlg['close'] as () => void)();
|
||
assert.equal(n.search, '?seed=430', `closing with "${returnValue}" navigated`);
|
||
assert.equal(n.reloads, 0, `closing with "${returnValue}" reloaded`);
|
||
}
|
||
});
|
||
|
||
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=anyOffice&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)();
|
||
const dlg = els.get('newgamedlg')!;
|
||
dlg['returnValue'] = 'deal';
|
||
(dlg['close'] 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 header', 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.
|
||
const { els } = await load('?seed=430&hand=sixRandom&passenger=4&freight=2&transit=1');
|
||
assert.match(String(els.get('houserules')!['textContent']), /6 cards.*4\/2\/1/);
|
||
assert.match(String(els.get('houserules')!['title']), /six random cards/i);
|
||
});
|
||
|
||
it('names the game type in the header, so a Cutthroat game does not look like a Co-op one', async () => {
|
||
const { els } = await load('?seed=430');
|
||
assert.equal(els.get('gametype')!['textContent'], 'Solitaire');
|
||
assert.match(String(els.get('gametype')!['title']), /Days: 5/, 'the tooltip does not carry the victory conditions');
|
||
});
|
||
|
||
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 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)();
|
||
const dlg = els.get('newgamedlg')!;
|
||
els.get('ng-seed')!['value'] = 'not a number';
|
||
dlg['returnValue'] = 'deal';
|
||
(dlg['close'] as () => void)();
|
||
|
||
assert.equal(
|
||
n.search,
|
||
'?hand=sixRandom&extra=anyOffice&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/);
|
||
});
|
||
});
|