Draw the board as track, split the site into three pages, and fix curve geometry
Adds SVG board rendering shared by the game and the replay, a splash page with a shareable replay directory, hover tooltips for reference detail, and makes curves two-port arcs so sidings and run-arounds can finally be built.
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 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 { 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 }[] };
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
function show(i: number): void {
|
||||
at = Math.max(0, Math.min(steps.length - 1, i));
|
||||
const step = steps[at];
|
||||
if (!step) return;
|
||||
const f = step.frame;
|
||||
|
||||
$('vclock').textContent = `Day ${f.day} · Stage ${f.stage} — ${f.clock}`;
|
||||
$('vphase').textContent = f.phase;
|
||||
$('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);
|
||||
|
||||
// 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>`;
|
||||
}
|
||||
}
|
||||
$('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));
|
||||
};
|
||||
$('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;
|
||||
document.head.appendChild(style);
|
||||
installTooltips();
|
||||
wire();
|
||||
void listHosted();
|
||||
Reference in New Issue
Block a user