/** * Component 16 — replay viewer. * * The important test here is narration coverage: adding an event type without narrating it should * fail the build rather than quietly degrading the replay into "something happened". */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { pump } from '../src/engine/advance.ts'; import { DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_TOTAL, collectiveRevenueFloor } from '../src/engine/content.ts'; import type { GameEvent } from '../src/engine/events.ts'; import { createGame } from '../src/engine/setup.ts'; import type { GameConfig } from '../src/engine/state.ts'; import { developerBot, playGame } from '../src/sim/bot.ts'; import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts'; import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts'; import { summarize } from '../src/sim/stats.ts'; // Mirrors `record()`'s own default exactly (`replay.ts`) — "does not drift from the engine" below // plays the same seed through both paths and compares outcomes, so they must share one floor. const config: GameConfig = { mode: 'solitaire', days: 5, minCombinedRevenue: collectiveRevenueFloor(1, 5), maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY, maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL, pvpCardsAllowed: false, optionalRules: { reducedVisibility: false, employeeRotation: false, emergencyToolbox: false, }, }; /** One representative instance of every event type the engine can emit. */ const SAMPLES: GameEvent[] = [ { type: 'stageBegan', day: 1, stage: 7 }, { type: 'phaseBegan', phase: 'mainline' }, { type: 'actorChanged', player: 0 }, { type: 'localOpsOptionChosen', player: 0, option: 'switch' }, { type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 }, { type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true }, { type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] }, { type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' }, { type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 }, { type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' }, { type: 'cardDiscarded', player: 0, cardId: 'c1', toSlot: 1 }, { type: 'deckReshuffled', order: ['c1', 'c2', 'c3', 'c4'], rngState: 7 }, { type: 'departmentRefilled', slot: 0, cardId: 'c2' }, { type: 'stockToOutbound', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } }, { type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } }, { type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } }, { type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 }, { type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false } }, { type: 'carPassed', player: 0, trayId: 't0' }, { type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' }, { type: 'clearanceGiven', trainId: 't1', allow: false }, { type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 } }, { type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 } }, { type: 'loadStarted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' }, { type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 }, { type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' }, { type: 'loadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' }, { type: 'unloadBegan', player: 0, at: { row: 1, col: 0 }, carType: 'hopper', carIndex: 0 }, { type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'freightLoad' }, { type: 'phaseEnded', player: 0, phase: 'localOps' }, ]; describe('narration', () => { it('covers every event type the engine can emit', () => { // Guards against a new event type slipping in unnarrated. const covered = new Set(SAMPLES.map((e) => e.type)); const declared = new Set(); for (const e of SAMPLES) declared.add(e.type); assert.equal(covered.size, 30, 'sample list is out of step with GameEvent'); assert.equal(declared.size, 30); }); it('gives every event a specific, non-empty sentence', () => { for (const e of SAMPLES) { const n = narrate(e); assert.ok(n.text.length > 3, `${e.type} produced no useful text`); assert.ok(!/^\[/.test(n.text), `${e.type} fell through to a fallback`); assert.ok(['plain', 'good', 'bad', 'clock', 'quiet', 'phase'].includes(n.tone), `${e.type} bad tone`); } }); it('marks revenue gains good and losses bad', () => { const gain = narrate({ type: 'revenueChanged', player: 0, delta: 1, total: 1, reason: 'boarding' }); const loss = narrate({ type: 'revenueChanged', player: 0, delta: -5, total: -5, reason: 'collision: no free A/D track', }); assert.equal(gain.tone, 'good'); assert.equal(loss.tone, 'bad'); assert.match(loss.text, /COLLISION/i); }); it('points at the board cell where something happened', () => { const n = narrate({ type: 'loadCompleted', player: 0, at: { row: 1, col: 2 }, carType: 'hopper' }); assert.deepEqual(n.where, { row: 1, col: 2 }); }); it('names phases in words rather than identifiers', () => { assert.equal(phaseLabel('loadUnload'), 'Cargo'); assert.equal(phaseLabel('newTrain'), 'New Train'); assert.equal(phaseLabel('shiftChange'), 'Supervisor Shift'); assert.equal(phaseLabel('localOps'), 'Local Operations'); assert.equal(phaseLabel('mainline'), 'Mainline'); }); it('hides only the events with nothing to show', () => { assert.equal(isVisible({ type: 'actorChanged', player: 0 }), false); assert.equal(isVisible({ type: 'loadCompleted', player: 0, at: { row: 0, col: 0 }, carType: 'hopper' }), true); }); }); describe('impediments', () => { it('reports nothing blocked on a fresh game', () => { const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] }); assert.deepEqual(impediments(s, 0), []); }); it('warns when every A/D track is occupied', () => { // Gap 2d — the next arrival is an automatic collision. const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] }); s.officeAreas.get(0)!.adOccupancy = ['t0']; const found = impediments(s, 0); assert.ok(found.some((b) => /A\/D/.test(b.why) && b.severity === 'risk')); }); it('reports a train held for want of a Crew Tray', () => { const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] }); s.timetable[s.clock.stage - 1] = 4; s.freeTrays = []; assert.ok(impediments(s, 0).some((b) => /HELD/.test(b.why))); }); }); describe('replay recording', () => { const rec = record(1234, 'standard'); it('produces frames', () => { assert.ok(rec.frames.length > 50, `only ${rec.frames.length} frames`); }); it('never runs time backwards', () => { let prev = 0; for (const f of rec.frames) { const t = f.day * 100 + f.stage; assert.ok(t >= prev, `time went backwards at Day ${f.day} Stage ${f.stage}`); prev = t; } }); it('always shows the Office card', () => { for (const f of rec.frames) { assert.ok(f.cells.some((c) => c.kind === 'office'), 'a frame lost the Office'); } }); it('does not drift from the engine', () => { // A replay that disagrees with the engine is worse than no replay. Replay the same seed // through the normal bot path and compare the end state. const s = createGame({ id: 'x', seed: 1234, config, playerNames: ['player'] }); const r = playGame(s, developerBot, pump); const stats = summarize(1234, r.events, r.intents, s); const last = rec.frames[rec.frames.length - 1]!; assert.equal(last.revenue, stats.revenue.net, 'final revenue disagrees with the engine'); assert.equal(last.day, s.clock.day, 'final Day disagrees with the engine'); assert.match(rec.outcome, new RegExp(stats.result)); }); it('narrates every frame', () => { for (const f of rec.frames) { assert.ok(f.lines.length > 0, 'a frame has no narration'); } }); }); describe('replay HTML', () => { const html = renderHtml(record(99, 'short')); it('is a complete standalone document', () => { assert.match(html, /^/i); assert.match(html, /<\/html>\s*$/); }); it('embeds no external resources', () => { // A strict requirement: the file must open anywhere, offline. const externals = html.match(/(?:src|href)\s*=\s*"(?!#)[^"]+"/g) ?? []; assert.deepEqual(externals, [], `external references: ${externals.join(', ')}`); }); it('embeds the frame data and the dictionaries it is packed against', () => { // The frames are packed now: a card's identity and its description are written once into // CARDS/WHATS and referenced by integer. A page with FRAMES but no dictionaries would draw an // empty board rather than fail, so all three are checked. assert.match(html, /const PACKED = \{/); assert.match(html, /FRAMES = PACKED\.frames/); assert.match(html, /CARDS = PACKED\.cards/); assert.match(html, /WHATS = PACKED\.whats/); // And the unpacking function must be emitted, not just referenced. assert.match(html, /const rehydrateCells = function/); }); it('offers the controls that make it usable', () => { for (const id of ['play', 'back', 'fwd', 'scrub', 'stage', 'money', 'crash', 'waste', 'jam', 'decision']) { assert.ok(html.includes(`id="${id}"`), `missing control: ${id}`); } }); it('emits a script that actually parses', () => { // THE GUARD THAT WAS MISSING. A stray apostrophe inside a single-quoted JS string once broke // the entire inline script, so no button handler ever bound — while every other test passed, // because they only checked that the markup contained the buttons. Checking the elements exist // says nothing about whether the page works. const js = html.slice(html.lastIndexOf('')); assert.doesNotThrow(() => new Function(js), 'inline script has a syntax error'); }); it('wires a handler to every control', () => { const js = html.slice(html.lastIndexOf('')); for (const id of ['play', 'back', 'fwd', 'first', 'stage', 'money', 'crash', 'waste', 'jam']) { assert.match(js, new RegExp(`\\$\\('${id}'\\)\\.onclick`), `no handler bound to ${id}`); } assert.match(js, /\$\('scrub'\)\.oninput/, 'no handler bound to scrub'); }); it('runs its render function against the real frames', () => { // Executes the page's own JS with a minimal DOM stub. Catches runtime errors in render(), // not just parse errors — e.g. a field the renderer expects that frames do not carry. const js = html.slice(html.lastIndexOf('')); const els = new Map>(); const stub = { getElementById: (id: string) => { if (!els.has(id)) { // A faithful-enough element. `classList` matters: the page folds the district panel by // toggling a class, and a stub without one throws — which is a page that never renders, // not a cosmetic gap. const classes = new Set(); els.set(id, { textContent: '', innerHTML: '', value: '', style: {}, max: 0, onclick: null, classList: { add: (c: string) => void classes.add(c), remove: (c: string) => void classes.delete(c), contains: (c: string) => classes.has(c), }, querySelector: () => null, }); } return els.get(id); }, }; const run = new Function('document', 'setInterval', 'clearInterval', js); assert.doesNotThrow( () => run(stub, () => 0, () => undefined), 'the page threw while rendering its first frame', ); // render() ran, so the turn chart should have been filled in — with the same Day/Stage/phase // chart the live game draws, from the shared renderer. const chart = String(els.get('turnchart')?.innerHTML ?? ''); assert.match(chart, /Day \d+/, 'the turn chart was not rendered'); assert.match(chart, /Stage \d+ of 12/); assert.match(chart, /class="tc-phase now"/, 'no phase is marked as the current one'); }); it('records what the bot chose, why, and what it passed over', () => { // The replay could always show what happened, never what COULD have happened — so a daft move // was visible but the alternatives it declined were not, which is what you need to say what it // should have done instead. const rec = record(202, 'standard'); const decided = rec.frames.filter((f) => f.decision !== null); assert.ok(decided.length > 0, 'no frame carried a decision'); for (const f of decided) { assert.ok(f.decision!.chose.length > 0, 'a decision with no chosen action'); assert.ok(f.decision!.why.length > 0, 'a decision with no stated reason'); assert.ok(f.decision!.totalOptions >= 1, 'a decision offering nothing'); } // The reasons must come from the bot's own branches, not a generic fallback for everything. const reasons = new Set(decided.map((f) => f.decision!.why)); assert.ok(reasons.size > 5, `only ${reasons.size} distinct reasons — the branches are not reporting`); // Turns with real alternatives must show them, or the panel is decoration. assert.ok( decided.some((f) => f.decision!.rejected.length > 0), 'no frame ever recorded a rejected option', ); }); it('flags jammed facilities and can tell them from ready ones', () => { const rec = record(202, 'standard'); for (const f of rec.frames) { for (const x of f.facilities) { // A jam is precisely "work on WORK that cannot come off", so it can never be 'ready'. assert.ok(!(x.jammed && x.canFinish), `${x.name} reported both jammed and ready`); } } }); it('renders a LATER frame, where the board state is carried forward', () => { // The page omits cells/facilities/division when unchanged and resolves them by walking back — // 63% of a 5.2 MB payload was the same grid re-serialised every frame. Rendering only frame 0 // would never exercise that path, because frame 0 always carries everything. const js = html.slice(html.lastIndexOf('')); const els = new Map>(); const stub = { getElementById: (id: string) => { if (!els.has(id)) { // A faithful-enough element. `classList` matters: the page folds the district panel by // toggling a class, and a stub without one throws — which is a page that never renders, // not a cosmetic gap. const classes = new Set(); els.set(id, { textContent: '', innerHTML: '', value: '', style: {}, max: 0, onclick: null, classList: { add: (c: string) => void classes.add(c), remove: (c: string) => void classes.delete(c), contains: (c: string) => classes.has(c), }, querySelector: () => null, }); } return els.get(id); }, }; // Append a jump to a late frame so render() runs against carried-forward state. const run = new Function('document', 'setInterval', 'clearInterval', js + '\n;go(FRAMES.length - 1);'); assert.doesNotThrow( () => run(stub, () => 0, () => undefined), 'the page threw rendering a frame whose board state was carried forward', ); assert.ok( String(els.get('grid')?.innerHTML ?? '').length > 0, 'the grid rendered empty on a carried-forward frame', ); }); it('stays a sane size', () => { const mb = Buffer.byteLength(html) / 1024 / 1024; assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`); }); }); describe('the replay wire format is lossless (regression)', () => { it('rebuilds every cell exactly, through the same function the page runs', () => { // The frames are packed before they reach the page: a card's identity and its description are // written once and referenced by integer, and a cell points at its facility instead of carrying // a second copy of it. That is 45% of the file — and it is only safe if unpacking is exact. // // `rehydrateCells` is the function the page itself runs, emitted by toString(), so this checks // the real thing rather than a second implementation of it. const rec = record(880009, 'standard', 4000); const packed = compress(rec.frames); let checked = 0; let carried: unknown[] = []; let carriedFacs: unknown[] = []; for (let i = 0; i < rec.frames.length; i++) { const wire = packed.frames[i] as Record; // null means "unchanged since the last frame that carried it", exactly as the page resolves it if (wire['cells'] !== null) carried = wire['cells'] as unknown[]; if (wire['facilities'] !== null) carriedFacs = wire['facilities'] as unknown[]; const back = rehydrateCells(carried, packed.cards, packed.whats, carriedFacs); assert.deepEqual(back, rec.frames[i]!.cells, `frame ${i} did not survive the round trip`); checked++; } assert.ok(checked > 100, `only ${checked} frames checked`); }); it("tolerates an older recording's singular train at the trains slot", () => { // Before "The Roster Pass" (docs/plans/switching-paths.md), the trains slot held one train // object or null — `rehydrateCells` has to make sense of that shape too, with no version flag // anywhere in a packed row to key off. const cards: unknown[] = [[0, 0, 'office', 'Whistle Post', true, ['ew']]]; const whats = ['a station']; const facs: unknown[] = []; const oldTrain = { trayId: 'crew', label: 'crew', cars: [], engineAt: 0, facing: 'e', what: '' }; const [withObject] = rehydrateCells([[0, 0, [], [], -1, oldTrain, 1, [], 0]], cards, whats, facs) as { trains: unknown[]; }[]; assert.deepEqual(withObject!.trains, [oldTrain], 'a lone train object should wrap into a one-train roster'); const [withNull] = rehydrateCells([[0, 0, [], [], -1, null, 1, [], 0]], cards, whats, facs) as { trains: unknown[]; }[]; assert.deepEqual(withNull!.trains, [], 'a null train slot should read as no train at all'); const [withArray] = rehydrateCells( [[0, 0, [], [], -1, [oldTrain, oldTrain], 1, [], 0]], cards, whats, facs, ) as { trains: unknown[] }[]; assert.equal(withArray!.trains.length, 2, 'an array should ride through as-is'); }); it('writes each distinct card and description once, not once per frame', () => { const rec = record(880009, 'standard', 4000); const packed = compress(rec.frames); const cellsSeen = rec.frames.reduce((n, f) => n + f.cells.length, 0); assert.ok( packed.cards.length < cellsSeen / 20, `${packed.cards.length} card entries for ${cellsSeen} cell-frames — identity is not interned`, ); assert.ok( packed.whats.length < cellsSeen / 20, `${packed.whats.length} descriptions for ${cellsSeen} cell-frames — prose is not interned`, ); }); }); describe('the replay behaves like the game it is replaying', () => { it('carries the sounds each frame earned, and the one implementation that plays them', () => { // A replay is for watching a game back, so it should sound and fold like the game. Both use the // SAME functions — `cuesFor` decides what happened, `playCue` decides what it sounds like — // embedded by toString() exactly as the board renderers are. A second copy would drift, and a // replay that sounds different from the game is worse than a replay with no sound. // POOLED OVER SEVERAL GAMES, because a single one cannot cover the set: measured over 40 games, // the bot couples in only 20% of them and sets cars out in 43%. This used to assert against one // seed and passed on luck — the moment the opening deal changed, that seed's game happened to // contain no switching at all and four of the six cues vanished. What is under test is the // plumbing, not one game, so the frames are pooled. /** * TWELVE SEEDS, not three — the same lesson as the comment above, learnt again at v0.4.7. * Coupling happens in about 39 games in 200, so three deals expect 0.6 of one: the assertion was * riding on luck exactly as the single seed had been, and the deck losing seven cards was enough * to tip it. * * TWENTY-FOUR, not twelve, and the difference is instructive: on this stride the first game that * couples anything is index 13, so a twelve-seed pool still contained none. The rate is what * matters, not the count — measured 39 in 200, with couplers at indices 13, 19, 22, 24, 28 … */ const recs = Array.from({ length: 24 }, (_, i) => record(1000 + i * 7919, 'standard', 4000)); const withCues = recs.flatMap((rec) => rec.frames.filter((f) => (f.cues?.length ?? 0) > 0)); assert.ok(withCues.length > 20, `only ${withCues.length} frames carry a cue`); const kinds = new Set(withCues.flatMap((f) => f.cues ?? [])); assert.ok(kinds.has('stage'), 'no Stage ever sounded'); assert.ok(kinds.has('day'), 'no Day ever sounded'); assert.ok(kinds.has('train'), 'no train was ever announced'); // Coupling and setting out are what a switching move IS, and both were silent — a car left the // board and only history said where it went. assert.ok(kinds.has('couple'), 'coupling a cut never made a sound'); assert.ok(kinds.has('drop'), 'setting cars out never made a sound'); assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently'); assert.ok(kinds.has('arrive'), 'a train pulling into an Office never made a sound'); assert.ok(kinds.has('depart'), 'a train highballing out of an Office never made a sound'); // Collisions are rare — measured 2 in 40 games — so this is the one cue this pool is not // guaranteed to contain on every stride; it happens to (seeds 96028 and 159380) at the current // stride and seed count. If this starts failing after either changes, widen the pool rather than // deleting the assertion — §10 is the one event a player most needs to hear. assert.ok(kinds.has('crash'), 'a collision never made a sound'); // One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same // accounting the live game is held to. The switching cues are counted separately: they follow // what a crew does, not what the clock does. let boundaries = 0; for (const rec of recs) { for (let k = 1; k < rec.frames.length; k++) { const a = rec.frames[k - 1]!; const b = rec.frames[k]!; if (a.day !== b.day || a.stage !== b.stage) boundaries++; } } const clock = withCues .flatMap((f) => f.cues ?? []) .filter((c) => c === 'stage' || c === 'day').length; assert.equal(clock, boundaries, 'Stage boundaries and Stage-or-Day cues disagree'); }); it('serves the controls for both, and embeds the player', () => { const html = renderHtml(record(880009, 'standard', 900)); for (const id of ['sound', 'districttoggle', 'districtsummary']) { assert.ok(html.includes(`id="${id}"`), `the replay has no #${id}`); } assert.match(html, /const playCue = function/, 'the sound player is referenced but not embedded'); assert.match(html, /#district\.folded/, 'nothing folds the district away'); }); });