Files
station-master/src/web/replays.ts
T
Jesse.MarkowitzandClaude Opus 5 9a9e50b3c6 v0.8.0.11 — sixteen fixes from the second multiplayer playtest
Arrivals name whose Office they reached, and no longer tell every seat they can
work the train. The turn chart follows the animation queue, so being five behind
looks five behind across the whole screen rather than half of it. Pause sits
beside Skip and preserves the dwell a held step still owed. A one-render look at
another player's Office Area. The district summary counts the board being shown.
LIMITS is printed beneath its card instead of through its border. The Mainline
region divider is visible. Only Hilly mentions FAST/SLOW, because it is the only
card that reads it. A passenger Modifier on a Whistle Post reports itself dormant
rather than claiming the facility "only receives". An automatic phase says what
the Division is doing instead of answering by negation. The version appears once
in the header rather than twice on every .s9pk. Save files carry the join code,
the Stage and the date.

The New Train phase, reviewed before being changed: the make-up panel now says
what the train STILL needs rather than only what its card calls for, explains
that a player adds one car before the round passes on, marks the train being
loaded on the Division map, and gives an addable car in the yard the same amber
every other clickable thing on the page wears.

Reasoning, measurements and the reports behind each are in CHANGELOG.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017nnuCv8UodHucFfx3LWEoX
2026-09-16 16:06:01 -04:00

330 lines
13 KiB
TypeScript

