/** * Component 16 — Game replay viewer. * * Records one game and writes a **self-contained HTML file** that plays it back step by step. * * node src/sim/replay.ts [--seed 1234] [--length standard] [--out replay.html] * * The browser never runs the engine. Frames are precomputed here in Node and embedded as JSON, so * the page is a dumb renderer with no bundling and no build step. * * The recorder drives `advance()` and `applyIntent()` itself rather than reusing `playGame`'s pump * loop, because `pump` batches a whole automatic phase into one call — which would collapse an * entire Mainline Phase into a single frame. Driving `advance` gives one frame per step. * * Priority is CLARITY over polish: plain boxes, words not symbols, and an explicit panel for what * is currently blocked. */ import { writeFileSync } from 'node:fs'; import { advance } from '../engine/advance.ts'; import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts'; import type { GameLength } from '../engine/content.ts'; import { MAINLINE_PROFILES, lengthProfile, officeProfile } from '../engine/content.ts'; import type { GameEvent } from '../engine/events.ts'; import type { Intent } from '../engine/intents.ts'; import { legalActions } from '../engine/legal.ts'; import { createGame } from '../engine/setup.ts'; import type { Facility, GameConfig, GameState } from '../engine/state.ts'; import { developerBot, lastChoiceReason } from './bot.ts'; import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts'; // The view-model lives in its own module so the browser build can import it without dragging in // this file's Node dependencies. Re-exported because tests and the web app import it from here. export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip } from './view.ts'; export { cardName, describeIntent, snapshot } from './view.ts'; import type { Frame } from './view.ts'; import type { Decision } from './view.ts'; import { cardName, describeDecision, snapshot, trainName } from './view.ts'; import { BOARD_CSS, divisionSvg, officeSvg } from './board-svg.ts'; import { TURNCHART_CSS, turnChartHtml } from './turnchart.ts'; import { playCue } from '../web/sound.ts'; // --------------------------------------------------------------------------- // Frame shape — only what the viewer draws // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Recording // --------------------------------------------------------------------------- export type Recording = { seed: number; length: GameLength; frames: Frame[]; outcome: string }; export function record(seed: number, length: GameLength, maxSteps = 100_000): Recording { const config: GameConfig = { mode: 'solitaire', victory: 'highestAfterDays', length, optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false, }, }; const s = createGame({ id: `replay-${seed}`, seed, config, playerNames: ['player'] }); const frames: Frame[] = []; const narrateCtx = { cardName: (id: string) => cardName(s, id), trainName: (id: string) => trainName(s, id), }; // Tracks whether a phase did anything, so an empty one can say so rather than ending silently. let didSomething = false; // The choice that produced the events about to be pushed. let pendingDecision: Decision | null = null; const push = (events: GameEvent[]): void => { const visible = events.filter(isVisible); if (visible.length === 0) return; const lines: { text: string; tone: string }[] = []; for (const e of visible) { if (e.type === 'phaseBegan') didSomething = false; if (e.type === 'phaseEnded' && !didSomething) { lines.push({ text: idleNote(e.phase), tone: 'quiet' }); } if ( e.type !== 'phaseBegan' && e.type !== 'phaseEnded' && e.type !== 'stageBegan' ) { didSomething = true; } const n = narrate(e, narrateCtx); lines.push({ text: n.text, tone: n.tone }); } const where = visible.map((e) => narrate(e, narrateCtx)).find((n) => n.where)?.where ?? null; const moved = visible.find((e) => e.type === 'trayMoved'); const from = moved && moved.type === 'trayMoved' ? moved.from : null; const d = pendingDecision; pendingDecision = null; // A Local Operations turn that ended with nothing but the turn ending is a wasted action — // six Moves and a card play spent on nothing. Among 600+ frames these are invisible unless // marked, and they are the ones worth reviewing. const wasted = d !== null && (d.chose === 'switch.end' || d.chose === 'draw.end' || d.chose === 'loadUnload.end') && visible.every((e) => e.type === 'phaseEnded' || e.type === 'actorChanged'); const frame = snapshot(s, lines, where ?? null, from, d, wasted); // Cues come from the RAW events, not the visible ones: what a frame sounds like is a different // question from what it prints, and `cuesFor` is shared with the live game so the replay cannot // sound a Stage the game did not. const cues = cuesFor(events); if (cues.length > 0) frame.cues = cues; frames.push(frame); }; frames.push( snapshot(s, [{ text: 'Game begins — the Division has just been spiked down.', tone: 'clock' }], null), ); for (let step = 0; step < maxSteps; step++) { const r = advance(s); push(r.events); if (s.status === 'finished') break; if (!r.needsInput) continue; const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor; if (actor === null) break; const options = legalActions(s, actor); if (options.length === 0) break; const chosen = developerBot.choose(s, actor, options); const decision = describeDecision(s, actor, chosen, options, lastChoiceReason()); const applied = applyIntent(s, actor, chosen); if (!applied.ok) break; pendingDecision = decision; push(applied.events); } const o = s.outcome; return { seed, length, frames, outcome: o ? `${o.result} — ${o.reason} · final Revenue ${s.players[0]?.revenue ?? 0}` : 'unfinished', }; } // --------------------------------------------------------------------------- // HTML // --------------------------------------------------------------------------- /** * Drop repeated board state from the serialised frames. * * Only the wire format changes: `record()` still returns complete frames, so tests and any other * consumer are unaffected. The page resolves a null by walking back to the last frame that carried * the field, and rebuilds each cell from the dictionaries below. * * MEASURED, on a 735-frame game, before any of this: `cells` was 62% of 7.1 MB of JSON, and within * it the cost was not where the TODO said. Carrying `links` forward — the item this started as — * would have saved 5% of cells. What actually costs: * * what 32% of cells long prose, and the SAME prose on every turnout in the district * facility 24% of cells the identical object is already in the frame's `facilities` * identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid * * So each of the three is interned instead. A card's identity and its description are written once * for the whole recording and referenced by integer, and a cell points at its facility rather than * carrying a second copy of it. What stays per-frame is only what genuinely changes: the cars * standing there, the crew, and any Enhancement laid on it. */ type Packed = { /** Static per square, written once: row, col, kind, label, running, links. */ cards: unknown[]; /** Distinct `what` descriptions, written once. */ whats: string[]; frames: unknown[]; }; export function compress(frames: Frame[]): Packed { const cards: unknown[] = []; const cardAt = new Map(); const whats: string[] = []; const whatAt = new Map(); const packed = frames.map((f) => { const cells = f.cells.map((c) => { const idKey = JSON.stringify([c.row, c.col, c.kind, c.label, c.running, c.links]); let ci = cardAt.get(idKey); if (ci === undefined) { ci = cards.length; cards.push([c.row, c.col, c.kind, c.label, c.running, c.links]); cardAt.set(idKey, ci); } let wi = whatAt.get(c.what); if (wi === undefined) { wi = whats.length; whats.push(c.what); whatAt.set(c.what, wi); } // Identity, not equality: `snapshot` pushes the very same FacilityView object into both the // cell and the frame's `facilities`, so this always resolves. const fi = c.facility ? f.facilities.indexOf(c.facility) : -1; // `train` rides whole rather than being interned: it changes almost every frame, so a table // of them would be as long as the frames are and buy nothing. return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad, c.enhancementsWhat, c.standingWest]; }); return { ...f, cells } as unknown as Frame; }); const keys = ['cells', 'facilities', 'division'] as const; let prev: Record = {}; const out = packed.map((f, i) => { const o: Record = { ...f }; for (const k of keys) { const json = JSON.stringify(f[k]); if (i > 0 && prev[k] === json) o[k] = null; prev[k] = json; } return o; }); return { cards, whats, frames: out }; } /** * Rebuild whole CellViews from the compact wire rows. * * Exported and then emitted into the page by `toString()`, exactly as the two board renderers are: * a second copy of this in the page's inline script could drift from the packing above, and the * failure would be a board that draws the wrong cards rather than an error. */ export function rehydrateCells( packed: unknown[], cards: unknown[], whats: string[], facs: unknown[], ): unknown[] { return packed.map((row) => { const p = row as [number, number, string[], string | null, string[], number, unknown, unknown, string[], number]; const c = cards[p[0]] as [number, number, string, string, boolean, string[]]; return { row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5], what: whats[p[1]], enhancements: p[2], tray: p[3], cars: p[4], facility: p[5] < 0 ? null : facs[p[5]], train: p[6] ?? null, ad: p[7] ?? null, // Carried rather than recomputed: this function is emitted into the page by toString() and so // cannot reach the card catalogue that produced the text. enhancementsWhat: p[8] ?? [], // Where the train on this card stands among the cars standing on it, so the replay draws a cut // ahead of or behind the engine exactly as the live board does. Absent in older recordings, // which read as 0 — the whole cut east of the engine, which is what they used to draw anyway. standingWest: p[9] ?? 0, }; }); } export function renderHtml(rec: Recording): string { const target = lengthProfile(rec.length); return ` Station Master — replay seed ${rec.seed}

Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}

