Three items folded into the unshipped v0.7.9. WAITING ON (reported by Jesse from play). The status line said "nobody — the Division is running itself" while the game was stopped on the Superintendent. Frame.actor carried clock.currentActor, which is null for the whole Mainline Phase, so all three interruptions — §8.1's clearance ruling, Gitea#5's Yard Office offer, Gitea#19's Red Flag prompt — reported that nobody was holding it up. actingPlayer had the answer since the Gitea#5 refactor; the Frame threw it away. It carries actingPlayer now, plus a new `awaiting` field naming the question and the train: "waiting on Bob · a clearance ruling · Train 4". Naming the person alone is not enough when three different things can be pending. THE FEDORA (TODO #29) rides at the right-hand end of the phase row instead of a line of its own, and wraps under rather than squeezing the chips. THE DEVELOPER REPLAY (TODO #34) printed "loss — revenueFloor", the same defect Gitea#16 was filed about, still alive because nothing player-facing pointed at it. panels.ts's reasonSentence is exported and shared rather than reimplemented, fed the last recorded frame and stripped of markup. The drift test maps win/loss to won/lost so it still checks the two AGREE rather than that they are spelled alike. Also carries the previous, unsigned commit's work: the two setup screens worded the same section by section. 877 tests pass, ten new. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdG46Ja2PEDBkpqiDazMoX
715 lines
35 KiB
TypeScript
715 lines
35 KiB
TypeScript
/**
|
|
* 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 `<h1>` 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<string, number>();
|
|
const whats: string[] = [];
|
|
const whatAt = new Map<string, number>();
|
|
|
|
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];
|
|
});
|
|
return { ...f, cells } as unknown as Frame;
|
|
});
|
|
|
|
const keys = ['cells', 'facilities', 'division'] as const;
|
|
let prev: Record<string, string> = {};
|
|
const out = packed.map((f, i) => {
|
|
const o: Record<string, unknown> = { ...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];
|
|
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,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function renderHtml(rec: Recording): string {
|
|
const profile = lengthProfile(rec.length);
|
|
return `<!doctype html>
|
|
<html lang="en"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Station Master — replay seed ${rec.seed}</title>
|
|
<style>
|
|
${BOARD_CSS}
|
|
${TURNCHART_CSS}
|
|
|
|
:root{--bg:#14161a;--fg:#e8e6e3;--dim:#8b9199;--line:#2c3138;--panel:#1b1f25;
|
|
--good:#5fd08a;--bad:#ff7a70;--clock:#7fb8ff;--warn:#ffc46b;--green:#2f6b47;--red:#6b3230;--maw:#2a4a6b}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
|
|
header{padding:10px 14px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5}
|
|
h1{font-size:14px;margin:0 0 6px;font-weight:600}
|
|
.bar{display:flex;gap:18px;flex-wrap:wrap;align-items:baseline}
|
|
.big{font-size:19px;font-weight:700}
|
|
.dim{color:var(--dim)}
|
|
.wrap{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:14px;padding:14px}
|
|
@media(max-width:900px){.wrap{grid-template-columns:1fr}}
|
|
section{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px 12px;margin-bottom:14px}
|
|
h2{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--dim);margin:0 0 8px;font-weight:600}
|
|
.div-strip{display:flex;gap:6px;overflow-x:auto;padding-bottom:4px}
|
|
.node{border:1px solid var(--line);border-radius:5px;padding:6px 8px;min-width:96px;background:#20252c}
|
|
.node.office{border-color:var(--clock)}
|
|
.regions{display:flex;gap:4px;margin-top:5px}
|
|
.region{flex:1;min-height:22px;border:1px dashed var(--line);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:11px}
|
|
.chip{background:var(--clock);color:#0d1117;border-radius:3px;padding:1px 5px;font-weight:700;font-size:11px}
|
|
.grid{display:grid;gap:5px;overflow-x:auto}
|
|
.cell{border:1px solid var(--line);border-radius:5px;padding:6px;min-height:64px;background:#20252c}
|
|
.cell.run{border-color:#4a545f;background:#252b33}
|
|
.cell.hl{outline:2px solid var(--warn);outline-offset:1px}
|
|
.cell.from{outline:2px dashed #6b7480;outline-offset:1px}
|
|
/* The crew is the thing you follow around the yard, so it gets the loudest treatment on the card. */
|
|
.crew{background:var(--warn);color:#0d1117;font-weight:700;border-radius:4px;padding:3px 6px;
|
|
margin-top:5px;font-size:11px;line-height:1.35}
|
|
.cell .nm{font-weight:700;font-size:11px}
|
|
.cell .cars{color:var(--dim);font-size:10.5px;margin-top:3px}
|
|
.cell .tray{display:inline-block;margin-top:4px}
|
|
.fac{border:1px solid var(--line);border-radius:5px;padding:8px;margin-bottom:8px;background:#20252c}
|
|
.boxes{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px;align-items:center}
|
|
.box{border-radius:3px;padding:3px 7px;font-size:11px;border:1px solid var(--line)}
|
|
.box.g{background:var(--green)}.box.r{background:var(--red)}.box.m{background:var(--maw)}.box.s{background:#3a4450}
|
|
.box.empty{background:transparent;color:var(--dim)}
|
|
.log{max-height:230px;overflow:auto}
|
|
/* Auto-hide, the same as the playable page. Folded leaves a summary rather than nothing, because a
|
|
panel that disappears entirely reads as broken instead of folded away. */
|
|
button.ghost{background:#222831;border:1px solid #4a5361;color:#c6ccd6;font-size:11px;
|
|
padding:2px 9px;margin-left:10px;text-transform:none;letter-spacing:0;vertical-align:middle}
|
|
button.ghost:hover{border-color:#4d6fa8;color:#fff}
|
|
#district.folded .grid{display:none}
|
|
#district.folded #districtsummary{display:block;padding:2px 0}
|
|
#districtsummary{display:none}
|
|
.line{padding:2px 0;border-bottom:1px solid #23272e}
|
|
.t-good{color:var(--good)}.t-bad{color:var(--bad)}.t-clock{color:var(--clock);font-weight:700}
|
|
.t-quiet{color:var(--dim)}.t-plain{color:var(--fg)}
|
|
.blocked li{margin-bottom:4px}
|
|
.sev-stuck{color:var(--bad)}.sev-risk{color:var(--warn)}.sev-waiting{color:var(--dim)}
|
|
/* FIXED, not sticky: a sticky footer shifts as panel heights change, so the mouse cannot stay
|
|
parked on one button and keep clicking. */
|
|
footer{position:fixed;left:0;right:0;bottom:0;background:var(--bg);border-top:1px solid var(--line);
|
|
padding:9px 14px;display:flex;gap:8px;align-items:center;flex-wrap:nowrap;z-index:10;overflow-x:auto}
|
|
body{padding-bottom:64px}
|
|
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:5px 11px;
|
|
font:inherit;cursor:pointer}
|
|
button:hover{background:#343b45}
|
|
input[type=range]{flex:1;min-width:180px}
|
|
.legend{font-size:11px;color:var(--dim);padding:0 14px 14px}
|
|
kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0 4px;font-size:10px}
|
|
.tt{display:grid;grid-template-columns:repeat(12,1fr);gap:3px}
|
|
.tt div{border:1px solid var(--line);border-radius:3px;padding:3px 2px;text-align:center;font-size:10.5px}
|
|
.tt div.due{background:var(--clock);color:#0d1117;font-weight:700}
|
|
.tt div.now{outline:2px solid var(--warn)}
|
|
.card{display:inline-block;border:1px solid var(--line);border-radius:3px;padding:1px 6px;margin:2px 3px 0 0;font-size:11px;background:#20252c}
|
|
.consist{font-size:10px;color:var(--dim);margin-top:2px}
|
|
.mini{margin-top:4px;font-size:9.5px;line-height:1.8}
|
|
.mini .box{padding:0 4px;font-size:9.5px}
|
|
.cell.office{border-color:var(--clock)}
|
|
.cell.modifier{border-style:dashed;opacity:.85}
|
|
|
|
.chose{font-size:15px;margin-bottom:4px}
|
|
.why{font-style:italic;opacity:.85;margin-bottom:2px}
|
|
.rejected{list-style:none;margin:0;padding:0;max-height:210px;overflow:auto}
|
|
.rejected li{padding:3px 0;border-top:1px solid rgba(128,128,128,.25);font-size:12px}
|
|
.wasted{background:#b03030;color:#fff;font-weight:bold;padding:3px 6px;margin-bottom:5px;border-radius:3px}
|
|
.fstat{margin-top:4px;font-size:11px;padding:2px 5px;border-radius:3px;display:inline-block}
|
|
.fstat.good{background:rgba(40,140,60,.28)}
|
|
.fstat.bad{background:rgba(190,50,50,.38);font-weight:bold}
|
|
.fstat.idle{opacity:.6}
|
|
.wastedmark{color:#e06060;font-weight:bold}
|
|
|
|
.enh{font-size:10px;background:rgba(90,140,220,.30);border-radius:3px;padding:1px 4px;margin-top:2px}
|
|
.mlmods{font-size:10px;background:rgba(200,150,60,.28);border-radius:3px;padding:1px 4px;margin-top:2px}
|
|
.grade{font-size:10px;background:rgba(200,90,60,.35);border-radius:3px;padding:1px 4px;font-weight:normal}
|
|
</style></head><body>
|
|
|
|
<header>
|
|
<h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (${profile.days} Days) · ${esc(rec.outcome)}</h1>
|
|
<div class="bar">
|
|
<span class="dim">fedora <span id="super">—</span></span>
|
|
<span>revenue <b class="big" id="rev">0</b></span>
|
|
<span class="dim">deck <span id="deck">0</span></span>
|
|
<span class="dim">frame <span id="fno">0</span>/<span id="ftot">0</span></span>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- The live game's turn chart, from the same renderer, so where you are in the Day looks the same
|
|
whether you are playing or watching. -->
|
|
<div id="turnchart"></div>
|
|
|
|
<div class="wrap">
|
|
<div>
|
|
<section><h2>Division — west to east</h2><div class="div-strip" id="division"></div></section>
|
|
<section id="district"><h2>Office Area <button id="districttoggle" class="ghost">auto-hide: on</button></h2>
|
|
<div id="districtsummary" class="dim"></div>
|
|
<div class="grid" id="grid"></div></section>
|
|
<section><h2>Facilities</h2><div id="facs"></div></section>
|
|
<section><h2>Timetable — which train is due out at each Stage</h2><div id="tt"></div></section>
|
|
</div>
|
|
<div>
|
|
<section><h2>What just happened</h2><div id="now"></div></section>
|
|
<section><h2>Decision — what it chose, and what it passed over</h2><div id="decision"></div></section>
|
|
<section><h2>Cards</h2>
|
|
<div><span class="dim">your hand:</span> <span id="hand">—</span></div>
|
|
<div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts">—</span></div>
|
|
</section>
|
|
<section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
|
|
<section><h2>Trains in play</h2><div id="trains" class="dim"></div></section>
|
|
<section><h2>History</h2><div class="log" id="log"></div></section>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="legend">
|
|
<b>Keyboard:</b> <kbd>←</kbd>/<kbd>→</kbd> step · <kbd>space</kbd> play/pause ·
|
|
<kbd>Home</kbd>/<kbd>End</kbd> first/last · <kbd>S</kbd> next Stage · <kbd>R</kbd> next revenue change ·
|
|
<kbd>C</kbd> next collision · <kbd>W</kbd> next wasted turn · <kbd>J</kbd> next jam.<br>
|
|
Running Track is the lighter row. <span class="chip">T4</span> is a train on the Division;
|
|
<span style="background:var(--warn);color:#0d1117;font-weight:700;border-radius:3px;padding:0 4px">🚂 T4</span>
|
|
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.
|
|
</div>
|
|
|
|
<footer>
|
|
<button id="first">⏮ start</button>
|
|
<button id="sound">🔇 muted</button>
|
|
<button id="back">◀ step</button>
|
|
<button id="play">▶ play</button>
|
|
<button id="fwd">step ▶</button>
|
|
<button id="stage">next Stage ⏭</button>
|
|
<button id="money">next revenue 💰</button>
|
|
<button id="crash">next collision ⚠</button>
|
|
<button id="waste">next wasted turn 🗑</button>
|
|
<button id="jam">next jam 🔒</button>
|
|
<input type="range" id="scrub" min="0" value="0">
|
|
<select id="speed">
|
|
<option value="3000">extra slow</option>
|
|
<option value="1200">slow</option>
|
|
<option value="250" selected>normal</option>
|
|
<option value="90">fast</option>
|
|
<option value="20">very fast</option>
|
|
</select>
|
|
</footer>
|
|
|
|
<script>
|
|
/* The board renderers, shared with the playable app. Embedded as SOURCE because this page has an
|
|
inline script and cannot import a module — a second copy would eventually draw a different board
|
|
from the same state, which is the one failure a track diagram exists to prevent. */
|
|
const divisionSvg = ${divisionSvg.toString()};
|
|
const officeSvg = ${officeSvg.toString()};
|
|
const turnChartHtml = ${turnChartHtml.toString()};
|
|
const playCue = ${playCue.toString()};
|
|
|
|
const PACKED = ${JSON.stringify(compress(rec.frames))};
|
|
const FRAMES = PACKED.frames, CARDS = PACKED.cards, WHATS = PACKED.whats;
|
|
/* The board changes rarely, so cells/facilities/division are emitted as null when identical to the
|
|
previous frame and resolved by walking back. Re-serialising the full grid every frame was 63% of
|
|
a 5.2 MB page. The scrubber jumps anywhere, hence the walk rather than a running pointer. */
|
|
function carry(i, key) {
|
|
for (let k = i; k >= 0; k--) if (FRAMES[k][key] !== null) return FRAMES[k][key];
|
|
return [];
|
|
}
|
|
const rehydrateCells = ${rehydrateCells.toString()};
|
|
function cellsAt(i) { return rehydrateCells(carry(i, 'cells'), CARDS, WHATS, carry(i, 'facilities')); }
|
|
let i = 0, timer = null;
|
|
/* Sound and auto-hide, the same two the playable page has — a replay is for watching a game back,
|
|
so it should behave like one. Both default to the quiet, tidy setting. */
|
|
let soundOn = false;
|
|
let districtMode = 'auto';
|
|
const FOCUS_PHASES = { localOps: 1, loadUnload: 1 };
|
|
const $ = (id) => document.getElementById(id);
|
|
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&','<':'<','>':'>'}[c]));
|
|
|
|
// The green / MEN|AT|WORK / red boxes drawn on the card itself, so a Freight Agent action or a
|
|
// Porter boarding shows up where it happened rather than only in a side panel.
|
|
function miniFacility(x) {
|
|
let h = '<div class="mini">';
|
|
if (x.greenCap > 0) h += '<span class="dim">grn </span>' + boxes(x.green, x.greenCap, 'g');
|
|
// Gated on the sign itself. This used to gate on trackCap, which was a stand-in for "freight
|
|
// facility" back when only a freight card had an industry track length; the length is gone and
|
|
// maw is the honest test — a Passenger Facility has no MEN | AT | WORK sign at all.
|
|
if (x.maw.length > 0) h += '<br><span class="dim">work </span>' +
|
|
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('');
|
|
if (x.redCap > 0) h += '<br><span class="dim">red </span>' + boxes(x.red, x.redCap, 'r');
|
|
return h + '</div>';
|
|
}
|
|
|
|
function boxes(items, cap, cls) {
|
|
let h = '';
|
|
for (let k = 0; k < Math.max(cap, items.length); k++) {
|
|
const v = items[k];
|
|
h += '<span class="box ' + (v ? cls : 'empty') + '">' + (v ? esc(v) : '·') + '</span>';
|
|
}
|
|
return h || '<span class="dim">—</span>';
|
|
}
|
|
|
|
function render() {
|
|
const f = FRAMES[i];
|
|
$('turnchart').innerHTML = turnChartHtml(
|
|
f,
|
|
f.actor === null ? null : 'Player ' + (f.actor + 1),
|
|
f.players.length > 1 ? 'Player ' + (f.superintendent + 1) : null,
|
|
);
|
|
$('super').textContent = 'P' + f.superintendent;
|
|
$('rev').textContent = f.revenue;
|
|
$('rev').className = 'big ' + (f.revenue < 0 ? 't-bad' : f.revenue > 0 ? 't-good' : '');
|
|
$('deck').textContent = f.deck;
|
|
$('hand').innerHTML = f.hand.length
|
|
? f.hand.map((c) => '<span class="card">' + esc(c) + '</span>').join('')
|
|
: '<span class="dim">empty</span>';
|
|
$('depts').innerHTML = f.departments.map((c) => '<span class="card">' + esc(c) + '</span>').join('');
|
|
$('tt').innerHTML = '<div class="tt">' + f.timetable.map((t, k) =>
|
|
'<div class="' + (t !== null ? 'due ' : '') + (k + 1 === f.stage ? 'now' : '') + '">' +
|
|
'S' + (k + 1) + '<br>' + (t !== null ? 'T' + t : '·') + '</div>').join('') + '</div>' +
|
|
'<div class="dim" style="margin-top:5px">The NUMBER of a train is its seniority and direction ' +
|
|
'(odd = westbound, even = eastbound). The Stage it departs is set by a 1D12 roll when its card ' +
|
|
'was played — so Train 8 leaving at Stage 6 is normal.</div>';
|
|
$('fno').textContent = i;
|
|
$('scrub').value = i;
|
|
|
|
const CELLS = cellsAt(i), FACS = carry(i, 'facilities'), DIV = carry(i, 'division');
|
|
|
|
// players, actor and viewer are not among the delta'd keys (see compress), so they ride whole on
|
|
// every frame and the replay names the districts exactly as the live page does. No backticks in
|
|
// this comment: it is inside the generated-page template literal, which they would terminate.
|
|
$('division').innerHTML = divisionSvg(DIV, { players: f.players, actor: f.actor, viewer: f.viewer });
|
|
// The same office renderer the playable app uses, so replay and game draw one board.
|
|
$('grid').innerHTML = officeSvg(CELLS, f.runningRow, [], [], f.limits);
|
|
|
|
/* Auto-hide: the district is worth its space during the phases that change it, and folded
|
|
otherwise. A summary stays, because a panel that vanishes reads as broken. */
|
|
const districtOpen = districtMode === 'auto' ? !!FOCUS_PHASES[f.phaseKey] : districtMode === 'open';
|
|
const sec = $('district');
|
|
if (districtOpen) sec.classList.remove('folded'); else sec.classList.add('folded');
|
|
let carsStanding = 0;
|
|
for (const c of CELLS) carsStanding += c.cars.length;
|
|
$('districtsummary').textContent =
|
|
CELLS.length + ' cards · ' + FACS.length + ' facilities · ' + carsStanding + ' cars standing';
|
|
$('districttoggle').textContent =
|
|
districtMode === 'auto'
|
|
? (districtOpen ? 'auto-hide: on — click to keep open' : 'auto-hide: on — click to show')
|
|
: districtMode === 'open' ? 'always showing — click for auto-hide' : 'always hidden — click for auto-hide';
|
|
if (f.where) {
|
|
const hit = $('grid').querySelector('g[data-cell="' + f.where.row + ',' + f.where.col + '"]');
|
|
if (hit) hit.classList.add('bs-focus');
|
|
}
|
|
if (f.whereFrom) {
|
|
const was = $('grid').querySelector('g[data-cell="' + f.whereFrom.row + ',' + f.whereFrom.col + '"]');
|
|
if (was) was.classList.add('bs-from');
|
|
}
|
|
|
|
$('facs').innerHTML = FACS.length === 0
|
|
? '<span class="dim">no freight facilities built yet</span>'
|
|
: FACS.map((x) =>
|
|
'<div class="fac"><b>' + esc(x.name) + '</b> <span class="dim">' + esc(x.commodity) + ' · ' + esc(x.flow) +
|
|
' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
|
|
'<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
|
|
// No sign on a Passenger Facility: maw is empty for one, but the LABEL was printed before
|
|
// the loop, so a Depot showed the caption over nothing.
|
|
// (No backticks in here — this whole block is inside a template literal.)
|
|
(x.maw.length > 0
|
|
? '<div class="boxes"><span class="dim">MEN|AT|WORK</span>' +
|
|
x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('') + '</div>'
|
|
: '') +
|
|
'<div class="boxes"><span class="dim">red</span>' + boxes(x.red, x.redCap, 'r') + '</div>' +
|
|
// 's', not 'g': standing here is a place, not a direction, and green is reserved for outbound.
|
|
// Cap 0 so only the cars actually spotted are drawn — an industry card prints no siding, and
|
|
// the row of empty squares this used to draw claimed both that it did and that the siding was
|
|
// as long as the industry's box count. It holds four cars, the same as any track card.
|
|
(x.track.length > 0
|
|
? '<div class="boxes"><span class="dim">spotted</span>' + boxes(x.track, 0, 's') + '</div>'
|
|
: '') +
|
|
'<div class="fstat ' + (x.jammed ? 'bad' : (x.canFinish ? 'good' : 'idle')) + '">' +
|
|
(x.jammed
|
|
? 'JAMMED — a load is on WORK with no car spotted to receive it; the industry track is locked'
|
|
: (x.canFinish
|
|
? 'ready — a matching empty car is spotted, so a load can finish'
|
|
: 'no car spotted — starting a load here would jam it')) +
|
|
'</div>' +
|
|
'</div>').join('');
|
|
|
|
$('now').innerHTML = f.lines.map((l) => '<div class="line t-' + l.tone + '">' + esc(l.text) + '</div>').join('');
|
|
|
|
var d = f.decision;
|
|
if (!d) {
|
|
$('decision').innerHTML = '<span class="dim">no choice here — the engine advanced on its own</span>';
|
|
} else {
|
|
var h = '';
|
|
if (f.wasted) h += '<div class="wasted">WASTED TURN — the whole action produced no change</div>';
|
|
h += '<div class="chose"><span class="dim">chose:</span> <b>' + esc(d.chose) + '</b></div>';
|
|
h += '<div class="why">why: ' + esc(d.why) + '</div>';
|
|
h += '<div class="dim" style="margin:6px 0 3px">passed over ' + (d.totalOptions - 1) +
|
|
' other legal option' + ((d.totalOptions - 1) === 1 ? '' : 's') + ':</div>';
|
|
if (d.rejected.length === 0) h += '<div class="dim">none — this was the only legal action</div>';
|
|
else h += '<ul class="rejected">' + d.rejected.map(function (r) {
|
|
return '<li><b>' + esc(r.kind) + '</b> <span class="dim">x' + r.count + '</span><br>' +
|
|
'<span class="dim">' + esc(r.detail) + '</span></li>';
|
|
}).join('') + '</ul>';
|
|
$('decision').innerHTML = h;
|
|
}
|
|
$('blocked').innerHTML = f.blocked.length === 0
|
|
? '<li class="dim">nothing blocked</li>'
|
|
: f.blocked.map((b) => '<li class="sev-' + b.severity + '"><b>' + esc(b.where) + '</b> — ' + esc(b.why) + '</li>').join('');
|
|
$('trains').innerHTML = f.trains.length === 0
|
|
? 'no trains running'
|
|
: f.trains.map((t) => esc(t.label) + ' — ' + esc(t.where)).join('<br>');
|
|
|
|
let hist = '';
|
|
for (let k = Math.max(0, i - 24); k <= i; k++) {
|
|
for (const l of FRAMES[k].lines) hist += '<div class="line t-' + l.tone + '">' + esc(l.text) + '</div>';
|
|
}
|
|
$('log').innerHTML = hist;
|
|
$('log').scrollTop = $('log').scrollHeight;
|
|
}
|
|
|
|
/* Sounds fire only when moving FORWARD one frame at a time — stepping or playing. Scrubbing across
|
|
a hundred frames would otherwise fire a hundred whistles at once, and stepping backwards would
|
|
sound a Stage ending that is being un-done. */
|
|
function go(n) {
|
|
const from = i;
|
|
i = Math.max(0, Math.min(FRAMES.length - 1, n));
|
|
if (soundOn && i === from + 1) {
|
|
const cues = FRAMES[i].cues || [];
|
|
for (const c of cues) playCue(c);
|
|
}
|
|
render();
|
|
}
|
|
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1], k)) return go(k); go(FRAMES.length - 1); }
|
|
|
|
$('districttoggle').onclick = () => {
|
|
const open = districtMode === 'auto' ? !!FOCUS_PHASES[FRAMES[i].phaseKey] : districtMode === 'open';
|
|
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
|
|
render();
|
|
};
|
|
$('sound').onclick = () => {
|
|
soundOn = !soundOn;
|
|
$('sound').textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted';
|
|
/* Confirm audibly, and give the browser the user gesture it needs before audio may start. */
|
|
if (soundOn) playCue('stage');
|
|
};
|
|
|
|
$('ftot').textContent = FRAMES.length - 1;
|
|
$('scrub').max = FRAMES.length - 1;
|
|
$('scrub').oninput = (e) => go(+e.target.value);
|
|
$('first').onclick = () => go(0);
|
|
$('back').onclick = () => go(i - 1);
|
|
$('fwd').onclick = () => go(i + 1);
|
|
$('stage').onclick = () => findNext((f, p) => f.stage !== p.stage || f.day !== p.day);
|
|
$('money').onclick = () => findNext((f, p) => f.revenue !== p.revenue);
|
|
$('crash').onclick = () => findNext((f) => f.lines.some((l) => /COLLISION|collision/.test(l.text)));
|
|
$('waste').onclick = () => findNext((f) => f.wasted);
|
|
$('jam').onclick = () => findNext((f, p, idx) => carry(idx, 'facilities').some((x) => x.jammed));
|
|
$('play').onclick = () => {
|
|
if (timer) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
|
|
$('play').textContent = '⏸ pause';
|
|
timer = setInterval(() => {
|
|
if (i >= FRAMES.length - 1) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
|
|
go(i + 1);
|
|
}, +$('speed').value);
|
|
};
|
|
$('speed').onchange = () => { if (timer) { $('play').click(); $('play').click(); } };
|
|
document.onkeydown = (e) => {
|
|
if (e.key === 'ArrowRight') go(i + 1);
|
|
else if (e.key === 'ArrowLeft') go(i - 1);
|
|
else if (e.key === 'Home') go(0);
|
|
else if (e.key === 'End') go(FRAMES.length - 1);
|
|
else if (e.key === 's' || e.key === 'S') $('stage').click();
|
|
else if (e.key === 'r' || e.key === 'R') $('money').click();
|
|
else if (e.key === 'c' || e.key === 'C') $('crash').click();
|
|
else if (e.key === 'w' || e.key === 'W') $('waste').click();
|
|
else if (e.key === 'j' || e.key === 'J') $('jam').click();
|
|
else if (e.key === ' ') { e.preventDefault(); $('play').click(); }
|
|
};
|
|
render();
|
|
</script></body></html>`;
|
|
}
|
|
|
|
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}`);
|
|
}
|