Files
station-master/test/replay.test.ts
T
Jesse 9f3b92d08e v0.4.7 — the switching game: track order, the cut on your own card, and four rules
Eight play reports and one design that had been written up and not built. The
through-line is switching: what a card can hold, which end of a train a cut comes
off, which way a train meets cars standing on the line, and what the board and the
log say about all of it.

TRACK ORDER FOR STANDING CARS, AND THE CUT ON YOUR OWN CARD

Two reports turned out to be one root cause. `TrackCard.standing` claimed "in track
order (§A.3)" and had no defined orientation at all, while `CrewTray.consist` does
(nose first, relative to facing) — so every transfer between them was a conversion
nothing performed. §A.3 says what it should be: cars occupy the track "in the same
order they originally held, left-to-right". Left-to-right is west-to-east, and that
is now the defined orientation of `standing` and of an industry track through
`carsOn`. It is the board's orientation, not the train's, so it does not change when
a different train touches the card.

  - Setting out is batch-invariant. Four cars at once, four singles and two pairs
    parked three different orders, one of them physically impossible. Successive
    cuts off the same end stack up towards the engine, so the insertion point is the
    train's own place in the row.
  - Approaching a cut from either end now mirrors. `couples` is built nearest-first
    along the direction of travel and reverses onto the nose, so the farthest car met
    ends up nose-most — which is what makes a run-around worth its Move.
  - A train no longer drives through its own cut. The walk began at the neighbour of
    the start square and never read the start card, so a crew could set cars out and
    pull straight away from them. Coupling is mandatory (§A.4) and your own square is
    no exception; the cut counts against the four-car limit. Setting out off the end
    you are not leaving by still works.

`CrewTray.standingWest` records where a train stands among the cars on its card — a
train may set out off both ends on one square, so which side a cut is on is not
recoverable from the array alone.

On the board, the cut is drawn split at the train — west cars left, east cars right,
engine in the gap — and each car's tooltip says whether it stands ahead of or behind
the engine. The history says which end a cut came off, and a move's button separates
"takes your own boxcar back off this card" from cars found standing on the line.

Decided: taking your own cut back on the square you are standing on is UNDOING the
drop. It is exempt from trains 3/4's per-location freight budget, X13's "drop but not
pick up" and X22's "empties only", and it refunds the budget the drop spent.
Otherwise a legal-looking drop becomes silently one-way.

Measured, 200 paired seeds, developer bot: -0.55 revenue (t = -3.63), freight revenue
1.11 -> 0.56. That cost is the bot's, not the rule's — its trains run engine-first,
so at a stub industry it sets a car out between itself and the only way out, and the
correct play is §A.5's facing-point move, which is the cross-turn planning TODO.md
already records as out of reach of any bot. Filtering self-recoupling moves out of its
options took recoupling from 625 of 1,029 set-outs to 101 of 677, and all 101 that
remain are that case. Read the number as a bot measurement, not a balance one.

THE SUPERINTENDENT'S RULING NAMES THE TRAINS IT IS ABOUT

Reported: the Superintendent could not tell which train he was clearing. The heading
asks the question now — "may Train 6 follow Train 4 onto the same Mainline card?" —
and the trains moved to the FRONT of each button, because the button splits its label
at the first em-dash and showed only the head.

AN INDUSTRY TRACK HOLDS FOUR CARS, LIKE EVERY OTHER CARD

Reported at undo 188: "we wanted to drop two cars, but were only allowed to drop one."
An industry track was built as long as its box count, so a one-box industry had room
for one car. Box count is how much WORK an industry can hold, not how much RAIL it
has. Ordinary track was the other exception, unbounded; both are gone and every card
holds four.

THE FREIGHT AGENT MAY STAGE A LOAD BEFORE THE CAR IS THERE

§6.3 asks nothing of the industry track — the empty car belongs to §9.3's Load the
car, which is the Laborer's action. The gate now lives only there, so cargo can wait
on the dock while the car to ship it in is still being switched in. Nothing can jam:
a load in a green box is waiting, not stuck.

THE TRUCK DOCK UNLOADS, AND BRINGS NOBODY

+1 inbound, no Laborer. It printed +1 outbound and +1 Laborer, which made it a
longer-host-list copy of Forklifts. Beside Packing Sheds it now does nothing at all,
and the hand tooltip says so before it is played.

Also in this release, from the days before: Mainline card tooltips computed from the
crossing rule; an Extra starts from the Division Point its number sends it to; a
modifier's suppressed grant comes back when a Whistle Post is upgraded; the Oil
Refinery and the Grocer's Warehouse ship as well as receive, per the card reference;
and the dormant defences name the attack they answer. `.claude/` is now gitignored —
it holds Claude Code's worktrees, i.e. a second checkout of this repository.

