Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YQAJ4dND7enLj54eLyiF2C
321 lines
12 KiB
TypeScript
321 lines
12 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';
|
|
|
|
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) };
|
|
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 = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
|
|
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}`;
|
|
$('turnchart').innerHTML = turnChartHtml(f, actorName);
|
|
$('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();
|