/**
* The replay directory, and the viewer that plays one back.
*
* A REPLAY IS A SAVE. `{ seed, history }` — a few hundred bytes — because the engine is
* deterministic and runs in the browser: re-submitting the same intents against the same seed
* reconstructs the position exactly. So sharing a game means sharing a small JSON file, not a
* multi-megabyte page, and a save that would describe an impossible position simply cannot be
* replayed, because every step goes through `applyIntent`.
*
* Static hosting cannot list a directory, so site-hosted replays are enumerated by
* `replays/manifest.json`, written at build time from whatever is in `public/replays/`.
*/
import { BOARD_CSS, divisionSvg, officeSvg } from '../sim/board-svg.ts';
import { TURNCHART_CSS, turnChartHtml } from '../sim/turnchart.ts';
import { PANEL_CSS, blockedHtml, facilitiesHtml, handHtml, pilesHtml, yardHtml } from './panels.ts';
import { cuesFor } from '../sim/narrate.ts';
import { playCue } from './sound.ts';
import { TOOLTIP_CSS, installTooltips } from './tooltip.ts';
import { narrate } from '../sim/narrate.ts';
import { cardName, trainName } from '../sim/view.ts';
import type { Frame } from '../sim/view.ts';
import { applyIntent } from '../engine/apply.ts';
import { pump } from '../engine/advance.ts';
import { createGame } from '../engine/setup.ts';
import { snapshot } from '../sim/view.ts';
import type { Intent } from '../engine/intents.ts';
import { SOLO_CONFIG } from './game.ts';
import { actingPlayer } from '../engine/state.ts';
type Save = { seed: number; history: Intent[] };
type Entry = { file: string; title: string; note?: string; seed?: number };
const $ = (id: string): HTMLElement => {
const el = document.getElementById(id);
if (!el) throw new Error(`missing element: ${id}`);
return el;
};
const esc = (s: string): string =>
String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c] ?? c);
/** Every position the save passes through, with the lines narrated along the way. */
type Step = {
frame: Frame;
lines: { text: string; tone: string }[];
/** What this step should sound like — from `cuesFor`, the same function the live game uses. */
cues: string[];
};
/**
* Rebuild a game from a save, keeping a snapshot at each step.
*
* An intent that no longer applies stops the rebuild rather than being forced — better a short
* replay than one showing a position the rules could not produce.
*/
function rebuild(save: Save): { steps: Step[]; stoppedEarly: boolean } {
const s = createGame({ id: `replay-${save.seed}`, seed: save.seed, config: SOLO_CONFIG, playerNames: ['player'] });
const steps: Step[] = [];
const ctx = {
cardName: (id: string) => cardName(s, id),
trainName: (id: string) => trainName(s, id),
// A player index is not a seat index, so the fallback names no number at all — see `game.ts`.
playerName: (p: number) => s.players[p]?.name ?? 'another player',
};
const push = (events: ReturnType<typeof pump>): void => {
const lines = events
.filter((e) => e.type !== 'actorChanged')
.map((e) => {
const n = narrate(e, ctx);
return { text: n.text, tone: n.tone };
});
steps.push({ frame: snapshot(s, [], null), lines, cues: cuesFor(events) });
};
push(pump(s));
let stoppedEarly = false;
for (const intent of save.history) {
const actor = actingPlayer(s);
if (actor === null || s.status !== 'active') break;
const r = applyIntent(s, actor, intent);
if (!r.ok) {
stoppedEarly = true;
break;
}
const events = [...r.events, ...pump(s)];
push(events);
}
return { steps, stoppedEarly };
}
// ---------------------------------------------------------------------------
// Viewer
// ---------------------------------------------------------------------------
let steps: Step[] = [];
let at = 0;
let timer: ReturnType<typeof setInterval> | null = null;
/**
* Sound and auto-hide, matching the playable page and the standalone replay.
*
* This viewer is a third place a game gets drawn, and it had drifted: three speeds where the
* standalone had five, and neither of these. Anything a player learns on one screen should hold on
* the others.
*/
let soundOn = false;
let districtMode: 'auto' | 'open' | 'closed' = 'auto';
const FOCUS_PHASES = new Set(['localOps', 'loadUnload']);
function show(i: number): void {
const from = at;
at = Math.max(0, Math.min(steps.length - 1, i));
const step = steps[at];
if (!step) return;
const f = step.frame;
// Only a single step FORWARD sounds. Scrubbing across a hundred steps would fire a hundred
// whistles, and stepping back would sound a Stage ending that is being un-done.
if (soundOn && at === from + 1) for (const c of step.cues) playCue(c);
// The live game's turn chart, from the shared renderer: same five phases, same violet "you are
// here". A replay used to report the Day and phase as two plain strings, so the one thing a player
// glances at most often looked nothing like it does in play.
// A replay carries the seat names in the frame's own lines rather than a player table, so the
// solitaire seat is named directly; a multi-player replay reports the index it has.
const actorName = f.actor === null ? null : `Player ${f.actor + 1}`;
// A replay of a multi-player game gets the Fedora too — it is the answer to "why is it asking
// THEM", which a replay raises exactly as a live game does.
const superName = f.players.length > 1 ? `Player ${f.superintendent + 1}` : null;
$('turnchart').innerHTML = turnChartHtml(f, actorName, superName);
$('vrev').textContent = String(f.revenue);
$('vpos').textContent = `${at} / ${steps.length - 1}`;
($('vscrub') as HTMLInputElement).value = String(at);
$('vdivision').innerHTML = divisionSvg(f.division);
$('vgrid').innerHTML = officeSvg(f.cells, f.runningRow, [], [], f.limits);
// Auto-hide: the district is worth its space during the phases that change it.
const open = districtMode === 'auto' ? FOCUS_PHASES.has(f.phaseKey) : districtMode === 'open';
const sec = $('vdistrict');
if (open) sec.classList.remove('folded');
else sec.classList.add('folded');
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
$('vdistrictsummary').textContent =
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing`;
$('vdistricttoggle').textContent =
districtMode === 'auto'
? (open ? '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';
// A window of history rather than only this step, so a jump lands in context.
let log = '';
for (let k = Math.max(0, at - 18); k <= at; k++) {
for (const l of steps[k]?.lines ?? []) {
log += `<div class="line t-${l.tone}${k === at ? ' now' : ''}">${esc(l.text)}</div>`;
}
}
// The same side panels the playable page shows, from the same renderers. A replay is the game
// being watched, so it drops only what asks you to act.
$('vhand').innerHTML = handHtml(f);
$('vdepts').innerHTML = pilesHtml(f);
$('vdivyard').innerHTML = yardHtml(f.yards.division);
$('vclsyard').innerHTML = yardHtml(f.yards.classification);
$('vdivtot').textContent = `${f.yards.divisionTotal} cars`;
$('vclstot').textContent = `${f.yards.classificationTotal} cars`;
$('vblocked').innerHTML = blockedHtml(f);
$('vfacs').innerHTML = facilitiesHtml(f);
$('vlog').innerHTML = log;
const logEl = $('vlog');
logEl.scrollTop = logEl.scrollHeight;
}
function openSave(save: Save, title: string): void {
const built = rebuild(save);
steps = built.steps;
$('picker').style.display = 'none';
$('viewer').style.display = 'block';
$('vtitle').textContent = title;
$('vnote').textContent = built.stoppedEarly
? 'this save stopped early — an action in it is no longer legal under the current rules'
: `${steps.length} steps · seed ${save.seed}`;
const scrub = $('vscrub') as HTMLInputElement;
scrub.max = String(Math.max(0, steps.length - 1));
show(0);
}
function parseSave(text: string, from: string): Save | null {
try {
const raw = JSON.parse(text) as Partial<Save>;
if (typeof raw.seed !== 'number' || !Array.isArray(raw.history)) {
throw new Error('not a Station Master save');
}
return { seed: raw.seed, history: raw.history as Intent[] };
} catch (e) {
$('perr').textContent = `Could not read ${from}: ${(e as Error).message}`;
return null;
}
}
// ---------------------------------------------------------------------------
// Directory
// ---------------------------------------------------------------------------
async function listHosted(): Promise<void> {
const el = $('hosted');
try {
const res = await fetch('./replays/manifest.json', { cache: 'no-cache' });
if (!res.ok) throw new Error(String(res.status));
const entries = (await res.json()) as Entry[];
if (entries.length === 0) {
el.innerHTML = '<p class="dim">No replays have been published to this site yet.</p>';
return;
}
el.innerHTML = entries
.map(
(e, i) =>
`<button class="entry" data-i="${i}"><b>${esc(e.title)}</b>` +
(e.note ? `<span class="dim"> — ${esc(e.note)}</span>` : '') +
`<div class="dim sm">${esc(e.file)}</div></button>`,
)
.join('');
for (const b of Array.from(el.querySelectorAll('button.entry'))) {
(b as HTMLElement).onclick = async () => {
const entry = entries[Number((b as HTMLElement).dataset['i'])];
if (!entry) return;
const r = await fetch(`./replays/${entry.file}`, { cache: 'no-cache' });
const save = parseSave(await r.text(), entry.file);
if (save) openSave(save, entry.title);
};
}
} catch {
el.innerHTML =
'<p class="dim">No replay index on this site yet. You can still open a save file from your computer below.</p>';
}
}
function wire(): void {
const file = $('file') as HTMLInputElement;
file.onchange = () => {
const f = file.files?.[0];
if (!f) return;
const reader = new FileReader();
reader.onload = () => {
const save = parseSave(String(reader.result), f.name);
if (save) openSave(save, f.name.replace(/\.json$/i, ''));
};
reader.readAsText(f);
};
$('vback').onclick = () => show(at - 1);
$('vfwd').onclick = () => show(at + 1);
$('vfirst').onclick = () => show(0);
$('vlast').onclick = () => show(steps.length - 1);
($('vscrub') as HTMLInputElement).oninput = (e) => show(Number((e.target as HTMLInputElement).value));
$('vstage').onclick = () => {
const now = steps[at]?.frame;
for (let k = at + 1; k < steps.length; k++) {
const f = steps[k]!.frame;
if (f.stage !== now?.stage || f.day !== now?.day) return show(k);
}
show(steps.length - 1);
};
$('vplay').onclick = () => {
if (timer) {
clearInterval(timer);
timer = null;
$('vplay').textContent = '▶ play';
return;
}
$('vplay').textContent = '⏸ pause';
timer = setInterval(() => {
if (at >= steps.length - 1) {
clearInterval(timer!);
timer = null;
$('vplay').textContent = '▶ play';
return;
}
show(at + 1);
}, Number(($('vspeed') as HTMLSelectElement).value));
};
/**
* A SPEED CHANGE HAS TO TAKE EFFECT WHILE IT IS PLAYING.
*
* The interval is created with whatever the select held at the moment play started, and nothing
* re-read it — so choosing "extra slow" mid-replay did nothing at all and the pace looked stuck.
* The standalone replay has always restarted its timer on change; this viewer was missed.
*/
($('vspeed') as HTMLSelectElement).onchange = () => {
if (!timer) return;
$('vplay').click();
$('vplay').click();
};
$('vdistricttoggle').onclick = () => {
const f = steps[at]?.frame;
const open = districtMode === 'auto' ? (f ? FOCUS_PHASES.has(f.phaseKey) : true) : districtMode === 'open';
districtMode = districtMode === 'auto' ? (open ? 'closed' : 'open') : 'auto';
show(at);
};
$('vsound').onclick = () => {
soundOn = !soundOn;
$('vsound').textContent = soundOn ? '\u{1F50A} sound' : '\u{1F507} muted';
// Confirms the change, and gives the browser the gesture it needs before audio may start.
if (soundOn) playCue('stage');
};
$('vclose').onclick = () => {
if (timer) clearInterval(timer);
timer = null;
$('viewer').style.display = 'none';
$('picker').style.display = 'block';
};
document.onkeydown = (e) => {
if ($('viewer').style.display === 'none') return;
if (e.key === 'ArrowRight') show(at + 1);
else if (e.key === 'ArrowLeft') show(at - 1);
else if (e.key === ' ') {
e.preventDefault();
$('vplay').click();
}
};
}
const style = document.createElement('style');
style.textContent = BOARD_CSS + TOOLTIP_CSS + TURNCHART_CSS + PANEL_CSS;
document.head.appendChild(style);
installTooltips();
wire();
void listHosted();