/** * 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 type { Impediment } from './narrate.ts'; import { carLabel, clockTime, idleNote, impediments, isVisible, narrate, phaseLabel } from './narrate.ts'; // --------------------------------------------------------------------------- // Frame shape — only what the viewer draws // --------------------------------------------------------------------------- export type CellView = { row: number; col: number; kind: string; label: string; running: boolean; tray: string | null; cars: string[]; facility: FacilityView | null; }; export type FacilityView = { name: string; commodity: string; flow: string; green: string[]; greenCap: number; maw: (string | null)[]; red: string[]; redCap: number; track: string[]; trackCap: number; laborers: string; porters: string; /** * Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on * WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock * that held freight to a 3% completion rate. */ canFinish: boolean; /** A load is sitting on WORK with no spotted car to receive it. */ jammed: boolean; }; export type TrainChip = { label: string; consist: string[] }; export type DivisionView = { kind: string; label: string; trains: TrainChip[][] }; export type Frame = { day: number; stage: number; clock: string; phase: string; actor: number | null; superintendent: number; revenue: number; lines: { text: string; tone: string }[]; where: { row: number; col: number } | null; /** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */ whereFrom: { row: number; col: number } | null; division: DivisionView[]; cells: CellView[]; facilities: FacilityView[]; hand: string[]; deck: number; departments: string[]; /** 12 slots; the train number due out at each Stage, or null. */ timetable: (number | null)[]; blocked: Impediment[]; trains: { label: string; where: string }[]; /** What the bot chose here, why, and what it passed over. Null on engine-driven frames. */ decision: Decision | null; /** A Local Operations turn that changed nothing — the frames worth your attention. */ wasted: boolean; }; /** * The choice behind a frame. * * The whole point is `rejected`: the replay could always show what happened, never what COULD have * happened, so a daft move was visible but the alternatives it passed over were not — which is * exactly what you need to say what it should have done instead. */ export type Decision = { actor: number; chose: string; why: string; /** Every legal option not taken, grouped by kind with a count. */ rejected: { kind: string; count: number; detail: string }[]; totalOptions: number; }; // --------------------------------------------------------------------------- // Snapshotting // --------------------------------------------------------------------------- const FACILITY_NAMES: Record = { mineTipple: 'Mine Tipple', produceShed: 'Produce Shed', grocersWarehouse: "Grocer's Warehouse", oilRefinery: 'Oil Refinery', powerPlant: 'Power Plant', }; function facilityView( card: { geometry: { kind: string; facility?: string }; facility: unknown }, officeName: string, ): FacilityView | null { const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility; // Passenger facilities were excluded entirely, so the Office's green and red slots never // appeared — which is why stocking a coach into the green box looked like nothing happening. if (!f) return null; if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null; const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : ''; return { name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? key), commodity: facilityCarType(f) ?? '?', flow: f.kind === 'passenger' ? 'passengers on and off' : f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives', green: f.outboundBox.map(carLabel), greenCap: f.capacity.outbound, maw: f.menAtWork.map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)), red: f.inboundBox.map(carLabel), redCap: f.capacity.inbound, track: f.industryTrack.cars.map(carLabel), trackCap: f.industryTrack.length, laborers: `${laborersLeft(f)}/${f.laborers}`, porters: `${portersLeft(f)}/${f.porters}`, canFinish: canFinishHere(f), jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f), }; } /** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */ function canFinishHere(f: Facility): boolean { const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0]; if (!pending) return false; return f.industryTrack.cars.some((c) => !c.loaded && c.type === pending.type); } /** * Summarise the choice: what was taken, why, and what was passed over. * * Options are grouped by kind because a switching turn can offer 40 destinations, and a list that * long hides the shape of the decision rather than showing it. */ function describeDecision( s: GameState, actor: number, chosen: Intent, options: Intent[], why: string, ): Decision { const groups = new Map(); for (const o of options) { if (o === chosen) continue; const list = groups.get(o.type) ?? []; list.push(o); groups.set(o.type, list); } const rejected = [...groups.entries()] .map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, kind, list) })) .sort((a, b) => b.count - a.count); return { actor, chose: describeIntent(s, chosen), why, rejected, totalOptions: options.length }; } /** A short, concrete example of what a group of rejected options would have done. */ function sampleDetail(s: GameState, kind: string, list: Intent[]): string { // Deduplicate by DESCRIPTION. Orientation variants and repeated copies of a card describe // identically, so the raw list reads "play Overpass at (0,0)" three times over and hides the // actual range of choices — the opposite of what this panel is for. const seen = new Set(); for (const i of list) seen.add(describeIntent(s, i)); const unique = [...seen]; const shown = unique.slice(0, 4); const more = unique.length - shown.length; return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : ''); } /** One readable line for a single intent. */ function describeIntent(s: GameState, i: Intent): string { const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`; switch (i.type) { case 'localOps.choose': return `choose ${i.option}`; case 'card.play': return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`; case 'card.discard': return `discard ${cardName(s, i.cardId)}`; case 'track.lay': return `lay ${i.hand === 'none' ? '' : i.hand + '-hand '}${i.geometry} at ${at(i.placement)}`; case 'switch.move': return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`; case 'switch.dropCars': return `drop ${i.count} car(s)`; case 'switch.sortConsist': return `re-order consist [${i.order.join(',')}]`; case 'freightAgent.stockOutbound': return `stock a ${i.carType} at ${at(i.at)}`; case 'freightAgent.unjam': return `unjam ${i.from} at ${at(i.at)}`; case 'freightAgent.clearInbound': return `clear red box at ${at(i.at)}`; case 'laborer.startLoad': return `start a load at ${at(i.at)}`; case 'laborer.advanceLoad': return `advance load in box ${i.box} at ${at(i.at)}`; case 'laborer.beginUnload': return `begin unloading car ${i.carIndex} at ${at(i.at)}`; case 'porter.board': return `board passengers at ${at(i.at)}`; case 'porter.detrain': return `detrain passengers at ${at(i.at)}`; case 'newTrain.placeCar': return `add ${i.loaded ? 'loaded' : 'empty'} ${i.carType}`; case 'mainline.modify': return `${cardName(s, i.cardId)} on Mainline card ${i.node}`; case 'maneuver.redFlags': return `Red Flags on ${i.trayId}`; case 'maneuver.flyingSwitch': return `Flying Switch ${i.count} car(s) into ${at(i.to)}`; case 'mainline.clearance': return i.allow ? 'grant clearance' : 'refuse clearance'; case 'draw.fromDepartment': return `draw the face-up card in slot ${i.slot + 1}`; default: return i.type; } } function snapshot( s: GameState, lines: { text: string; tone: string }[], where: { row: number; col: number } | null, whereFrom: { row: number; col: number } | null = null, decision: Decision | null = null, wasted = false, ): Frame { const area = areaOf(s, 0); const trayAt = new Map(); for (const [id, tray] of s.trays) { if (tray.position.at === 'grid') { const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`; const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]'; trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying); } } const cells: CellView[] = []; const facilities: FacilityView[] = []; for (const [key, card] of area.grid) { const [row, col] = key.split(',').map(Number); const g = card.geometry; const kind = g.kind; let label: string; if (g.kind === 'office') label = officeProfile(area.tier).name; else if (g.kind === 'limits') label = 'Limits'; else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? g.facility; else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? g.modifier; else if (g.kind === 'spaceUse') label = prettyKey(g.key); else label = g.geometry === 'sharpCurved' ? 'sharp curve' : g.geometry; const fv = facilityView(card as never, officeProfile(area.tier).name); if (fv) facilities.push(fv); cells.push({ row: row!, col: col!, kind, label, running: row === area.runningRow, tray: trayAt.get(key) ?? null, cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel), facility: fv, }); } const division: DivisionView[] = s.division.nodes.map((n) => { if (n.kind === 'divisionPoint') { return { kind: 'dp', label: n.side === 'west' ? 'West DP' : 'East DP', trains: [n.holding.map((id) => trainChip(s, id))], }; } if (n.kind === 'mainline') { // Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it // with how long each still has to run. const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card; return { kind: 'ml', label: name, trains: [n.transits.map((t) => { const chip = trainChip(s, t.tray); return { ...chip, label: `${chip.label} (${t.stagesRemaining})` }; })], }; } return { kind: 'office', label: officeProfile(areaOf(s, n.owner).tier).name, trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))], }; }); return { day: s.clock.day, stage: s.clock.stage, clock: clockTime(s.clock.stage), phase: phaseLabel(s.clock.phase), actor: s.clock.currentActor, superintendent: s.clock.superintendent, revenue: s.players[0]?.revenue ?? 0, lines, where, whereFrom, division, cells, facilities, hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)), deck: s.decks.homeOffice.length, departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')), timetable: [...s.timetable], decision, wasted, blocked: impediments(s, 0), trains: [...s.trays.values()].map((t) => ({ label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`, where: t.position.at === 'divisionPoint' ? `${t.position.side} Division Point` : t.position.at === 'mainline' ? `Mainline card ${t.position.index}` : `Office Area (${t.position.coord.row},${t.position.coord.col})`, })), }; } /** A card id turned into something a person can read. */ export function cardName(s: GameState, id: string): string { const k = s.cards.get(id)?.kind; if (!k) return 'a card'; switch (k.kind) { case 'timetabledTrain': return `Train ${k.number}`; case 'extraTrain': return `Extra X${k.number}`; case 'office': return `${k.tier.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())} upgrade`; case 'freightFacility': return FACILITY_NAMES[k.facility] ?? k.facility; case 'modifier': return MODIFIER_NAMES[k.modifier] ?? k.modifier; case 'track': return k.geometry === 'sharpCurved' ? 'Sharp curve' : `${k.geometry} track`; case 'spaceUse': case 'enhancement': case 'mainlineModifier': case 'maneuver': case 'action': return prettyKey(k.key); } } /** camelCase key → readable name, for the card categories that carry only a key. */ function prettyKey(key: string): string { return key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()); } const MODIFIER_NAMES: Record = { teamTrack: 'Team Track', loadingDock: 'Loading Dock', storageShed: 'Storage Shed', extraPlatform: 'Extra Platform', sectionGang: 'Section Gang', }; /** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */ function trainChip(s: GameState, id: string): TrainChip { const t = s.trays.get(id); if (!t) return { label: id, consist: [] }; return { label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`, consist: t.consist.map(carLabel), }; } // --------------------------------------------------------------------------- // 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) }; // 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'); frames.push(snapshot(s, lines, where ?? null, from, d, wasted)); }; 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 // --------------------------------------------------------------------------- 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)}

phase actor · 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}`); }