The last item off 0.7.9.6's sweep, parked there as the one genuine maybe. It had a second half worth more than the first. #101 — Telegraph (+4), Telephone (+8) and Radio (+12) are "once a day, when dispatching facing trains, add +N to the other train's number". `enhancementText(key)` takes only the KEY, so the tooltip could not vary with anything: a spent Radio read "Once a day, add +12..." for the rest of the Day, advertising a bonus that was not there. That is `trainRules` before #100, in another corner of the same view. THE HALF THAT ACTUALLY SURPRISES. `spendDispatchBonus` reads `areaOf(s, s.clock.superintendent)` — the SUPERINTENDENT's own devices, not the train owner's — and the Fedora moves every STAGES_PER_SHIFT (3) Stages, four times a Day. So a player's Radio does nothing at all for three-quarters of the Day, and is spent automatically, without its owner being asked, during the quarter it is theirs. Neither half was anywhere on the board. The card now reads as one of three states — available and dispatching, unspent but idle while somebody else holds the Fedora, or spent until the next Day — and names the shift length, because "not now" without "for how long" is half an answer. A spent device is struck through on the board. Shown on EVERY district, not only the viewer's (Jesse's call): it is public, and a rival's spent Radio is what you want to know before forcing a meet. What counts as a device is `enhancementRule(key)?.dispatchBonus` rather than three keys written out in the view — the ladder lives in ENHANCEMENT_RULES and a fourth rung would otherwise be silently exempt. A STALE COMMENT CORRECTED, AND PINNED. `advance.ts` warned that indexing a SEAT-keyed area with the PLAYER holding the Fedora "is right only while seating is the identity map". It read as a live Employee Rotation bug and was not one: `areaOf(s, p)` IS `areaAtSeat(s, seatOf(s, p))`. A comment that sends the next reader chasing a bug that does not exist costs about what the bug would. Rewritten, and the claim is now a test — seating set to a real permutation, and the Superintendent's own district rather than the seat with the same index is the one that reads as dispatching. TWO THINGS MUTATION CAUGHT THAT PASSING DID NOT. A test asserted the ABSENCE of /spent|Fedora/ with the Fedora held, and a mutant with the `dispatchBonus` guard deleted PASSED it — the leaked text in that case says "Available today, and this district is dispatching", which contains neither word. A test that something was left alone has to compare it against what it should be, so it asserts equality with `enhancementText` now, in both Fedora states. And the replay wire format needed the field: cells pack positionally, so the flag is index 9 and reads `?? []`, the same tolerance `standingWest` uses — older recordings report no device spent, which is what they drew at the time, so every published replay is unchanged. 943 tests pass, up from 934. NOT VERIFIED AT A TABLE, like 0.7.9.6. #39 and #35 still stand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y5boPxP6JHRYMm8adXaF5R
719 lines
35 KiB
TypeScript
719 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, c.enhancementsSpent];
|
|
});
|
|
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, boolean[]];
|
|
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
|
|
// Tolerant the same way `standingWest` below is: an array rides through as-is, a lone object
|
|
// (an older recording's singular `train`) is wrapped into a one-train roster, and null or
|
|
// undefined reads as no train at all.
|
|
const rawTrains = p[5];
|
|
const trains = Array.isArray(rawTrains) ? rawTrains : rawTrains ? [rawTrains] : [];
|
|
return {
|
|
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
|
|
what: whats[p[1]], enhancements: p[2], cars: p[3],
|
|
facility: p[4] < 0 ? null : facs[p[4]],
|
|
trains,
|
|
adTracks: (p[6] as number | undefined) ?? null,
|
|
// Carried rather than recomputed: this function is emitted into the page by toString() and so
|
|
// cannot reach the card catalogue that produced the text.
|
|
enhancementsWhat: p[7] ?? [],
|
|
// Where the train(s) on this card stand among the cars standing on it, so the replay draws a
|
|
// cut ahead of or behind the engine exactly as the live board does. Absent in older recordings,
|
|
// which read as 0 — the whole cut east of the engine, which is what they used to draw anyway.
|
|
standingWest: p[8] ?? 0,
|
|
// Which dispatch devices were spent (#101), so a replay strikes a used Radio through exactly
|
|
// as the live board does. Absent in recordings made before it existed, which read as no
|
|
// device spent — the same thing they drew at the time, so an old replay is unchanged.
|
|
enhancementsSpent: p[9] ?? [],
|
|
};
|
|
});
|
|
}
|
|
|
|
export function renderHtml(rec: Recording): string {
|
|
const profile = lengthProfile(rec.length);
|
|
return `<!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}`);
|
|
}
|