/** * 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 { 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 } from '../src/engine/content.ts'; import { facilitiesHtml, timetableHtml } from '../src/web/panels.ts'; import { record, renderHtml } from '../src/sim/replay.ts'; import { snapshot } from '../src/sim/view.ts'; import { createGame as createEngineGame } from '../src/engine/setup.ts'; import { overHandLimit, actionGroups, actionMenu, handPlayable, currentActor, fromSave, newGame, submit, toSave, undo, view, } from '../src/web/game.ts'; const root = join(import.meta.dirname, '..'); const dist = join(root, 'dist'); /** Play a whole game by always taking the first offered action. */ function playThrough(seed: number, maxTurns = 20_000) { const game = newGame(seed); let turns = 0; for (; turns < maxTurns; turns++) { if (currentActor(game) === null) break; const { options, groups } = actionGroups(game); if (groups.length === 0 || options.length === 0) break; const first = groups[0]!.actions[0]!; if (!submit(game, options[first.index]!)) break; } return { game, turns }; } /** * 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, 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`); 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(); 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: { length: 2, cars: [{ type: 'hopper', loaded: false }] }, laborers: 3, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, } as never); const filled = (): string[] => { const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!; const svg = officeSvg([cell], 0); return [...svg.matchAll(/class="bs-wb (bs-\w+)( bs-occ)?"/g)].map( (m) => m[1]!.replace('bs-', '') + (m[2] ? '=FULL' : ''), ); }; // 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: { length: 2, 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: { length: 0, 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: { length: 1, 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: { length: 2, cars: [] }, laborers: 2, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, } as never); const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!; assert.match(cell.what, /a load is on AT/, 'the card does not say where the work has got to'); }); it('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"[^>]*>(?: 48, `the caption at y=${y} prints over the rail`); }); 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: { length: 2, 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]*>L 1\/1 { // 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 { // `overpass` and `facingPointLocks` are placed `onCard`, so they can land on a facility. At its // old baseline the label printed straight through the green/MEN|AT|WORK/red row. const game = newGame(5); const area = game.state.officeAreas.get(0)!; area.grid.set('-1,0', { geometry: { kind: 'facility', facility: 'mineTipple' }, baseOperationalRail: true, standing: [], modifiers: [], enhancements: ['overpass'], facility: { kind: 'freight', subtype: 'mineTipple', allows: { outbound: true, inbound: false }, outboundBox: [], inboundBox: [], capacity: { outbound: 1, inbound: 0 }, menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] }, laborers: 2, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, } as never); const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!; const svg = officeSvg([cell], 0); const enhY = Number(/class="bs-enh" x="\d+" y="(\d+)"/.exec(svg)?.[1]); const boxTop = Math.min( ...[...svg.matchAll(/class="bs-wb [^"]*" x="\d+" y="(\d+)"/g)].map((m) => Number(m[1])), ); assert.ok(Number.isFinite(enhY) && Number.isFinite(boxTop), 'label or squares are not drawn'); // 9px text sits roughly 9px above its baseline, so the baseline itself must clear the squares. assert.ok(enhY <= boxTop, `enhancement label at y=${enhY} prints over the squares at y=${boxTop}`); }); }); describe('the board draws the printed card (docs/tracks.png)', () => { /** One card, alone, so the measurements are in card coordinates. */ const draw = (links: string[]): { x1: number; y1: number; x2: number; y2: number }[] => { const cell = { row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], tray: null, cars: [], facility: null, links, what: '', }; const svg = officeSvg([cell] as never, 0); return [...svg.matchAll(//g)] .map((m) => ({ x1: Number(m[1]), y1: Number(m[2]), x2: Number(m[3]), y2: Number(m[4]) })); }; /** Undirected segment angles in degrees, deduplicated. 0 is horizontal. */ const angles = (links: string[]): number[] => [ ...new Set( draw(links).map((r) => { const a = (Math.atan2(r.y2 - r.y1, r.x2 - r.x1) * 180) / Math.PI; return Math.round((((a % 180) + 180) % 180) * 10) / 10; }), ), ].sort((a, b) => a - b); it('runs the through track dead centre, edge to edge', () => { // The measurement from the printed sheet: on every one of its nine rows the east-west rail sits // at the card's exact vertical middle. It used to be drawn at 31% down the card, on the belief // that the art put it along the top edge. const rails = draw(['ew']); assert.equal(rails.length, 2, 'a track is two rails'); const H = 96; for (const r of rails) { assert.equal(r.y1, r.y2, 'the through track must be level'); assert.ok(Math.abs(r.y1 - H / 2) <= 3, `through rail at y=${r.y1}, not the card's middle (${H / 2})`); assert.deepEqual([Math.min(r.x1, r.x2), Math.max(r.x1, r.x2)], [0, 166], 'it must span the whole card'); } }); it('leaves at exactly 45°, through the middle of the north or south edge', () => { for (const links of [['ne'], ['nw'], ['se'], ['sw'], ['ew', 'ws'], ['ew', 'en']]) { const found = angles(links); assert.ok(found.includes(0), `${links} has no level run along the centre line`); assert.ok( found.includes(45) || found.includes(135), `${links} draws its leg at ${found.join('/')}° — the card prints 45°`, ); assert.equal(found.length, 2, `${links} draws ${found.join('/')}° — a leg is a run and one 45° angle`); } }); it('draws the two diagonals as two different diagonals', () => { // The matching rule made visible. `ne` and `sw` lie on one line and `nw` and `se` on the other, // so a stacked pair on the same diagonal reads as one unbroken rail and a mismatched pair reads // as the V it is. Drawn alike, the picture would claim a join the engine refuses. assert.deepEqual(angles(['ne']), angles(['sw']), 'ne and sw must lie on the same diagonal'); assert.deepEqual(angles(['nw']), angles(['se']), 'nw and se must lie on the same diagonal'); assert.notDeepEqual(angles(['ne']), angles(['nw']), 'the two diagonals must be distinguishable'); }); it('meets the card edge at its midpoint, so abutting cards line up', () => { // A leg that met the edge anywhere else would join to nothing, however right its angle. const W = 166; const H = 96; // Only the sloping segments, and only the end of each that lands on the south edge. const atEdge = draw(['sw']) .filter((r) => r.y1 !== r.y2) .map((r) => (Math.abs(r.y1 - H) < Math.abs(r.y2 - H) ? r.x1 : r.x2)); assert.equal(atEdge.length, 2, 'the leg is two rails'); const mid = (atEdge[0]! + atEdge[1]!) / 2; assert.ok(Math.abs(mid - W / 2) < 0.5, `the leg crosses the south edge at x=${mid}, not the middle (${W / 2})`); }); }); describe('board highlighting', () => { it('gives every spot a coordinate to highlight, 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.row}, ${sp.coord.col})`), 'label and coord disagree'); } } }); it('highlights squares OUTSIDE the current district', () => { // The commonest placement is just beyond the existing cards — that is what extending means. A // grid sized only to the cards already down would highlight nothing exactly when it matters. const game = newGame(555); submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!); /** * Open the district first, with a TURNOUT on the Running Track. * * Every office and industry card is a plain east-west straight and the Office carries no stub, * so on the opening board the only legal squares are the two Limits signs — on the Running Track * itself, inside the existing bounds, and this test would prove nothing. A turnout is what puts * a square OFF the main in reach. The lay flag is then cleared rather than playing out a second * turn: the subject here is the drawn canvas, not the turn economy. */ const { 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. const game = newGame(111); for (let i = 0; i < 400; i++) { if (currentActor(game) === null) break; const { options } = actionGroups(game); if (options.length === 0) break; const pick = options.find((o) => o.type === 'card.play' && o.placement) ?? options[0]!; if (!submit(game, pick)) break; } const cells = view(game).cells; assert.ok(cells.length > 4, 'not enough of the board was built to be a real check'); for (const c of cells) { assert.ok(c.what.length > 0, `(${c.row},${c.col}) ${c.label} has no explanation`); // camelCase on the board is the failure that keeps recurring — labels AND descriptions. assert.doesNotMatch(c.label, /[a-z][A-Z]/, `raw camelCase label: ${c.label}`); assert.doesNotMatch(c.what, /[a-z][A-Z]/, `raw camelCase in "${c.label}": ${c.what}`); } }); it('spells out which way a turnout will and will not let a train run', () => { // §A.1 is an ABSENT edge, not a one-way street, and it is invisible on a card that just says // "turnout". Getting it wrong is how a crew ends up somewhere it cannot leave. const game = newGame(3); const area = game.state.officeAreas.get(0)!; area.grid.set('-1,0', { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], }); const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!; // 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(); 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 = { 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: { length: 2, cars: [] }, laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 }, }, }; area.grid.set(`${area.runningRow},2`, industry as never); area.grid.set(`${area.runningRow - 1},0`, JSON.parse(JSON.stringify(industry)) as never); const cells = view(game).cells; const onRunning = cells.find((c) => c.row === area.runningRow && c.col === 2)!; const below = cells.find((c) => c.row === area.runningRow - 1 && c.col === 0)!; assert.match(onRunning.what, /RUNNING TRACK/, 'no warning on a Running Track industry'); assert.match(onRunning.what, /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 = { train: 'tray2', occupiedBy: 'tray3' }; const allow = describeIntent(s, { type: 'mainline.clearance', allow: true }); const hold = describeIntent(s, { type: 'mainline.clearance', allow: false }); for (const label of [allow, hold]) { assert.match(label, /Train 4/, `the ruling does not name the train: ${label}`); assert.doesNotMatch(label, /tray\d/, `internal id on a button: ${label}`); } // Deliberately NOT asserting that granting clearance mentions a collision: no rear-end is // implemented, and promising one would describe a consequence the engine never delivers. assert.match(allow, /same Mainline card/, 'granting clearance does not say what happens'); assert.match(hold, /losing the Stage/, 'holding does not mention the cost'); }); it('states the objective and whether you are keeping up', () => { const f = view(newGame(430)); assert.equal(f.objective.target, 20); assert.equal(f.objective.days, 5); assert.match(f.objective.note, /of 20/); assert.match(f.objective.note, /Days? left/); }); it('explains what each Local Operations option spends', () => { // The most consequential decision of the Stage, previously labelled "choose switch". const game = newGame(555); const choices = actionGroups(game) .options.filter((o) => o.type === 'localOps.choose') .map((o) => describeIntent(game.state, o)); assert.ok(choices.length > 0); for (const c of choices) { assert.ok(c.length > 25, `too terse to be an explanation: "${c}"`); assert.match(c, /—/, `no explanation after the option name: "${c}"`); } }); it('says which way a piece will point, never "rotation 2"', () => { const game = newGame(555); submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!); const track = actionMenu(game) .placeable.flatMap((g) => g.items) .filter((i) => /straight|curve|turnout/.test(i.subject)); assert.ok(track.length > 0, 'no track pieces offered'); for (const item of track) { for (const sp of item.spots) { assert.doesNotMatch(sp.label, /rotation \d/, `opaque rotation label: ${sp.label}`); } // A piece with more than one orientation must say which is which, or the spots are ambiguous. const perSquare = new Map(); for (const sp of item.spots) { 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; assert.equal(typeof save['seed'], 'number', `${entry.file} has no seed`); assert.ok(Array.isArray(save['history']), `${entry.file} has no history`); } }); }); describe('the static build', () => { it('builds, and needs nothing but a static host', () => { execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' }); assert.ok(existsSync(join(dist, 'index.html')), 'no index.html'); assert.ok(existsSync(join(dist, 'web/main.js')), 'no entry script'); assert.ok(existsSync(join(dist, 'engine/apply.js')), 'the engine did not emit'); }); it('emits no import that a browser cannot resolve', () => { // Node's type-stripping lets the source import './x.ts'; a browser cannot. The build rewrites // those to .js, and nothing may reach for a bare module or a node: builtin. const files: string[] = []; const walk = (dir: string): void => { for (const e of readdirSync(dir, { withFileTypes: true })) { if (e.isDirectory()) walk(join(dir, e.name)); else if (e.name.endsWith('.js')) files.push(join(dir, e.name)); } }; walk(dist); assert.ok(files.length > 5, 'suspiciously few emitted files'); for (const f of files) { const src = readFileSync(f, 'utf8'); // Anchor to real import/export statements. A loose `from '...'` also matches PROSE — the // turnout comment "a train entering a turnout from \"A\"" was read as importing a module // called A, which is the kind of false alarm that trains you to ignore a failing test. const imports = /^\s*(?:import|export)[^'"\n]*?from\s+['"]([^'"]+)['"]/gm; for (const m of src.matchAll(imports)) { const spec = m[1]!.split('?')[0]!; assert.ok(!spec.endsWith('.ts'), `${f} imports a .ts path: ${spec}`); assert.ok(!spec.startsWith('node:'), `${f} imports a Node builtin: ${spec}`); assert.ok(spec.startsWith('.') || spec.startsWith('/'), `${f} imports bare module: ${spec}`); } assert.ok(!/^[^/*]*\bprocess\.\w/m.test(src), `${f} references process`); assert.ok(!/\brequire\(/.test(src), `${f} uses require()`); } }); it('renders clickable highlights on the board when a card is picked', async () => { // The real check: load the EMITTED bundle with a DOM stub, click a card, and confirm the board // came back with highlighted squares wired to handlers. Asserting the data has coordinates says // nothing about whether the page draws them. const els = new Map>(); const injected: Record[] = []; const make = (): Record => { let html = ''; // Cache per selector and clear it when innerHTML changes. Returning fresh objects each call // would drop the handlers the page assigns — the test would then report "no button" for a // page that works perfectly. let cache = new Map[]>(); const found = new Map>(); // Every element carries a classList. The page folds the district panel by toggling a class on // it, and a stub without one throws on load — which is a page that never starts, not a // cosmetic gap. const ownClasses = new Set(); // The New Game dialog is a native : 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 void)[]>(); const node: Record = { 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(); found.set(key, { onclick: null, classList: { add: (c: string) => void classes.add(c), has: (c: string) => classes.has(c) }, classes, }); } return found.get(key); }, highlighted: () => [...found.values()].filter((f) => (f['classes'] as Set).size > 0), querySelectorAll(sel: string) { const hit = cache.get(sel); if (hit) return hit; const out: Record[] = []; const re = sel.startsWith('[') ? /data-at="([^"]*)"/g : new RegExp(`