fedora — revenue 0 deck 0 frame 0/0

Division — west to east

Office Area

Facilities

Timetable — which train is due out at each Stage

What just happened

Decision — what it chose, and what it passed over

Cards

your hand: —
face-up Department slots: —

Blocked — why nothing is moving

    Trains in play

    History

    Keyboard: ←/→ step · space play/pause · Home/End first/last · S next Stage · R next revenue change · C next collision · W next wasted turn · J next jam.
    Running Track is the lighter row. T4 is a train on the Division; 🚂 T4 is the crew in your yard — it carries the whole train with it as it moves, and a dashed outline marks where it came from. Green box = outbound loads waiting · MEN / AT / WORK = the three-step loading track (→ outbound, ← inbound) · red box = delivered loads. Laborers and Porters show remaining/total for this Stage.
    `; } function esc(s: string): string { return s.replace(/[&<>]/g, (c) => ({ '&': '&', '<': '<', '>': '>' })[c] ?? c); } // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- const isMain = process.argv[1]?.endsWith('replay.ts') ?? false; if (isMain) { const arg = (name: string, dflt: string): string => { const i = process.argv.indexOf(`--${name}`); return i >= 0 ? (process.argv[i + 1] ?? dflt) : dflt; }; const seed = Number(arg('seed', '1234')); const length = arg('length', 'standard') as GameLength; const out = arg('out', 'replay.html'); const rec = record(seed, length); writeFileSync(out, renderHtml(rec)); const kb = (Buffer.byteLength(renderHtml(rec)) / 1024).toFixed(0); console.log(`wrote ${out} — ${rec.frames.length} frames, ${kb} KB`); console.log(`seed ${seed} · ${length} · ${rec.outcome}`); }