570 tests, typecheck clean. The three published replays were re-recorded twice —
legality changed, so bot play changed. Full detail in CHANGELOG.md.
2026-08-19 12:12:30 -04:00

448 lines
21 KiB
TypeScript

/**
* Component 16 — replay viewer.
*
* The important test here is narration coverage: adding an event type without narrating it should
* fail the build rather than quietly degrading the replay into "something happened".
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { pump } from '../src/engine/advance.ts';
import type { GameEvent } from '../src/engine/events.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
import { summarize } from '../src/sim/stats.ts';
const config: GameConfig = {
mode: 'solitaire',
victory: 'highestAfterDays',
length: 'standard',
optionalRules: {
reducedVisibility: false,
sisterTrains: false,
employeeRotation: false,
emergencyToolbox: false,
},
};
/** One representative instance of every event type the engine can emit. */
const SAMPLES: GameEvent[] = [
{ type: 'stageBegan', day: 1, stage: 7 },
{ type: 'phaseBegan', phase: 'mainline' },
{ type: 'actorChanged', player: 0 },
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
{ type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
{ type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
{ type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
{ type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
{ type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
{ type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
{ type: 'cardDiscarded', player: 0, cardId: 'c1', toSlot: 1 },
{ type: 'deckReshuffled', order: ['c1', 'c2', 'c3', 'c4'], rngState: 7 },
{ type: 'departmentRefilled', slot: 0, cardId: 'c2' },
{ type: 'stockToOutbound', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
{ type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
{ type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } },
{ type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
{ type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false } },
{ type: 'carPassed', player: 0, trayId: 't0' },
{ type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
{ type: 'clearanceGiven', trainId: 't1', allow: false },
{ type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 } },
{ type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 } },
{ type: 'loadStarted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
{ type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'loadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'unloadBegan', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
{ type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'freightLoad' },
{ type: 'phaseEnded', player: 0, phase: 'localOps' },
];
describe('narration', () => {
it('covers every event type the engine can emit', () => {
// Guards against a new event type slipping in unnarrated.
const covered = new Set(SAMPLES.map((e) => e.type));
const declared = new Set<string>();
for (const e of SAMPLES) declared.add(e.type);
assert.equal(covered.size, 30, 'sample list is out of step with GameEvent');
assert.equal(declared.size, 30);
});
it('gives every event a specific, non-empty sentence', () => {
for (const e of SAMPLES) {
const n = narrate(e);
assert.ok(n.text.length > 3, `${e.type} produced no useful text`);
assert.ok(!/^\[/.test(n.text), `${e.type} fell through to a fallback`);
assert.ok(['plain', 'good', 'bad', 'clock', 'quiet', 'phase'].includes(n.tone), `${e.type} bad tone`);
}
});
it('marks revenue gains good and losses bad', () => {
const gain = narrate({ type: 'revenueChanged', player: 0, delta: 1, total: 1, reason: 'boarding' });
const loss = narrate({
type: 'revenueChanged', player: 0, delta: -5, total: -5, reason: 'collision: no free A/D track',
});
assert.equal(gain.tone, 'good');
assert.equal(loss.tone, 'bad');
assert.match(loss.text, /COLLISION/i);
});
it('points at the board cell where something happened', () => {
const n = narrate({ type: 'loadCompleted', player: 0, at: { row: 1, col: 2 }, carType: 'hopper' });
assert.deepEqual(n.where, { row: 1, col: 2 });
});
it('names phases in words rather than identifiers', () => {
assert.equal(phaseLabel('loadUnload'), 'Cargo');
assert.equal(phaseLabel('newTrain'), 'New Train');
assert.equal(phaseLabel('shiftChange'), 'Supervisor Shift');
assert.equal(phaseLabel('localOps'), 'Local Operations');
assert.equal(phaseLabel('mainline'), 'Mainline');
});
it('hides only the events with nothing to show', () => {
assert.equal(isVisible({ type: 'actorChanged', player: 0 }), false);
assert.equal(isVisible({ type: 'loadCompleted', player: 0, at: { row: 0, col: 0 }, carType: 'hopper' }), true);
});
});
describe('impediments', () => {
it('reports nothing blocked on a fresh game', () => {
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
assert.deepEqual(impediments(s, 0), []);
});
it('warns when every A/D track is occupied', () => {
// Gap 2d — the next arrival is an automatic collision.
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
s.officeAreas.get(0)!.adOccupancy = ['t0'];
const found = impediments(s, 0);
assert.ok(found.some((b) => /A\/D/.test(b.why) && b.severity === 'risk'));
});
it('reports a train held for want of a Crew Tray', () => {
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
s.timetable[s.clock.stage - 1] = 4;
s.freeTrays = [];
assert.ok(impediments(s, 0).some((b) => /HELD/.test(b.why)));
});
});
describe('replay recording', () => {
const rec = record(1234, 'standard');
it('produces frames', () => {
assert.ok(rec.frames.length > 50, `only ${rec.frames.length} frames`);
});
it('never runs time backwards', () => {
let prev = 0;
for (const f of rec.frames) {
const t = f.day * 100 + f.stage;
assert.ok(t >= prev, `time went backwards at Day ${f.day} Stage ${f.stage}`);
prev = t;
}
});
it('always shows the Office card', () => {
for (const f of rec.frames) {
assert.ok(f.cells.some((c) => c.kind === 'office'), 'a frame lost the Office');
}
});
it('does not drift from the engine', () => {
// A replay that disagrees with the engine is worse than no replay. Replay the same seed
// through the normal bot path and compare the end state.
const s = createGame({ id: 'x', seed: 1234, config, playerNames: ['player'] });
const r = playGame(s, developerBot, pump);
const stats = summarize(1234, r.events, r.intents, s);
const last = rec.frames[rec.frames.length - 1]!;
assert.equal(last.revenue, stats.revenue.net, 'final revenue disagrees with the engine');
assert.equal(last.day, s.clock.day, 'final Day disagrees with the engine');
assert.match(rec.outcome, new RegExp(stats.result));
});
it('narrates every frame', () => {
for (const f of rec.frames) {
assert.ok(f.lines.length > 0, 'a frame has no narration');
}
});
});
describe('replay HTML', () => {
const html = renderHtml(record(99, 'short'));
it('is a complete standalone document', () => {
assert.match(html, /^<!doctype html>/i);
assert.match(html, /<\/html>\s*$/);
});
it('embeds no external resources', () => {
// A strict requirement: the file must open anywhere, offline.
const externals = html.match(/(?:src|href)\s*=\s*"(?!#)[^"]+"/g) ?? [];
assert.deepEqual(externals, [], `external references: ${externals.join(', ')}`);
});
it('embeds the frame data and the dictionaries it is packed against', () => {
// The frames are packed now: a card's identity and its description are written once into
// CARDS/WHATS and referenced by integer. A page with FRAMES but no dictionaries would draw an
// empty board rather than fail, so all three are checked.
assert.match(html, /const PACKED = \{/);
assert.match(html, /FRAMES = PACKED\.frames/);
assert.match(html, /CARDS = PACKED\.cards/);
assert.match(html, /WHATS = PACKED\.whats/);
// And the unpacking function must be emitted, not just referenced.
assert.match(html, /const rehydrateCells = function/);
});
it('offers the controls that make it usable', () => {
for (const id of ['play', 'back', 'fwd', 'scrub', 'stage', 'money', 'crash', 'waste', 'jam', 'decision']) {
assert.ok(html.includes(`id="${id}"`), `missing control: ${id}`);
}
});
it('emits a script that actually parses', () => {
// THE GUARD THAT WAS MISSING. A stray apostrophe inside a single-quoted JS string once broke
// the entire inline script, so no button handler ever bound — while every other test passed,
// because they only checked that the markup contained the buttons. Checking the elements exist
// says nothing about whether the page works.
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
assert.doesNotThrow(() => new Function(js), 'inline script has a syntax error');
});
it('wires a handler to every control', () => {
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
for (const id of ['play', 'back', 'fwd', 'first', 'stage', 'money', 'crash', 'waste', 'jam']) {
assert.match(js, new RegExp(`\\$\\('${id}'\\)\\.onclick`), `no handler bound to ${id}`);
}
assert.match(js, /\$\('scrub'\)\.oninput/, 'no handler bound to scrub');
});
it('runs its render function against the real frames', () => {
// Executes the page's own JS with a minimal DOM stub. Catches runtime errors in render(),
// not just parse errors — e.g. a field the renderer expects that frames do not carry.
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
const els = new Map<string, Record<string, unknown>>();
const stub = {
getElementById: (id: string) => {
if (!els.has(id)) {
// A faithful-enough element. `classList` matters: the page folds the district panel by
// toggling a class, and a stub without one throws — which is a page that never renders,
// not a cosmetic gap.
const classes = new Set<string>();
els.set(id, {
textContent: '', innerHTML: '', value: '', style: {}, max: 0, onclick: null,
classList: {
add: (c: string) => void classes.add(c),
remove: (c: string) => void classes.delete(c),
contains: (c: string) => classes.has(c),
},
querySelector: () => null,
});
}
return els.get(id);
},
};
const run = new Function('document', 'setInterval', 'clearInterval', js);
assert.doesNotThrow(
() => run(stub, () => 0, () => undefined),
'the page threw while rendering its first frame',
);
// render() ran, so the turn chart should have been filled in — with the same Day/Stage/phase
// chart the live game draws, from the shared renderer.
const chart = String(els.get('turnchart')?.innerHTML ?? '');
assert.match(chart, /Day \d+/, 'the turn chart was not rendered');
assert.match(chart, /Stage \d+ of 12/);
assert.match(chart, /class="tc-phase now"/, 'no phase is marked as the current one');
});
it('records what the bot chose, why, and what it passed over', () => {
// The replay could always show what happened, never what COULD have happened — so a daft move
// was visible but the alternatives it declined were not, which is what you need to say what it
// should have done instead.
const rec = record(202, 'standard');
const decided = rec.frames.filter((f) => f.decision !== null);
assert.ok(decided.length > 0, 'no frame carried a decision');
for (const f of decided) {
assert.ok(f.decision!.chose.length > 0, 'a decision with no chosen action');
assert.ok(f.decision!.why.length > 0, 'a decision with no stated reason');
assert.ok(f.decision!.totalOptions >= 1, 'a decision offering nothing');
}
// The reasons must come from the bot's own branches, not a generic fallback for everything.
const reasons = new Set(decided.map((f) => f.decision!.why));
assert.ok(reasons.size > 5, `only ${reasons.size} distinct reasons — the branches are not reporting`);
// Turns with real alternatives must show them, or the panel is decoration.
assert.ok(
decided.some((f) => f.decision!.rejected.length > 0),
'no frame ever recorded a rejected option',
);
});
it('flags jammed facilities and can tell them from ready ones', () => {
const rec = record(202, 'standard');
for (const f of rec.frames) {
for (const x of f.facilities) {
// A jam is precisely "work on WORK that cannot come off", so it can never be 'ready'.
assert.ok(!(x.jammed && x.canFinish), `${x.name} reported both jammed and ready`);
}
}
});
it('renders a LATER frame, where the board state is carried forward', () => {
// The page omits cells/facilities/division when unchanged and resolves them by walking back —
// 63% of a 5.2 MB payload was the same grid re-serialised every frame. Rendering only frame 0
// would never exercise that path, because frame 0 always carries everything.
const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
const els = new Map<string, Record<string, unknown>>();
const stub = {
getElementById: (id: string) => {
if (!els.has(id)) {
// A faithful-enough element. `classList` matters: the page folds the district panel by
// toggling a class, and a stub without one throws — which is a page that never renders,
// not a cosmetic gap.
const classes = new Set<string>();
els.set(id, {
textContent: '', innerHTML: '', value: '', style: {}, max: 0, onclick: null,
classList: {
add: (c: string) => void classes.add(c),
remove: (c: string) => void classes.delete(c),
contains: (c: string) => classes.has(c),
},
querySelector: () => null,
});
}
return els.get(id);
},
};
// Append a jump to a late frame so render() runs against carried-forward state.
const run = new Function('document', 'setInterval', 'clearInterval', js + '\n;go(FRAMES.length - 1);');
assert.doesNotThrow(
() => run(stub, () => 0, () => undefined),
'the page threw rendering a frame whose board state was carried forward',
);
assert.ok(
String(els.get('grid')?.innerHTML ?? '').length > 0,
'the grid rendered empty on a carried-forward frame',
);
});
it('stays a sane size', () => {
const mb = Buffer.byteLength(html) / 1024 / 1024;
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
});
});
describe('the replay wire format is lossless (regression)', () => {
it('rebuilds every cell exactly, through the same function the page runs', () => {
// The frames are packed before they reach the page: a card's identity and its description are
// written once and referenced by integer, and a cell points at its facility instead of carrying
// a second copy of it. That is 45% of the file — and it is only safe if unpacking is exact.
//
// `rehydrateCells` is the function the page itself runs, emitted by toString(), so this checks
// the real thing rather than a second implementation of it.
const rec = record(880009, 'standard', 4000);
const packed = compress(rec.frames);
let checked = 0;
let carried: unknown[] = [];
let carriedFacs: unknown[] = [];
for (let i = 0; i < rec.frames.length; i++) {
const wire = packed.frames[i] as Record<string, unknown>;
// null means "unchanged since the last frame that carried it", exactly as the page resolves it
if (wire['cells'] !== null) carried = wire['cells'] as unknown[];
if (wire['facilities'] !== null) carriedFacs = wire['facilities'] as unknown[];
const back = rehydrateCells(carried, packed.cards, packed.whats, carriedFacs);
assert.deepEqual(back, rec.frames[i]!.cells, `frame ${i} did not survive the round trip`);
checked++;
}
assert.ok(checked > 100, `only ${checked} frames checked`);
});
it('writes each distinct card and description once, not once per frame', () => {
const rec = record(880009, 'standard', 4000);
const packed = compress(rec.frames);
const cellsSeen = rec.frames.reduce((n, f) => n + f.cells.length, 0);
assert.ok(
packed.cards.length < cellsSeen / 20,
`${packed.cards.length} card entries for ${cellsSeen} cell-frames — identity is not interned`,
);
assert.ok(
packed.whats.length < cellsSeen / 20,
`${packed.whats.length} descriptions for ${cellsSeen} cell-frames — prose is not interned`,
);
});
});
describe('the replay behaves like the game it is replaying', () => {
it('carries the sounds each frame earned, and the one implementation that plays them', () => {
// A replay is for watching a game back, so it should sound and fold like the game. Both use the
// SAME functions — `cuesFor` decides what happened, `playCue` decides what it sounds like —
// embedded by toString() exactly as the board renderers are. A second copy would drift, and a
// replay that sounds different from the game is worse than a replay with no sound.
// POOLED OVER SEVERAL GAMES, because a single one cannot cover the set: measured over 40 games,
// the bot couples in only 20% of them and sets cars out in 43%. This used to assert against one
// seed and passed on luck — the moment the opening deal changed, that seed's game happened to
// contain no switching at all and four of the six cues vanished. What is under test is the
// plumbing, not one game, so the frames are pooled.
/**
* TWELVE SEEDS, not three — the same lesson as the comment above, learnt again at v0.4.7.
* Coupling happens in about 39 games in 200, so three deals expect 0.6 of one: the assertion was
* riding on luck exactly as the single seed had been, and the deck losing seven cards was enough
* to tip it.
*
* TWENTY-FOUR, not twelve, and the difference is instructive: on this stride the first game that
* couples anything is index 13, so a twelve-seed pool still contained none. The rate is what
* matters, not the count — measured 39 in 200, with couplers at indices 13, 19, 22, 24, 28 …
*/
const recs = Array.from({ length: 24 }, (_, i) => record(1000 + i * 7919, 'standard', 4000));
const withCues = recs.flatMap((rec) => rec.frames.filter((f) => (f.cues?.length ?? 0) > 0));
assert.ok(withCues.length > 20, `only ${withCues.length} frames carry a cue`);
const kinds = new Set(withCues.flatMap((f) => f.cues ?? []));
assert.ok(kinds.has('stage'), 'no Stage ever sounded');
assert.ok(kinds.has('day'), 'no Day ever sounded');
assert.ok(kinds.has('train'), 'no train was ever announced');
// Coupling and setting out are what a switching move IS, and both were silent — a car left the
// board and only history said where it went.
assert.ok(kinds.has('couple'), 'coupling a cut never made a sound');
assert.ok(kinds.has('drop'), 'setting cars out never made a sound');
assert.ok(kinds.has('schedule'), 'the 1D12 that sets a train\'s departure Stage landed silently');
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
// accounting the live game is held to. The switching cues are counted separately: they follow
// what a crew does, not what the clock does.
let boundaries = 0;
for (const rec of recs) {
for (let k = 1; k < rec.frames.length; k++) {
const a = rec.frames[k - 1]!;
const b = rec.frames[k]!;
if (a.day !== b.day || a.stage !== b.stage) boundaries++;
}
}
const clock = withCues
.flatMap((f) => f.cues ?? [])
.filter((c) => c === 'stage' || c === 'day').length;
assert.equal(clock, boundaries, 'Stage boundaries and Stage-or-Day cues disagree');
});
it('serves the controls for both, and embeds the player', () => {
const html = renderHtml(record(880009, 'standard', 900));
for (const id of ['sound', 'districttoggle', 'districtsummary']) {
assert.ok(html.includes(`id="${id}"`), `the replay has no #${id}`);
}
assert.match(html, /const playCue = function/, 'the sound player is referenced but not embedded');
assert.match(html, /#district\.folded/, 'nothing folds the district away');
});
});