A second-digit bump for a playtest read back against the save file. Nine questions were asked of one three-Day game; three were bugs, three were the rules working and undocumented, three were decisions. Every save on the test server was replayed against this build BEFORE release, which is how the cost of each rule was known before it was chosen rather than discovered after. EVERY DISTRICT OPENS ON A DEPOT. A Whistle Post has one A/D track and is not a Passenger Facility, so the opening of every game was spent unable to work a passenger and one arrival away from a collision. Two A/D tracks and passengers from Stage 1 now; "Players start with Whistle Posts, not Depots" is the harder game, set when the game is created. The deck follows the choice — starting on Depots the four Depot upgrade cards are left out, because an upgrade must be to the next tier and a Depot card at a table of Depots is a dead draw. How much easier it is showed up as a test failure rather than an argument: the cue-coverage pool needed widening from 24 seeded games to 60 before it held one collision. NO SAVE WAS STRANDED BY IT, which took care. This is the one house rule that changes how a game is DEALT rather than how it plays, so replaying a save under the wrong opening is a different railroad from intent one — silently, with no error. `withSavedOpening` fills it on the replay paths ONLY. Putting it in the resolver instead made a fresh Cutthroat game deal Whistle Posts and read as Custom, which is how the distinction was found. THREE BUGS, ALL REPORTED FROM ONE GAME AND ALL CONFIRMED ON ITS SAVE. An Office held TWO TRAINS ON ONE A/D TRACK. The capacity test passed with nothing standing, the train the Interlocking had been holding at the Limits was moved into the free slot, and the arriving train was pushed in after it without anyone asking again whether there was room — so the collision §8.3 calls for never happened. The held train keeps priority; the newcomer now takes the consequence it would have met had the held train arrived first. THE HISTORY FROZE, permanently, and the log cap was not really the cause. Each seat's "what have I sent you" bookmark was an INDEX into an array the game trims, so once a seat's bookmark reached the limit the slice returned nothing for the rest of the game — at a different moment per seat, because each holds its own. That game's log ended at exactly the cap. Lines carry a sequence number now, which survives trimming; proven by pushing twice the cap through a simulated seat. §8.1 ASKED THE WRONG QUESTION TWICE. "Trains may pass" returned `clear` before the Subdivision was looked at, so a train entering a Double Track was released however busy the rest of it was — that, not anything about Control Points, is what let Train 8 out with no ruling. And a train standing at an Office was invisible to the scan, so one about to re-enter the very Subdivision being entered counted for nothing. Capacity is the test, not presence: a Depot with a track free is not in the way; a Whistle Post with its one track taken is. THINGS THAT HAPPENED SILENTLY NOW SAY SO — a train held against a facing one, a train released from the Limits (a side effect of somebody else's arrival, so it simply appeared at the Office), and the train an Interlocking is holding, whose explanatory tooltip has existed since #99 with NO renderer ever reading the flag. WHERE A MOVE IS REFUSED, AND WHY. `exploreMoves` decides where the rails go and the pick-up restrictions are enforced afterwards in `check`, so a square the rails reached and the card forbade was reachable, un-offered, and absent from the block list with nothing said. Those squares are blocked with the rule that blocks them now, and the reasons are got by ASKING `check` rather than re-deriving: a second implementation of the rules is exactly the failure the block list exists to avoid. A train may also always recover its own caboose — X13 prints "may drop but not pick up anything", and a train needs its caboose to be made up, so one that parted with it could never legally leave again. RULES DECIDED IN SEPTEMBER AND APPLIED HERE. A Modifier must sit square against its host, no diagonals. A passenger Modifier may not be played at a Whistle Post. Both were built, measured, held back for a fortnight so a playtest could finish, and applied now. A Second Section costs its card: `SECOND_SECTION` was declared in content.ts and never dealt, so the action was free and the bot ordered 26 accidental ones in a measured round. The card is dealt and spent — gating on a card the deck never holds would have deleted the mechanic rather than fixed it. THE DOCUMENTATION IS A SET OF PAGES, not five text files served as text/plain — a card reference is mostly tables, and as plain text a table is rows of pipes. Markdown is still the one copy; the build renders it, and publishes the .md beside each page. No Markdown library: this project has no runtime dependencies and one would be a poor first. The pages add what Markdown cannot carry without drifting — a nav across the set, a contents list built from the headings actually rendered, an anchor on every heading, a 70-character measure, and tables that are tables. They print as ink on paper. The references caught up with the rules, checked rather than assumed: two statements had gone from stale to misleading (the Quickstart told a new player to "get a Depot down as soon as one appears"), and four rules nobody could look up are written down — the Office tier table, §8.1 in practice, what the Circus Train pays for, and that a Realignment can be a card with no legal target. Adding one card to the deck reshuffles every seeded deal, which broke five fixtures. Each was a seed meaning "a game like this" — TODO #84, exactly — so seeds moved and pools widened rather than assertions weakening, and the clearance fixture pins its terrain the way `enhancements.test.ts` already does. The three published replays were re-recorded. Closes TODO #40, #42a, #108, #109 and #110. 1046 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
784 lines
40 KiB
TypeScript
784 lines
40 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';
|
|
import { officeProfile } from '../src/engine/content.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,
|
|
// NO house rules, deliberately: `record()` names none either, so both take today's defaults and
|
|
// "does not drift from the engine" below compares two games that were dealt the same way.
|
|
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 },
|
|
// Sampled rather than left to swell the unsampled count: this sentence is one a player reads at
|
|
// the table every third Stage, so its text is worth exercising.
|
|
{ type: 'superintendentChanged', player: 1, stage: 6 },
|
|
{ type: 'localOpsOptionChosen', player: 0, option: 'switch' },
|
|
{ type: 'trayMoved', player: 0, trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5, movesAllowed: 6 },
|
|
{ type: 'switchingEnded', player: 0, movesUsed: 3, movesAllowed: 6, lastMove: { trayId: 't0', to: { row: 0, col: 1 } } },
|
|
{ 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 } },
|
|
// Sampled from the start rather than joining the unsampled 25: an idle Freight Agent is the ONE
|
|
// line whose whole job is to be read when nothing happened, so an empty or fallback sentence
|
|
// would reproduce the silence it exists to fix.
|
|
{ type: 'freightAgentIdled', player: 0 },
|
|
{ type: 'trainReleasedFromLimits', trainNumber: 8, office: 'Whistle Post', owner: 0, freedBy: 14 },
|
|
{ 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 }, trainNumber: 10, isExtra: false },
|
|
{ type: 'carPassed', player: 0, trayId: 't0', trainNumber: 10, isExtra: false },
|
|
{
|
|
type: 'makeUpShort',
|
|
trainNumber: 5,
|
|
isExtra: false,
|
|
placed: 0,
|
|
wanted: 3,
|
|
missing: ['coach'],
|
|
waiting: 14,
|
|
divisionYardHolds: 46,
|
|
},
|
|
{ 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 32 of the 57 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);
|
|
});
|
|
|
|
/**
|
|
* WHOSE OFFICE, AND WHOSE TRAIN — Jesse, playtest 2026-09-16.
|
|
*
|
|
* The line went to every seat reading "ARRIVED at the Whistle Post … You can work it in Cargo now".
|
|
* At a table of four that is one true sentence and one false one: every seat has an Office, so the
|
|
* tier alone does not say which district the train is standing in, and three of the four readers
|
|
* cannot touch it. Constructed here rather than fished out of a game so both halves are pinned
|
|
* exactly, and the resolver-less path is checked too — the replay viewers pass no names for a
|
|
* table they do not have.
|
|
*/
|
|
it('names WHOSE Office a train reached, and never tells the table they can work it', () => {
|
|
const named = narrate(
|
|
{ type: 'trainArrived', trainNumber: 3, consist: [], office: 'Whistle Post', owner: 1, expedited: false },
|
|
{ playerName: () => 'Tom' },
|
|
);
|
|
assert.match(named.text, /Tom's Whistle Post/, `the arrival did not name the Office's owner: ${named.text}`);
|
|
assert.match(named.text, /Tom can work it/, `the arrival did not say whose train it is to work: ${named.text}`);
|
|
assert.doesNotMatch(named.text, /\bYou can work it\b/i, `the arrival still addresses every reader: ${named.text}`);
|
|
|
|
// No resolver — still English, and still no raw index leaking into a sentence.
|
|
const anon = narrate({
|
|
type: 'trainArrived', trainNumber: 3, consist: [], office: 'Whistle Post', owner: 1, expedited: false,
|
|
});
|
|
assert.match(anon.text, /at the Whistle Post/, anon.text);
|
|
assert.doesNotMatch(anon.text, /undefined|\bplayer \d+\b/i, anon.text);
|
|
});
|
|
|
|
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. Filled to CAPACITY rather than to one
|
|
// train, so the fixture says what it means whatever the Office opens as: a Depot has two A/D
|
|
// tracks and one occupied is not a warning.
|
|
const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
|
|
const area = s.officeAreas.get(0)!;
|
|
area.adOccupancy = Array.from({ length: officeProfile(area.tier).adTracks }, (_, i) => `t${i}`);
|
|
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 …
|
|
*
|
|
* SIXTY, not twenty-four, since every player opens on a Depot. Two A/D tracks instead of one is
|
|
* the single biggest reason a game used to end in a wreck, so collisions went from rare to much
|
|
* rarer: measured on this stride, no crash cue appears in the first 24 seeds or the first 40,
|
|
* and the pool needs 60 to contain one. Widened rather than dropped, per the note below — §10
|
|
* is the one event a player most needs to hear.
|
|
*/
|
|
const recs = Array.from({ length: 60 }, (_, 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 rarer still now that every Office opens as a Depot — see the note above. This
|
|
// is the one cue the pool is not guaranteed to contain on every stride. If it starts failing
|
|
// after the stride, the seed count or the opening 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');
|
|
});
|
|
});
|