"Trains that are only freight (cabooses ok, no coaches allowed) that arrive in a
player's area who has the yard office card get an extra ability… the game will
offer that player the option… They can of course still choose to have the train go
to the standard office."
It was implemented, in a stripped form missing all three conditions: a qualifying
train was TELEPORTED onto the Yard Office card. Nobody was asked, no route was
computed — so the card's own printed "that can reach the yard office in one move"
was unenforced — and because nothing was walked, nothing was ever met on the way.
All three now hold:
- OFFERED to whoever sits in the district, interrupting the Mainline Phase on the
turn the train arrives. Declining is an ordinary arrival onto an A/D track.
- REACHABILITY is the engine's own move walk. `exploreMoves` already means what
the card means — any distance without changing direction, finishing on
Operational Rail (§2.4, §A.1) — so using it is what makes code and card agree.
Reversing is a separate Move, so a yard that can only be reached by backing up
is correctly out of reach.
- CARS ON THE LEAD COLLIDE. The walk does not treat standing cars as obstacles;
it COUPLES them, because that is what a switching move does. An arriving train
is not switching, so what it would have coupled is what it is about to hit —
the same reading §8.3 already applies to the Running Track. `destination.couples`
is therefore the fouling signal, and it needed no new machinery.
Per Jesse's ruling (2026-08-29) the two failures his issue names are kept apart: no
route means no offer, with the history saying why ("make sure this is logged in
history — why can't move so user knows why they can't get to yard"); a route that
exists but is fouled IS offered, and taking it crashes. A silent absence is
indistinguishable from a broken feature, which is how the missing check survived.
THE SHARED REFACTOR THIS NEEDED. `pendingDecision` was one question asked of one
player — §8.1's clearance, always the Superintendent — and `currentActor` hardcoded
that. It is a discriminated union now, with `decisionActor` as the single place that
maps a question to whoever must answer it, and `clearanceRuling` generalised to
`decisionAnswer`. Six copies of `pendingDecision !== null ? superintendent :
currentActor` across the engine, the sim, the web client and the tests collapse into
`actingPlayer`; they had already stopped being right the moment a second kind of
question existed. Gitea#19 needs the same machinery and now only has to add a case.
A BUG THE FIRST CUT WALKED INTO, worth recording because it is a trap the next
interruption will meet too: the offer must be put BEFORE the train is taken off its
Mainline card. `needsClearance` unwinds the whole phase and the driver re-enters
from the top, so asking after the `transits` filter cost the train its place on the
card and the answer had nowhere to land. §8.1 gets this right by asking before it
commits, and the Yard Office now does the same.
The developer bot declines: the Yard Office frees an A/D track, but the lead may be
fouled and the bot cannot read its own yard well enough to tell (`TODO.md`, Bot
Performance). Declining is always safe and keeps the harness comparable with every
measurement taken before this rule existed.
851 tests pass.
Closes #5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
700 lines
34 KiB
TypeScript
700 lines
34 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 { 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);
|
|
}
|
|
|
|
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<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}`);
|
|
}
|