/** * The playable browser build. * * These tests drive `game.ts` exactly as the page does — take the offered actions, submit one, look * at the new position — so "it compiles" is never mistaken for "it plays". */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { cardDescription, describeIntent } from '../src/sim/view.ts'; import { actionGroups, actionMenu, handPlayable, currentActor, fromSave, newGame, submit, toSave, view, } from '../src/web/game.ts'; /** Play a whole game by always taking the first offered action. */ function playThrough(seed: number, maxTurns = 20_000) { const game = newGame(seed); let turns = 0; for (; turns < maxTurns; turns++) { if (currentActor(game) === null) break; const { options, groups } = actionGroups(game); if (groups.length === 0 || options.length === 0) break; const first = groups[0]!.actions[0]!; if (!submit(game, options[first.index]!)) break; } return { game, turns }; } describe('the browser game plays', () => { it('reaches the end of a game through the same calls the page makes', () => { const { game, turns } = playThrough(77); assert.ok(turns > 50, `only ${turns} decisions — the game stalled`); assert.equal(game.state.status, 'finished'); assert.ok(game.state.outcome !== null, 'a finished game must have an outcome'); }); it('never offers an action the engine then refuses', () => { // The action list comes from legalActions, which delegates to check — so every button on the // page must be accepted. A rejection here means the UI and the rules disagree. const game = newGame(21); for (let i = 0; i < 300; i++) { if (currentActor(game) === null) break; const { options, groups } = actionGroups(game); if (groups.length === 0) break; const pick = groups[groups.length - 1]!.actions[0]!; assert.ok(submit(game, options[pick.index]!), 'the engine refused an offered action'); } }); it('offers every legal action somewhere, dropping none', () => { // Grouping is presentation. If a kind is not named in GROUP_ORDER it must still appear, or a // legal move becomes unreachable in a way that is very hard to notice. const game = newGame(5); for (let i = 0; i < 120; i++) { if (currentActor(game) === null) break; const { options, groups } = actionGroups(game); if (options.length === 0) break; const shown = new Set(groups.flatMap((g) => g.actions.map((a) => a.index))); const kinds = new Set(options.map((o) => o.type)); const shownKinds = new Set([...shown].map((i2) => options[i2]!.type)); assert.deepEqual(shownKinds, kinds, 'a kind of legal action was not offered at all'); submit(game, options[[...shown][0]!]!); } }); it('restores a saved game to the same position', () => { // A save is the seed plus the intents; replaying them must reproduce the game exactly. That is // the payoff of event sourcing, and it is only true while the RNG stays seeded. const game = newGame(909); for (let i = 0; i < 80; i++) { if (currentActor(game) === null) break; const { options, groups } = actionGroups(game); if (groups.length === 0) break; submit(game, options[groups[0]!.actions[0]!.index]!); } const restored = fromSave(toSave(game)); assert.equal(restored.state.players[0]!.revenue, game.state.players[0]!.revenue); assert.equal(restored.state.clock.day, game.state.clock.day); assert.equal(restored.state.clock.stage, game.state.clock.stage); assert.equal(restored.state.status, game.state.status); assert.deepEqual(view(restored).cells, view(game).cells, 'the board differs after restore'); }); it('is deterministic — the same seed deals the same game', () => { const a = playThrough(4242).game; const b = playThrough(4242).game; assert.equal(a.state.players[0]!.revenue, b.state.players[0]!.revenue); assert.deepEqual(view(a).cells, view(b).cells); }); }); describe('the action menu presents choices the way they are made', () => { it('offers a card ONCE, with its locations underneath', () => { // A single turn offered 29 track buttons and 7 card buttons in one flat list, with no way to // tell which square each referred to. Subject first, location second. const game = newGame(555); submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!); const menu = actionMenu(game); const items = menu.placeable.flatMap((g) => g.items); assert.ok(items.length > 0, 'nothing placeable was offered'); const keys = items.map((i) => i.subjectKey); assert.equal(new Set(keys).size, keys.length, 'the same card appeared as two subjects'); for (const item of items) { const labels = item.spots.map((sp) => sp.label); assert.equal(new Set(labels).size, labels.length, `${item.subject} lists a spot twice`); assert.ok(item.spots.length > 0, `${item.subject} has nowhere to go but was offered`); } }); it('loses no legal action in the reshuffle', () => { // Splitting into direct + placeable must not drop anything: every option must still be reachable. const game = newGame(88); for (let i = 0; i < 120; i++) { if (currentActor(game) === null) break; const menu = actionMenu(game); if (menu.options.length === 0) break; const reachable = new Set([ ...menu.direct.flatMap((g) => g.actions.map((a) => a.index)), ...menu.placeable.flatMap((g) => g.items.flatMap((it) => it.spots.map((sp) => sp.index))), ]); const kinds = new Set(menu.options.map((o) => o.type)); const shown = new Set([...reachable].map((k) => menu.options[k]!.type)); assert.deepEqual(shown, kinds, 'a kind of legal action became unreachable'); submit(game, menu.options[[...reachable][0]!]!); } }); it('names the card in every face-up slot', () => { // "slot 2" is unusable information: the whole point of a face-up slot is choosing it on sight. const game = newGame(555); submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!); const draw = actionMenu(game).direct.find((g) => g.title === 'Draw'); assert.ok(draw, 'no draw group'); for (const a of draw!.actions) { assert.ok(!/^draw\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`); } assert.ok( draw!.actions.some((a) => /face-up slot/.test(a.label)), 'face-up slots are not named', ); }); it('never offers a train card a place on the board', () => { // A train card goes to the TIMETABLE. Seed 555 offered Extra X15 at six squares with six // rotations each — all identical, because the placement was accepted and then ignored. const game = newGame(555); for (let i = 0; i < 200; i++) { if (currentActor(game) === null) break; const { options } = actionGroups(game); if (options.length === 0) break; for (const o of options) { if (o.type !== 'card.play') continue; const kind = game.state.cards.get(o.cardId)?.kind.kind; if (kind === 'timetabledTrain' || kind === 'extraTrain') { assert.equal(o.placement, undefined, 'a train card was offered a board square'); } } submit(game, options[0]!); } }); it('reports what is left of the personal track supply', () => { // Track is not drawn from the deck — 26 pieces per player, at most one laid a turn — so the // board alone could never answer "how many straights have I got left". const game = newGame(31); const supply = view(game).trackSupply; assert.ok(supply.length > 0, 'no track supply reported'); assert.equal(supply.reduce((n, t) => n + t.left, 0), 26, 'the opening supply should be 26'); assert.ok(supply.every((t) => !/none/.test(t.piece)), 'un-handed pieces should not say "none"'); }); }); describe('board highlighting', () => { it('gives every spot a coordinate to highlight', () => { const game = newGame(555); submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!); const items = actionMenu(game).placeable.flatMap((g) => g.items); assert.ok(items.length > 0); for (const it of items) { for (const sp of it.spots) { assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`); assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`); assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree'); } } }); it('highlights squares OUTSIDE the current district', () => { // The commonest placement is just beyond the existing cards — that is what extending means. A // grid sized only to the cards already down would highlight nothing exactly when it matters. const game = newGame(555); submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!); const cells = view(game).cells; const minCol = Math.min(...cells.map((c) => c.col)); const maxCol = Math.max(...cells.map((c) => c.col)); const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((i) => i.spots); assert.ok( spots.some((sp) => sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0), 'no legal spot lies outside the current cards — the bounds test would be vacuous', ); }); }); describe('the page explains itself', () => { it('says what every card in hand and every face-up slot does', () => { // A hand of bare names is unplayable: "Steam Turbines" carries no hint of its effect, and all of // this text already existed in the content tables without reaching the screen. for (const seed of [555, 430, 7, 99]) { const f = view(newGame(seed)); assert.equal(f.handWhat.length, f.hand.length, 'a hand card has no description slot'); f.hand.forEach((name, i) => { const what = f.handWhat[i]!; assert.ok(what.length > 0, `${name} has no description`); // camelCase leaking to the screen is the recurring failure here. assert.doesNotMatch(what, /[a-z][A-Z]/, `raw camelCase in "${name}": ${what}`); assert.doesNotMatch(what, /playerChoicebound|undefined|NaN/, `broken text: ${what}`); }); f.departments.forEach((name, i) => { if (name === '—') return; assert.ok(f.departmentsWhat[i]!.length > 0, `face-up ${name} has no description`); }); } }); it('says what every card on the BOARD does, in readable words', () => { // A played card becomes a cell with a name on it — "turnout", "Freight House", "waiting area" — // and the explanation that was visible while it sat in hand disappears exactly when it starts // mattering. Play a long way in so every card kind reaches the grid. const game = newGame(111); for (let i = 0; i < 400; i++) { if (currentActor(game) === null) break; const { options } = actionGroups(game); if (options.length === 0) break; const pick = options.find((o) => o.type === 'card.play' && o.placement) ?? options.find((o) => o.type === 'track.lay') ?? options[0]!; if (!submit(game, pick)) break; } const cells = view(game).cells; assert.ok(cells.length > 4, 'not enough of the board was built to be a real check'); for (const c of cells) { assert.ok(c.what.length > 0, `(${c.row},${c.col}) ${c.label} has no explanation`); // camelCase on the board is the failure that keeps recurring — labels AND descriptions. assert.doesNotMatch(c.label, /[a-z][A-Z]/, `raw camelCase label: ${c.label}`); assert.doesNotMatch(c.what, /[a-z][A-Z]/, `raw camelCase in "${c.label}": ${c.what}`); } }); it('spells out which way a turnout will and will not let a train run', () => { // §A.1 is an ABSENT edge, not a one-way street, and it is invisible on a card that just says // "turnout". Getting it wrong is how a crew ends up somewhere it cannot leave. const game = newGame(3); const area = game.state.officeAreas.get(0)!; area.grid.set('-1,0', { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [], }); const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!; assert.match(cell.what, /stem east/); assert.match(cell.what, /diverges south/); assert.match(cell.what, /NEVER/, 'the missing edge is not spelled out'); }); it('describes every card kind in the deck, not just the easy ones', () => { // Walk the whole deck rather than one hand: the categories differ, and a missing branch would // show as a blank line under a card name only for the seeds that happen to deal it. const game = newGame(1); const seen = new Set(); for (const [id, card] of game.state.cards) { const what = cardDescription(game.state, id); assert.ok(what.length > 0, `${card.kind.kind} has no description`); seen.add(card.kind.kind); } assert.ok(seen.size >= 7, `only ${seen.size} card kinds seen — the deck should hold more`); }); it('marks cards that cannot be played yet, and says why', () => { // A Station upgrade drawn at a Whistle Post is dead weight — upgrades are strictly sequential // (Gap 3b) — but the hand showed it identically to a playable card, so taking it looked like an // action that did nothing. const game = newGame(111); submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!); submit(game, actionGroups(game).options.find((o) => o.type === 'draw.fromDepartment' && o.slot === 0)!); const f = view(game); const playable = handPlayable(game); assert.equal(playable.length, f.hand.length, 'a hand card has no playable flag'); const upgrades = f.hand .map((name, i) => ({ name, what: f.handWhat[i]!, can: playable[i]! })) .filter((c) => /upgrade/.test(c.name)); assert.ok(upgrades.length > 0, 'this seed should deal an Office upgrade'); for (const u of upgrades) { // Only Depot is reachable from a Whistle Post. if (/Depot/.test(u.name)) continue; assert.equal(u.can, false, `${u.name} should not be playable from a Whistle Post`); assert.match(u.what, /requires the Office to be a/, `${u.name} does not say what it needs`); } }); it('agrees with the buttons about what is playable', () => { // The flag is derived from legalActions, so it must never disagree with the offered actions. const game = newGame(7); for (let i = 0; i < 60; i++) { if (currentActor(game) === null) break; const hand = game.state.decks.hands.get(0) ?? []; const flags = handPlayable(game); const offered = new Set( actionGroups(game) .options.filter((o) => o.type === 'card.play') .map((o) => (o as { cardId: string }).cardId), ); hand.forEach((id, k) => { assert.equal(flags[k], offered.has(id), 'playable flag disagrees with the action list'); }); const { options } = actionGroups(game); if (options.length === 0) break; submit(game, options[0]!); } }); it('never puts an internal tray id 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, engineFront: true, consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0, }); s.trays.set('tray3', { id: 'tray3', trainNumber: 7, trainIsExtra: false, engineFront: true, 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}`); } assert.match(allow, /collision/, 'granting clearance does not mention the risk'); assert.match(hold, /safe|loses/, 'holding does not mention the cost'); }); it('states the objective and whether you are keeping up', () => { const f = view(newGame(430)); assert.equal(f.objective.target, 20); assert.equal(f.objective.days, 5); assert.match(f.objective.note, /of 20/); assert.match(f.objective.note, /Days? left/); }); it('explains what each Local Operations option spends', () => { // The most consequential decision of the Stage, previously labelled "choose switch". const game = newGame(555); const choices = actionGroups(game) .options.filter((o) => o.type === 'localOps.choose') .map((o) => describeIntent(game.state, o)); assert.ok(choices.length > 0); for (const c of choices) { assert.ok(c.length > 25, `too terse to be an explanation: "${c}"`); assert.match(c, /—/, `no explanation after the option name: "${c}"`); } }); it('says which way a piece will point, never "rotation 2"', () => { const game = newGame(555); submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!); const track = actionMenu(game) .placeable.flatMap((g) => g.items) .filter((i) => /straight|curve|turnout/.test(i.subject)); assert.ok(track.length > 0, 'no track pieces offered'); for (const item of track) { for (const sp of item.spots) { assert.doesNotMatch(sp.label, /rotation \d/, `opaque rotation label: ${sp.label}`); } // A piece with more than one orientation must say which is which, or the spots are ambiguous. const perSquare = new Map(); for (const sp of item.spots) { const k = `${sp.coord.row},${sp.coord.col}`; perSquare.set(k, (perSquare.get(k) ?? 0) + 1); } if ([...perSquare.values()].some((n) => n > 1)) { assert.ok( item.spots.some((sp) => /east|west|north|south/.test(sp.label)), `${item.subject} offers two orientations on one square without naming them`, ); } } }); }); describe('the static build', () => { const root = join(import.meta.dirname, '..'); const dist = join(root, 'dist'); 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 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 node: Record = { textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 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(`