/** * 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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): 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 | 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 += `
${esc(l.text)}
`; } } // 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; 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 { 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 = '

No replays have been published to this site yet.

'; return; } el.innerHTML = entries .map( (e, i) => ``, ) .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 = '

No replay index on this site yet. You can still open a save file from your computer below.

'; } } 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();