asks what game you want Three queued items. The last matters most. A RELEASE NO LONGER DESTROYS EVERY GAME IN PROGRESS. Four consecutive releases killed every game on the box, one of them a release that changed only how the board is drawn. The reasoning behind the refusal was always right — a move legal under old rules may not be legal under new ones, and half-replaying a save is worse than refusing it. The TEST was wrong: it compared engineVersion for exact equality, and that stamp is the package version, which moves for a CSS fix. Whether a save still replays has an exact answer, so it is now asked directly. loadGame reads the file and judges nothing; tryResumeSession replays the intents and reports the first one the engine refuses. A save stamped with a version this server has never run resumes fine provided its moves replay — verified against a file hand-stamped 0.4.9-ancient. One that genuinely does not replay is still refused, but the log names the move rather than two version strings: "move 3 of 8 (localOps.choose) is rejected by the current rules with OPTION_ALREADY_CHOSEN". fromMultiplayerSave had to stop lying first. It has always stopped at the first unacceptable intent and done so in silence, which was survivable only because the version gate meant a doomed replay was never attempted. Now that the replay IS the check, it returns where it stopped and why. Deliberately not done: resuming a partly-replayable game at its last good move. That silently rewinds a game to a position nobody played to while every browser holding a later Frame carries on unaware. Refusing leaves the file intact, so putting the previous version back still recovers it. EMPLOYEE ROTATION IS IMPLEMENTED, SISTER TRAINS IS DELETED. Two of the four optional-rule flags were read by nothing at all. Employee Rotation is four lines in advance.ts, because the seat/player split (D9) exists for precisely this rule: seating is the only thing that moves, so Revenue, hands, the Superintendent and whose turn it is travel with the player, and the Office, district, grid and any trains standing in it stay with the chair. Inheriting the district you move into is the point of the rule, not a side effect. "Left" is seat + 1, matching playerLeftOf. Sister Trains is deleted rather than built: Q9 records that the Second Section card supersedes it, and that card exists, so the flag was a toggle for a rule the game no longer has. THE LOBBY ASKS WHAT GAME YOU WANT TO PLAY. Creating a game asked for a name, a mode and a table size; every other dial was hardcoded. A Game settings block now carries the same set the solitaire dialog does — seed, starting hand, the three revenue rates, Days, the combined-Revenue floor, both collision caps, the opponent-card toggle — plus the three surviving optional rules. Mode and table size set the defaults and everything stays editable. The seed is honoured, so a game can be reproduced or compared. Verified: 682 tests pass (679 + 3). The rotation tests were mutation-checked both ways — disabling the rotation and turning the table the wrong way each fail the suite. Live: a save stamped 0.4.9-ancient resumed, an injected illegal move was refused by name, and a create with every dial set to a non-default value came back out of game.json with all of them intact, including seed 777. Two of my own assertions were wrong on the way and the tests caught them: the Fedora legitimately passes at Stage 12 (§5) so it cannot be compared against its own earlier value, and dispatchUsedToday is cleared at every Day boundary so it cannot mark a district.
488 lines
24 KiB
TypeScript
488 lines
24 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 { DEFAULT_MAX_COLLISIONS_PER_DAY, DEFAULT_MAX_COLLISIONS_TOTAL, collectiveRevenueFloor } from '../src/engine/content.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';
|
|
|
|
// Mirrors `record()`'s own default exactly (`replay.ts`) — "does not drift from the engine" below
|
|
// plays the same seed through both paths and compares outcomes, so they must share one floor.
|
|
const config: GameConfig = {
|
|
mode: 'solitaire',
|
|
days: 5,
|
|
minCombinedRevenue: collectiveRevenueFloor(1, 5),
|
|
maxCollisionsPerDay: DEFAULT_MAX_COLLISIONS_PER_DAY,
|
|
maxCollisionsTotal: DEFAULT_MAX_COLLISIONS_TOTAL,
|
|
pvpCardsAllowed: false,
|
|
optionalRules: {
|
|
reducedVisibility: 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', carIndex: 0 },
|
|
{ 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("tolerates an older recording's singular train at the trains slot", () => {
|
|
// Before "The Roster Pass" (docs/plans/switching-paths.md), the trains slot held one train
|
|
// object or null — `rehydrateCells` has to make sense of that shape too, with no version flag
|
|
// anywhere in a packed row to key off.
|
|
const cards: unknown[] = [[0, 0, 'office', 'Whistle Post', true, ['ew']]];
|
|
const whats = ['a station'];
|
|
const facs: unknown[] = [];
|
|
const oldTrain = { trayId: 'crew', label: 'crew', cars: [], engineAt: 0, facing: 'e', what: '' };
|
|
|
|
const [withObject] = rehydrateCells([[0, 0, [], [], -1, oldTrain, 1, [], 0]], cards, whats, facs) as {
|
|
trains: unknown[];
|
|
}[];
|
|
assert.deepEqual(withObject!.trains, [oldTrain], 'a lone train object should wrap into a one-train roster');
|
|
|
|
const [withNull] = rehydrateCells([[0, 0, [], [], -1, null, 1, [], 0]], cards, whats, facs) as {
|
|
trains: unknown[];
|
|
}[];
|
|
assert.deepEqual(withNull!.trains, [], 'a null train slot should read as no train at all');
|
|
|
|
const [withArray] = rehydrateCells(
|
|
[[0, 0, [], [], -1, [oldTrain, oldTrain], 1, [], 0]],
|
|
cards,
|
|
whats,
|
|
facs,
|
|
) as { trains: unknown[] }[];
|
|
assert.equal(withArray!.trains.length, 2, 'an array should ride through as-is');
|
|
});
|
|
|
|
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');
|
|
assert.ok(kinds.has('arrive'), 'a train pulling into an Office never made a sound');
|
|
assert.ok(kinds.has('depart'), 'a train highballing out of an Office never made a sound');
|
|
// Collisions are rare — measured 2 in 40 games — so this is the one cue this pool is not
|
|
// guaranteed to contain on every stride; it happens to (seeds 96028 and 159380) at the current
|
|
// stride and seed count. If this starts failing after either changes, widen the pool rather than
|
|
// deleting the assertion — §10 is the one event a player most needs to hear.
|
|
assert.ok(kinds.has('crash'), 'a collision never made a sound');
|
|
|
|
// 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');
|
|
});
|
|
});
|