TODO #13, #15 and #18 — Gitea#20 steps 2-4 pointed at a seated player's own screen. Every accepted intent, and every automatic phase that does anything, becomes an ordered presentation step. A bot's whole switching turn used to land in one push; now it arrives as a run of steps, the district panel follows whoever is acting, and a [N behind] … [Skip] row says how far the board is from the game. Solitaire runs the same path — one collector inside submit(), which both session kinds already funnel through — which is where its automatic phases finally get a visible beat. Dwell is assigned by kind: switching holds the screen, turn bookkeeping costs nothing, and the clock turning over earns the beat. Tunable per viewer without a rebuild, and off entirely at pace 0. Also: switching was the one class of action logging unattributed, and now names its train. Reasoning, measurements and the three things that turned out wrong are in CHANGELOG.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
726 lines
36 KiB
TypeScript
726 lines
36 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 { readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
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 { areaOf } from '../src/engine/apply.ts';
|
|
import { createGame } from '../src/engine/setup.ts';
|
|
import { coordKey, turnOf } from '../src/engine/state.ts';
|
|
import type { GameConfig, GameState, GridCoord } 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', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
|
|
{ type: 'carsCoupled', player: 0, trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }], from: [{ row: 0, col: 1 }], toNose: true },
|
|
{ type: 'carsDropped', player: 0, 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 }, trayId: 't0', coachIndex: 0 },
|
|
{ type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 }, trayId: 't0', coachIndex: 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', () => {
|
|
/**
|
|
* THIS TEST USED TO BUILD BOTH SETS FROM `SAMPLES` and compare them to each other, so it could
|
|
* only ever assert that the sample list had 30 distinct entries — the one thing it could not
|
|
* detect was the thing its comment promised, a new `GameEvent` slipping in unnarrated. Fixed
|
|
* 2026-09-09 while adding the v0.8.0 step collector, which made the event union load-bearing for
|
|
* a second reader.
|
|
*
|
|
* The union is read out of `src/engine/events.ts` rather than hand-listed, the same way
|
|
* `test/pacing.test.ts` reads the intent union: a list maintained by hand is a list that goes
|
|
* stale, which is how this got here.
|
|
*/
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
const declared = new Set(
|
|
[...readFileSync(join(here, '../src/engine/events.ts'), 'utf8').matchAll(/type: '([a-zA-Z]+)'/g)]
|
|
.map((m) => m[1]!),
|
|
);
|
|
const narrated = new Set(
|
|
[...readFileSync(join(here, '../src/sim/narrate.ts'), 'utf8').matchAll(/case '([a-zA-Z]+)':/g)]
|
|
.map((m) => m[1]!),
|
|
);
|
|
assert.ok(declared.size > 40, `only ${declared.size} event types parsed — the parse is wrong`);
|
|
|
|
// THE INVARIANT THAT MATTERS: an event the engine can emit and `narrate` has no case for falls
|
|
// through to a placeholder, in front of a player. This is the check the old version promised.
|
|
const unnarrated = [...declared].filter((t) => !narrated.has(t));
|
|
assert.deepEqual(unnarrated, [], 'these event types can be emitted and have no narration case');
|
|
|
|
const covered = new Set<string>(SAMPLES.map((e) => e.type));
|
|
const unknown = [...covered].filter((t) => !declared.has(t));
|
|
assert.deepEqual(unknown, [], 'these samples name an event the engine no longer declares');
|
|
|
|
/**
|
|
* THE KNOWN GAP, PINNED SO IT CANNOT GROW.
|
|
*
|
|
* `SAMPLES` exercises the TEXT of 30 of the 55 declared events; the other 25 have a narration
|
|
* case (checked above) but no sample, so nothing proves their sentence is any good. Found
|
|
* 2026-09-09 — the old test built both of its sets from `SAMPLES` and compared them to each
|
|
* other, so it could only ever assert that the sample list had 30 distinct entries, and the one
|
|
* thing it could not detect was the thing its comment promised.
|
|
*
|
|
* Pinned rather than fixed: writing 25 fixtures is a job of its own, and a bad sentence is worth
|
|
* finding deliberately rather than in a rush. What this does guarantee is that a NEW event type
|
|
* cannot join the unsampled set silently.
|
|
*/
|
|
const unsampled = [...declared].filter((t) => !covered.has(t)).sort();
|
|
assert.equal(
|
|
unsampled.length,
|
|
25,
|
|
`the unsampled set changed (${unsampled.length}): add a sample for a new event, or update this count`,
|
|
);
|
|
});
|
|
|
|
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)));
|
|
});
|
|
|
|
/** A crew standing on `at`, working the given train, with one empty tank car on the drawbar. */
|
|
const express = (s: GameState, trainNumber: number, at: GridCoord): string => {
|
|
const id = s.freeTrays.pop()!;
|
|
s.trays.set(id, {
|
|
id, trainNumber, trainIsExtra: false, engineAt: 0,
|
|
consist: [{ type: 'tank', loaded: false, origin: 0 }],
|
|
direction: 'east', position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
|
|
});
|
|
return id;
|
|
};
|
|
|
|
/**
|
|
* GITEA#21 — THE GAME REFUSED, AND THE PANEL EXPLAINED SOMETHING ELSE.
|
|
*
|
|
* "I wanted to drop two empty tank cars so that the freight agents and men at work could load
|
|
* them later. I dropped the first tank car, but that was all I was allowed to do. Checked
|
|
* 'Blocked — why nothing is moving' and saw: refinery 1,0 — green box empty — nothing to load
|
|
* (needs a Freight Agent action)."
|
|
*
|
|
* Replayed from the attached save (seed 550943578, 181 intents): the crew was Train 3, and the
|
|
* engine's answer was `FREIGHT_WORKED_HERE`. Train 3 is the Express, and the Express prints "May
|
|
* drop or pick up one freight car at every location" — so THE REFUSAL WAS CORRECT and the rule
|
|
* is not what is wrong here. It resets next turn, and the Express may work a car at the next
|
|
* square this turn; that is what makes it an Express rather than a one-car-a-Stage train.
|
|
*
|
|
* What was wrong is that nothing said so. The panel whose entire job is "why is nothing moving?"
|
|
* listed the refinery's green box — a true statement about the FACILITY, and nothing to do with
|
|
* why the drop was refused — so the player was sent to fix a Freight Agent action that would not
|
|
* have helped. The rule was on the train card's own tooltip, which is not where somebody looks
|
|
* when a button they expected is missing.
|
|
*
|
|
* The panel is where a refusal gets explained, so the budget belongs in it.
|
|
*/
|
|
it('says when the Express has spent its one freight car on this square (Gitea#21)', () => {
|
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
|
const area = areaOf(s, 0);
|
|
const at = area.officeCoord;
|
|
|
|
// A crew standing at the Office working Train 3 — the Express — with a tank car still on it.
|
|
const trayId = express(s, 3, at);
|
|
|
|
s.clock.phase = 'localOps';
|
|
const turn = turnOf(s, 0);
|
|
turn.option = 'switch';
|
|
|
|
// Nothing to say before it has worked anything here.
|
|
assert.ok(
|
|
!impediments(s, 0).some((b) => /FREIGHT CAR PER LOCATION/i.test(b.why)),
|
|
'the budget was reported spent before the train had worked a car at all',
|
|
);
|
|
|
|
// Now it has set one car out here — exactly the state the save is in at intent 181.
|
|
turn.freightWorked[`${trayId}@${coordKey(at)}`] = 1;
|
|
|
|
const row = impediments(s, 0).find((b) => /FREIGHT CAR PER LOCATION/i.test(b.why));
|
|
assert.ok(
|
|
row,
|
|
`nothing explained the refusal:\n${JSON.stringify(impediments(s, 0), null, 2)}`,
|
|
);
|
|
// It must name the train, or a player with three crews out cannot tell which one it means.
|
|
assert.match(row!.where, /Train 3/);
|
|
// And it must say the limit lifts, or it reads as "this train can never work here again".
|
|
assert.match(row!.why, /turn/i);
|
|
// Amber: this is the printed rule doing its job, not a fault.
|
|
assert.equal(row!.severity, 'waiting');
|
|
});
|
|
|
|
it('leaves every other train alone — the rule is printed on 3 and 4 only (Gitea#21)', () => {
|
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
|
const area = areaOf(s, 0);
|
|
const at = area.officeCoord;
|
|
// Train 5 is The Sparrow, which prints no per-location freight limit.
|
|
const trayId = express(s, 5, at);
|
|
s.clock.phase = 'localOps';
|
|
turnOf(s, 0).option = 'switch';
|
|
turnOf(s, 0).freightWorked[`${trayId}@${coordKey(at)}`] = 1;
|
|
|
|
assert.ok(
|
|
!impediments(s, 0).some((b) => /FREIGHT CAR PER LOCATION/i.test(b.why)),
|
|
'a train with no such rule was told it had spent a budget it does not have',
|
|
);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Gitea#2 — "four porters, two passengers on the platform, and I never get the chance to work them."
|
|
*
|
|
* The engine was faithful at every step; what was missing was any way to SEE why. A Porter action
|
|
* that cannot be taken is simply absent from the menu, and this panel — the one that answers "why is
|
|
* nothing moving?" — opened with `f.kind !== 'freight'`, so a platform had never had anything to say
|
|
* for itself at all.
|
|
*/
|
|
describe('a blocked platform says why (Gitea#2)', () => {
|
|
/** Raise the Whistle Post to a working Terminal: the tier's printed numbers, applied directly. */
|
|
function platform(s: GameState) {
|
|
const area = areaOf(s, 0);
|
|
area.tier = 'terminal';
|
|
const card = area.grid.get(coordKey(area.officeCoord))!;
|
|
const f = card.facility!;
|
|
f.allows = { outbound: true, inbound: true };
|
|
f.porters = 3;
|
|
f.capacity = { outbound: 3, inbound: 3 };
|
|
return { area, f };
|
|
}
|
|
|
|
/** A tray standing on an A/D track at the Office, carrying whatever it is given. */
|
|
function atOffice(s: GameState, consist: { type: 'coach'; loaded: boolean; origin?: number }[]): void {
|
|
const area = areaOf(s, 0);
|
|
const id = s.freeTrays.pop()!;
|
|
s.trays.set(id, {
|
|
id, trainNumber: null, trainIsExtra: false, engineAt: 0, consist,
|
|
direction: 'east', position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
|
|
});
|
|
area.adOccupancy.push(id);
|
|
}
|
|
|
|
it('reports passengers standing on a platform with no train to take them', () => {
|
|
// The whole of the bug's second half: before this, `impediments` returned an EMPTY list here.
|
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
|
const { f } = platform(s);
|
|
f.outboundBox = [{ type: 'coach', loaded: true }];
|
|
const found = impediments(s, 0);
|
|
const platformRow = found.find((b) => /platform/.test(b.why));
|
|
assert.ok(platformRow, `nothing reported for the platform:\n${JSON.stringify(found, null, 2)}`);
|
|
assert.match(platformRow.why, /passengers waiting, no train at the platform/);
|
|
assert.equal(platformRow.severity, 'waiting');
|
|
});
|
|
|
|
it('names the Office by its tier rather than the word "facility"', () => {
|
|
// A Passenger Facility rides on the `office` card, so the freight branch's `geometry.facility`
|
|
// is not there to read and every passenger row read `facility 0,0` next to `mineTipple 1,-3`.
|
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
|
const { f } = platform(s);
|
|
f.outboundBox = [{ type: 'coach', loaded: true }];
|
|
const row = impediments(s, 0).find((b) => /platform/.test(b.why))!;
|
|
assert.match(row.where, /^terminal /, `the Office is unnamed: ${row.where}`);
|
|
});
|
|
|
|
it('explains the coach shortage that made the game look broken', () => {
|
|
// The reported state: a train in with passengers to set down, red slots free, four porters —
|
|
// and §9.2 needs a white coach out of the Division Yard to swap in. There was none, with eight
|
|
// more sitting in the Classification Yard that §2.2 returns only when the Division Yard is BARE.
|
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
|
const { f } = platform(s);
|
|
atOffice(s, [{ type: 'coach', loaded: true, origin: 1 }]);
|
|
s.yards.divisionYard = s.yards.divisionYard.filter((c) => !(c.type === 'coach' && !c.loaded));
|
|
s.yards.classificationYard = [
|
|
{ type: 'coach', loaded: false },
|
|
{ type: 'coach', loaded: false },
|
|
];
|
|
const row = impediments(s, 0).find((b) => /§9\.2/.test(b.why));
|
|
assert.ok(row, `the coach shortage was not explained:\n${JSON.stringify(impediments(s, 0), null, 2)}`);
|
|
assert.equal(row.severity, 'stuck', 'a train that cannot be emptied is stuck, not merely waiting');
|
|
assert.match(row.why, /2 coaches are in the Classification Yard/, `where the coaches are is not said: ${row.why}`);
|
|
assert.match(row.why, /Classification returns only when the Division Yard is bare/);
|
|
assert.ok(f.inboundBox.length === 0, 'the red slots were free — the shortage is the only cause');
|
|
});
|
|
|
|
it('says why the game ended in words, not as a raw enum (TODO #34)', () => {
|
|
/**
|
|
* The heading read `loss — revenueFloor` — the exact defect Gitea#16 was filed about on the
|
|
* playable page, still alive here a release after that was fixed, because nothing
|
|
* player-facing pointed at the developer replay. It shares `reasonSentence` with the results
|
|
* screen now, so the two cannot explain one ending in two ways.
|
|
*/
|
|
const rec = record(1234, 'standard');
|
|
for (const raw of ['revenueFloor', 'daysElapsed', 'collisionFloor']) {
|
|
assert.ok(!rec.outcome.includes(raw), `the summary still prints the raw reason "${raw}"`);
|
|
}
|
|
assert.doesNotMatch(rec.outcome, /<[^>]+>/, 'markup leaked into a heading and a console line');
|
|
assert.match(rec.outcome, /Revenue/, 'the summary says nothing about how the game went');
|
|
// And the sentence is the shared one, with this game's own numbers in it.
|
|
assert.match(rec.outcome, /closed short|last on the timetable|declared unsafe/,
|
|
'the ending is not explained in the words the results screen uses');
|
|
});
|
|
|
|
it('says nothing about a platform that is working fine', () => {
|
|
// Passengers waiting AND a train with an empty coach to take them: no impediment.
|
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
|
const { f } = platform(s);
|
|
f.outboundBox = [{ type: 'coach', loaded: true }];
|
|
atOffice(s, [{ type: 'coach', loaded: false }]);
|
|
const found = impediments(s, 0).filter((b) => /platform|§9\.2|Porters/.test(b.why));
|
|
assert.deepEqual(found, [], `a working platform reported an impediment:\n${JSON.stringify(found, null, 2)}`);
|
|
});
|
|
});
|
|
|
|
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');
|
|
// The summary says "won"/"lost" rather than the engine's `win`/`loss` (TODO #34 — it is a
|
|
// sentence for a reader now, not an enum). Mapped here so this still checks the two AGREE,
|
|
// which is what the test is for, rather than checking they are spelled the same.
|
|
assert.match(rec.outcome, new RegExp(stats.result === 'win' ? 'won' : 'lost'));
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|