/**
* 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 {
DEFAULT_MAX_COLLISIONS_PER_DAY,
DEFAULT_MAX_COLLISIONS_TOTAL,
MAINLINE_PROFILES,
collectiveRevenueFloor,
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 { actingPlayer } from '../engine/state.ts';
import { reasonSentence } from '../web/panels.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 days = lengthProfile(length).days;
const config: GameConfig = {
mode: 'solitaire',
days,
minCombinedRevenue: collectiveRevenueFloor(1, days),
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
pvpCardsAllowed: false,
optionalRules: {
reducedVisibility: 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 = actingPlayer(s);
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);
}
/**
* IN WORDS, NOT AS AN ENUM (`TODO.md` #34). This heading read `loss — revenueFloor`, which is
* exactly the defect Gitea#16 was filed about on the playable page — it just outlived the fix
* here, because nothing player-facing pointed at it. `reasonSentence` is shared rather than
* reimplemented, so the replay and the results screen cannot end up explaining the same ending
* two different ways.
*
* Fed the LAST frame, which is the state the outcome was decided in and is already recorded.
* Tags are stripped: this lands in an `
` and in a console line, neither of which wants markup.
*/
const o = s.outcome;
const last = frames[frames.length - 1];
const why = o && last ? reasonSentence(last, o, last.day).replace(/<[^>]+>/g, '') : '';
return {
seed,
length,
frames,
outcome: o
? `${o.result === 'win' ? 'won' : 'lost'} — ${why} 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;
// `trains` 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.cars, fi, c.trains, c.adTracks, c.enhancementsWhat, c.standingWest, c.enhancementsSpent];
});
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[], number, unknown, unknown, string[], number, boolean[]];
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
// Tolerant the same way `standingWest` below is: an array rides through as-is, a lone object
// (an older recording's singular `train`) is wrapped into a one-train roster, and null or
// undefined reads as no train at all.
const rawTrains = p[5];
const trains = Array.isArray(rawTrains) ? rawTrains : rawTrains ? [rawTrains] : [];
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], cars: p[3],
facility: p[4] < 0 ? null : facs[p[4]],
trains,
adTracks: (p[6] as number | undefined) ?? 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[7] ?? [],
// Where the train(s) on this card stand 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[8] ?? 0,
// Which dispatch devices were spent (#101), so a replay strikes a used Radio through exactly
// as the live board does. Absent in recordings made before it existed, which read as no
// device spent — the same thing they drew at the time, so an old replay is unchanged.
enhancementsSpent: p[9] ?? [],
};
});
}
export function renderHtml(rec: Recording): string {
const profile = lengthProfile(rec.length);
return `
Station Master — replay seed ${rec.seed}
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.