Files
station-master/src/web/replays.ts
T
Jesse.MarkowitzandClaude Opus 5 228027637b The Yard Office is offered, reachable, and can be run into (Gitea#5)
"Trains that are only freight (cabooses ok, no coaches allowed) that arrive in a
player's area who has the yard office card get an extra ability… the game will
offer that player the option… They can of course still choose to have the train go
to the standard office."

It was implemented, in a stripped form missing all three conditions: a qualifying
train was TELEPORTED onto the Yard Office card. Nobody was asked, no route was
computed — so the card's own printed "that can reach the yard office in one move"
was unenforced — and because nothing was walked, nothing was ever met on the way.

All three now hold:

  - OFFERED to whoever sits in the district, interrupting the Mainline Phase on the
    turn the train arrives. Declining is an ordinary arrival onto an A/D track.
  - REACHABILITY is the engine's own move walk. `exploreMoves` already means what
    the card means — any distance without changing direction, finishing on
    Operational Rail (§2.4, §A.1) — so using it is what makes code and card agree.
    Reversing is a separate Move, so a yard that can only be reached by backing up
    is correctly out of reach.
  - CARS ON THE LEAD COLLIDE. The walk does not treat standing cars as obstacles;
    it COUPLES them, because that is what a switching move does. An arriving train
    is not switching, so what it would have coupled is what it is about to hit —
    the same reading §8.3 already applies to the Running Track. `destination.couples`
    is therefore the fouling signal, and it needed no new machinery.

Per Jesse's ruling (2026-08-29) the two failures his issue names are kept apart: no
route means no offer, with the history saying why ("make sure this is logged in
history — why can't move so user knows why they can't get to yard"); a route that
exists but is fouled IS offered, and taking it crashes. A silent absence is
indistinguishable from a broken feature, which is how the missing check survived.

THE SHARED REFACTOR THIS NEEDED. `pendingDecision` was one question asked of one
player — §8.1's clearance, always the Superintendent — and `currentActor` hardcoded
that. It is a discriminated union now, with `decisionActor` as the single place that
maps a question to whoever must answer it, and `clearanceRuling` generalised to
`decisionAnswer`. Six copies of `pendingDecision !== null ? superintendent :
currentActor` across the engine, the sim, the web client and the tests collapse into
`actingPlayer`; they had already stopped being right the moment a second kind of
question existed. Gitea#19 needs the same machinery and now only has to add a case.

A BUG THE FIRST CUT WALKED INTO, worth recording because it is a trap the next
interruption will meet too: the offer must be put BEFORE the train is taken off its
Mainline card. `needsClearance` unwinds the whole phase and the driver re-enters
from the top, so asking after the `transits` filter cost the train its place on the
card and the answer had nowhere to land. §8.1 gets this right by asking before it
commits, and the Yard Office now does the same.

The developer bot declines: the Yard Office frees an A/D track, but the lead may be
fouled and the bot cannot read its own yard well enough to tell (`TODO.md`, Bot
Performance). Declining is always safe and keeps the harness comparable with every
measurement taken before this rule existed.

851 tests pass.

Closes #5

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAgJSmeV8zrMh55Mj85ESb
2026-08-29 06:57:31 -04:00

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