Eight play reports and one design that had been written up and not built. The
through-line is switching: what a card can hold, which end of a train a cut comes
off, which way a train meets cars standing on the line, and what the board and the
log say about all of it.
TRACK ORDER FOR STANDING CARS, AND THE CUT ON YOUR OWN CARD
Two reports turned out to be one root cause. `TrackCard.standing` claimed "in track
order (§A.3)" and had no defined orientation at all, while `CrewTray.consist` does
(nose first, relative to facing) — so every transfer between them was a conversion
nothing performed. §A.3 says what it should be: cars occupy the track "in the same
order they originally held, left-to-right". Left-to-right is west-to-east, and that
is now the defined orientation of `standing` and of an industry track through
`carsOn`. It is the board's orientation, not the train's, so it does not change when
a different train touches the card.
- Setting out is batch-invariant. Four cars at once, four singles and two pairs
parked three different orders, one of them physically impossible. Successive
cuts off the same end stack up towards the engine, so the insertion point is the
train's own place in the row.
- Approaching a cut from either end now mirrors. `couples` is built nearest-first
along the direction of travel and reverses onto the nose, so the farthest car met
ends up nose-most — which is what makes a run-around worth its Move.
- A train no longer drives through its own cut. The walk began at the neighbour of
the start square and never read the start card, so a crew could set cars out and
pull straight away from them. Coupling is mandatory (§A.4) and your own square is
no exception; the cut counts against the four-car limit. Setting out off the end
you are not leaving by still works.
`CrewTray.standingWest` records where a train stands among the cars on its card — a
train may set out off both ends on one square, so which side a cut is on is not
recoverable from the array alone.
On the board, the cut is drawn split at the train — west cars left, east cars right,
engine in the gap — and each car's tooltip says whether it stands ahead of or behind
the engine. The history says which end a cut came off, and a move's button separates
"takes your own boxcar back off this card" from cars found standing on the line.
Decided: taking your own cut back on the square you are standing on is UNDOING the
drop. It is exempt from trains 3/4's per-location freight budget, X13's "drop but not
pick up" and X22's "empties only", and it refunds the budget the drop spent.
Otherwise a legal-looking drop becomes silently one-way.
Measured, 200 paired seeds, developer bot: -0.55 revenue (t = -3.63), freight revenue
1.11 -> 0.56. That cost is the bot's, not the rule's — its trains run engine-first,
so at a stub industry it sets a car out between itself and the only way out, and the
correct play is §A.5's facing-point move, which is the cross-turn planning TODO.md
already records as out of reach of any bot. Filtering self-recoupling moves out of its
options took recoupling from 625 of 1,029 set-outs to 101 of 677, and all 101 that
remain are that case. Read the number as a bot measurement, not a balance one.
THE SUPERINTENDENT'S RULING NAMES THE TRAINS IT IS ABOUT
Reported: the Superintendent could not tell which train he was clearing. The heading
asks the question now — "may Train 6 follow Train 4 onto the same Mainline card?" —
and the trains moved to the FRONT of each button, because the button splits its label
at the first em-dash and showed only the head.
AN INDUSTRY TRACK HOLDS FOUR CARS, LIKE EVERY OTHER CARD
Reported at undo 188: "we wanted to drop two cars, but were only allowed to drop one."
An industry track was built as long as its box count, so a one-box industry had room
for one car. Box count is how much WORK an industry can hold, not how much RAIL it
has. Ordinary track was the other exception, unbounded; both are gone and every card
holds four.
THE FREIGHT AGENT MAY STAGE A LOAD BEFORE THE CAR IS THERE
§6.3 asks nothing of the industry track — the empty car belongs to §9.3's Load the
car, which is the Laborer's action. The gate now lives only there, so cargo can wait
on the dock while the car to ship it in is still being switched in. Nothing can jam:
a load in a green box is waiting, not stuck.
THE TRUCK DOCK UNLOADS, AND BRINGS NOBODY
+1 inbound, no Laborer. It printed +1 outbound and +1 Laborer, which made it a
longer-host-list copy of Forklifts. Beside Packing Sheds it now does nothing at all,
and the hand tooltip says so before it is played.
Also in this release, from the days before: Mainline card tooltips computed from the
crossing rule; an Extra starts from the Division Point its number sends it to; a
modifier's suppressed grant comes back when a Whistle Post is upgraded; the Oil
Refinery and the Grocer's Warehouse ship as well as receive, per the card reference;
and the dormant defences name the attack they answer. `.claude/` is now gitignored —
it holds Claude Code's worktrees, i.e. a second checkout of this repository.
570 tests, typecheck clean. The three published replays were re-recorded twice —
legality changed, so bot play changed. Full detail in CHANGELOG.md.
677 lines
33 KiB
TypeScript
677 lines
33 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 { MAINLINE_PROFILES, lengthProfile, officeProfile } from '../engine/content.ts';
|
|
import type { GameEvent } from '../engine/events.ts';
|
|
import type { Intent } from '../engine/intents.ts';
|
|
import { legalActions } from '../engine/legal.ts';
|
|
import { createGame } from '../engine/setup.ts';
|
|
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
|
|
import { developerBot, lastChoiceReason } from './bot.ts';
|
|
import { carLabel, cuesFor, idleNote, isVisible, narrate } from './narrate.ts';
|
|
// The view-model lives in its own module so the browser build can import it without dragging in
|
|
// this file's Node dependencies. Re-exported because tests and the web app import it from here.
|
|
export type { CellView, DivisionView, FacilityView, Frame, Decision, TrainChip } from './view.ts';
|
|
export { cardName, describeIntent, snapshot } from './view.ts';
|
|
import type { Frame } from './view.ts';
|
|
import type { Decision } from './view.ts';
|
|
import { cardName, describeDecision, snapshot, trainName } from './view.ts';
|
|
import { BOARD_CSS, divisionSvg, officeSvg } from './board-svg.ts';
|
|
import { TURNCHART_CSS, turnChartHtml } from './turnchart.ts';
|
|
import { playCue } from '../web/sound.ts';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Frame shape — only what the viewer draws
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Recording
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export type Recording = { seed: number; length: GameLength; frames: Frame[]; outcome: string };
|
|
|
|
export function record(seed: number, length: GameLength, maxSteps = 100_000): Recording {
|
|
const config: GameConfig = {
|
|
mode: 'solitaire',
|
|
victory: 'highestAfterDays',
|
|
length,
|
|
optionalRules: {
|
|
reducedVisibility: false,
|
|
sisterTrains: false,
|
|
employeeRotation: false,
|
|
emergencyToolbox: false,
|
|
},
|
|
};
|
|
const s = createGame({ id: `replay-${seed}`, seed, config, playerNames: ['player'] });
|
|
const frames: Frame[] = [];
|
|
const narrateCtx = {
|
|
cardName: (id: string) => cardName(s, id),
|
|
trainName: (id: string) => trainName(s, id),
|
|
};
|
|
|
|
// Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
|
|
let didSomething = false;
|
|
// The choice that produced the events about to be pushed.
|
|
let pendingDecision: Decision | null = null;
|
|
|
|
const push = (events: GameEvent[]): void => {
|
|
const visible = events.filter(isVisible);
|
|
if (visible.length === 0) return;
|
|
|
|
const lines: { text: string; tone: string }[] = [];
|
|
for (const e of visible) {
|
|
if (e.type === 'phaseBegan') didSomething = false;
|
|
if (e.type === 'phaseEnded' && !didSomething) {
|
|
lines.push({ text: idleNote(e.phase), tone: 'quiet' });
|
|
}
|
|
if (
|
|
e.type !== 'phaseBegan' &&
|
|
e.type !== 'phaseEnded' &&
|
|
e.type !== 'stageBegan'
|
|
) {
|
|
didSomething = true;
|
|
}
|
|
const n = narrate(e, narrateCtx);
|
|
lines.push({ text: n.text, tone: n.tone });
|
|
}
|
|
const where = visible.map((e) => narrate(e, narrateCtx)).find((n) => n.where)?.where ?? null;
|
|
const moved = visible.find((e) => e.type === 'trayMoved');
|
|
const from = moved && moved.type === 'trayMoved' ? moved.from : null;
|
|
const d = pendingDecision;
|
|
pendingDecision = null;
|
|
// A Local Operations turn that ended with nothing but the turn ending is a wasted action —
|
|
// six Moves and a card play spent on nothing. Among 600+ frames these are invisible unless
|
|
// marked, and they are the ones worth reviewing.
|
|
const wasted =
|
|
d !== null &&
|
|
(d.chose === 'switch.end' || d.chose === 'draw.end' || d.chose === 'loadUnload.end') &&
|
|
visible.every((e) => e.type === 'phaseEnded' || e.type === 'actorChanged');
|
|
const frame = snapshot(s, lines, where ?? null, from, d, wasted);
|
|
// Cues come from the RAW events, not the visible ones: what a frame sounds like is a different
|
|
// question from what it prints, and `cuesFor` is shared with the live game so the replay cannot
|
|
// sound a Stage the game did not.
|
|
const cues = cuesFor(events);
|
|
if (cues.length > 0) frame.cues = cues;
|
|
frames.push(frame);
|
|
};
|
|
|
|
frames.push(
|
|
snapshot(s, [{ text: 'Game begins — the Division has just been spiked down.', tone: 'clock' }], null),
|
|
);
|
|
|
|
for (let step = 0; step < maxSteps; step++) {
|
|
const r = advance(s);
|
|
push(r.events);
|
|
if (s.status === 'finished') break;
|
|
if (!r.needsInput) continue;
|
|
|
|
const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
|
if (actor === null) break;
|
|
const options = legalActions(s, actor);
|
|
if (options.length === 0) break;
|
|
const chosen = developerBot.choose(s, actor, options);
|
|
const decision = describeDecision(s, actor, chosen, options, lastChoiceReason());
|
|
const applied = applyIntent(s, actor, chosen);
|
|
if (!applied.ok) break;
|
|
pendingDecision = decision;
|
|
push(applied.events);
|
|
}
|
|
|
|
const o = s.outcome;
|
|
return {
|
|
seed,
|
|
length,
|
|
frames,
|
|
outcome: o ? `${o.result} — ${o.reason} · final Revenue ${s.players[0]?.revenue ?? 0}` : 'unfinished',
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// HTML
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Drop repeated board state from the serialised frames.
|
|
*
|
|
* Only the wire format changes: `record()` still returns complete frames, so tests and any other
|
|
* consumer are unaffected. The page resolves a null by walking back to the last frame that carried
|
|
* the field, and rebuilds each cell from the dictionaries below.
|
|
*
|
|
* MEASURED, on a 735-frame game, before any of this: `cells` was 62% of 7.1 MB of JSON, and within
|
|
* it the cost was not where the TODO said. Carrying `links` forward — the item this started as —
|
|
* would have saved 5% of cells. What actually costs:
|
|
*
|
|
* what 32% of cells long prose, and the SAME prose on every turnout in the district
|
|
* facility 24% of cells the identical object is already in the frame's `facilities`
|
|
* identity 26% of cells row/col/kind/label/running/links, fixed once the card is laid
|
|
*
|
|
* So each of the three is interned instead. A card's identity and its description are written once
|
|
* for the whole recording and referenced by integer, and a cell points at its facility rather than
|
|
* carrying a second copy of it. What stays per-frame is only what genuinely changes: the cars
|
|
* standing there, the crew, and any Enhancement laid on it.
|
|
*/
|
|
type Packed = {
|
|
/** Static per square, written once: row, col, kind, label, running, links. */
|
|
cards: unknown[];
|
|
/** Distinct `what` descriptions, written once. */
|
|
whats: string[];
|
|
frames: unknown[];
|
|
};
|
|
|
|
export function compress(frames: Frame[]): Packed {
|
|
const cards: unknown[] = [];
|
|
const cardAt = new Map<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;
|
|
// `train` rides whole rather than being interned: it changes almost every frame, so a table
|
|
// of them would be as long as the frames are and buy nothing.
|
|
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad, c.enhancementsWhat, c.standingWest];
|
|
});
|
|
return { ...f, cells } as unknown as Frame;
|
|
});
|
|
|
|
const keys = ['cells', 'facilities', 'division'] as const;
|
|
let prev: Record<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 | null, string[], number, unknown, unknown, string[], number];
|
|
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
|
return {
|
|
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
|
|
what: whats[p[1]], enhancements: p[2], tray: p[3], cars: p[4],
|
|
facility: p[5] < 0 ? null : facs[p[5]],
|
|
train: p[6] ?? null,
|
|
ad: p[7] ?? null,
|
|
// Carried rather than recomputed: this function is emitted into the page by toString() and so
|
|
// cannot reach the card catalogue that produced the text.
|
|
enhancementsWhat: p[8] ?? [],
|
|
// Where the train on this card stands among the cars standing on it, so the replay draws a cut
|
|
// ahead of or behind the engine exactly as the live board does. Absent in older recordings,
|
|
// which read as 0 — the whole cut east of the engine, which is what they used to draw anyway.
|
|
standingWest: p[9] ?? 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
export function renderHtml(rec: Recording): string {
|
|
const target = lengthProfile(rec.length);
|
|
return `<!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} (target ${target.target} over ${target.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));
|
|
$('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');
|
|
|
|
$('division').innerHTML = divisionSvg(DIV);
|
|
// The same office renderer the playable app uses, so replay and game draw one board.
|
|
$('grid').innerHTML = officeSvg(CELLS, f.runningRow);
|
|
|
|
/* 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}`);
|
|
}
|