Show the whole Division mainline. district now has option for auto hiding on phases where not needed. scaffold for multi-player geometry showing division. regions on mainline cards implemented. bug fix on laying rail cards. optimized replay (carried links forward). Add sounds, first pass
This commit is contained in:
@@ -299,7 +299,7 @@ describe('collisions are automatic (Gap 2)', () => {
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
|
||||
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||
}
|
||||
|
||||
s.clock.phase = 'mainline';
|
||||
@@ -322,7 +322,7 @@ describe('collisions are automatic (Gap 2)', () => {
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
mlx.card = 'plains';
|
||||
mlx.transits.push({ tray: 'x', stagesRemaining: 1, direction: 'east' });
|
||||
mlx.transits.push({ tray: 'x', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||
}
|
||||
s.trays.set('x', {
|
||||
id: 'x',
|
||||
@@ -371,7 +371,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
||||
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
}
|
||||
|
||||
// A second train at the Western Division Point wanting to follow it.
|
||||
@@ -414,7 +414,7 @@ describe('the Superintendent clearance interrupt (§8.1)', () => {
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
|
||||
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||
}
|
||||
|
||||
s.trays.set('waiting', {
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('Interlocking and Yard Office relieve the Office', () => {
|
||||
// would otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar being asserted here.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: id, stagesRemaining: 1, direction: 'east' });
|
||||
ml.transits.push({ tray: id, stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||
}
|
||||
s.clock.phase = 'mainline';
|
||||
s.movedThisPhase = new Set();
|
||||
@@ -261,7 +261,7 @@ describe('ABS Signals amend the collision rule', () => {
|
||||
// pass, so the following train would never be held and there would be nothing for ABS to do.
|
||||
ml.card = 'plains';
|
||||
ml.absSignals = true;
|
||||
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
||||
ml.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
}
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
||||
@@ -301,7 +301,7 @@ describe('Telegraph, Telephone and Radio dispatch meets', () => {
|
||||
// otherwise decide this test's outcome — and Double Track / Uncontrolled Siding set
|
||||
// `trainsMayPass`, which legitimately removes the §8.1 bar this test is about.
|
||||
ml.card = 'plains';
|
||||
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, direction: 'west' });
|
||||
ml.transits.push({ tray: 'oncoming', stagesRemaining: 2, stagesTotal: 2, direction: 'west' });
|
||||
}
|
||||
s.trays.set('mine', {
|
||||
id: 'mine', trainNumber: 9, trainIsExtra: false, engineFront: true,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import { createGame } from '../src/engine/setup.ts';
|
||||
import type { GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
|
||||
import { coordKey } from '../src/engine/state.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
mode: 'solitaire',
|
||||
@@ -146,7 +147,7 @@ describe('placing a Mainline modifier', () => {
|
||||
it('will not modify a card with a train on it', () => {
|
||||
const s = game();
|
||||
const node = pinned(s, 1, 'heavyGrade');
|
||||
node.transits.push({ tray: 'someone', stagesRemaining: 1, direction: 'east' });
|
||||
node.transits.push({ tray: 'someone', stagesRemaining: 1, stagesTotal: 1, direction: 'east' });
|
||||
drawTurn(s);
|
||||
const cardId = hand(s, 'mainlineModifier', 'brakeman');
|
||||
assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'TRAIN_ON_CARD');
|
||||
@@ -207,7 +208,7 @@ describe('Red Flags protect a stopped train', () => {
|
||||
/** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */
|
||||
function rearEnder(s: GameState) {
|
||||
const node = pinned(s, 1, 'plains');
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
|
||||
node.transits.push({ tray: 'ahead', stagesRemaining: 2, stagesTotal: 2, direction: 'east' });
|
||||
s.trays.set('ahead', {
|
||||
id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
|
||||
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
||||
@@ -534,3 +535,63 @@ describe("a train is made up to its card's consist (§8.2)", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('regions on a Mainline card (§2.1, §8.2)', () => {
|
||||
/** Put one train mid-crossing and ask the view where the map should draw it. */
|
||||
const regionFor = (stagesTotal: number, stagesRemaining: number): { region: number; regions: number } => {
|
||||
const s = createGame({
|
||||
id: 'reg',
|
||||
seed: 4,
|
||||
config: {
|
||||
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Solitaire'],
|
||||
});
|
||||
const node = s.division.nodes.find((n) => n.kind === 'mainline');
|
||||
assert.ok(node && node.kind === 'mainline');
|
||||
const tray = [...s.trays.keys()][0]!;
|
||||
node.transits.push({ tray, stagesRemaining, stagesTotal, direction: 'east' });
|
||||
const ml = snapshot(s, [], null).division.find((n) => n.kind === 'ml');
|
||||
assert.ok(ml, 'no Mainline node in the view');
|
||||
const t = ml!.trains.flat()[0]!;
|
||||
return { region: t.region as number, regions: ml!.regions as number };
|
||||
};
|
||||
|
||||
it('divides every Mainline card into two regions', () => {
|
||||
// §2.1, and open-questions.md settled on two deliberately: at three, a Day of 12 Stages leaves
|
||||
// trains still on the road when the next Day begins, locking Crew Trays permanently.
|
||||
assert.equal(regionFor(2, 2).regions, 2);
|
||||
});
|
||||
|
||||
it('moves one region per Stage for an ordinary train on a 30 card', () => {
|
||||
// §8.2 exactly. This is the case the printed rule was written for.
|
||||
assert.equal(regionFor(2, 2).region, 0);
|
||||
assert.equal(regionFor(2, 1).region, 1);
|
||||
});
|
||||
|
||||
it('starts a fast train further along the card rather than moving it twice', () => {
|
||||
// The printed cards carry Start positions — a 60 card is crossed in one Stage, which the map
|
||||
// shows as entering at the second region. That is the same fact as "Brakemen moves your entry
|
||||
// point further along", which is how the modifier cards are drawn.
|
||||
assert.equal(regionFor(1, 1).region, 1);
|
||||
});
|
||||
|
||||
it('holds a slow train at the START, not at the exit', () => {
|
||||
// Three Stages will not fit three steps into two regions. The card's distance is fixed, so the
|
||||
// extra Stage is spent getting going — clamping the entry instead would park it at the exit and
|
||||
// read as a train that raced across and then waited.
|
||||
assert.equal(regionFor(3, 3).region, 0);
|
||||
assert.equal(regionFor(3, 2).region, 0);
|
||||
assert.equal(regionFor(3, 1).region, 1);
|
||||
});
|
||||
|
||||
it('never leaves the card it is on', () => {
|
||||
for (let total = 1; total <= 4; total++) {
|
||||
for (let left = total; left >= 1; left--) {
|
||||
const r = regionFor(total, left).region;
|
||||
assert.ok(r >= 0 && r <= 1, `total ${total}, ${left} left put the train in region ${r}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+53
-3
@@ -14,7 +14,7 @@ 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 { record, renderHtml } from '../src/sim/replay.ts';
|
||||
import { compress, rehydrateCells, record, renderHtml } from '../src/sim/replay.ts';
|
||||
import { summarize } from '../src/sim/stats.ts';
|
||||
|
||||
const config: GameConfig = {
|
||||
@@ -189,8 +189,16 @@ describe('replay HTML', () => {
|
||||
assert.deepEqual(externals, [], `external references: ${externals.join(', ')}`);
|
||||
});
|
||||
|
||||
it('embeds the frame data', () => {
|
||||
assert.match(html, /const FRAMES = \[/);
|
||||
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', () => {
|
||||
@@ -300,3 +308,45 @@ describe('replay HTML', () => {
|
||||
assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the replay wire format is lossless (regression)', () => {
|
||||
it('rebuilds every cell exactly, through the same function the page runs', () => {
|
||||
// The frames are packed before they reach the page: a card's identity and its description are
|
||||
// written once and referenced by integer, and a cell points at its facility instead of carrying
|
||||
// a second copy of it. That is 45% of the file — and it is only safe if unpacking is exact.
|
||||
//
|
||||
// `rehydrateCells` is the function the page itself runs, emitted by toString(), so this checks
|
||||
// the real thing rather than a second implementation of it.
|
||||
const rec = record(880009, 'standard', 4000);
|
||||
const packed = compress(rec.frames);
|
||||
|
||||
let checked = 0;
|
||||
let carried: unknown[] = [];
|
||||
let carriedFacs: unknown[] = [];
|
||||
for (let i = 0; i < rec.frames.length; i++) {
|
||||
const wire = packed.frames[i] as Record<string, unknown>;
|
||||
// null means "unchanged since the last frame that carried it", exactly as the page resolves it
|
||||
if (wire['cells'] !== null) carried = wire['cells'] as unknown[];
|
||||
if (wire['facilities'] !== null) carriedFacs = wire['facilities'] as unknown[];
|
||||
|
||||
const back = rehydrateCells(carried, packed.cards, packed.whats, carriedFacs);
|
||||
assert.deepEqual(back, rec.frames[i]!.cells, `frame ${i} did not survive the round trip`);
|
||||
checked++;
|
||||
}
|
||||
assert.ok(checked > 100, `only ${checked} frames checked`);
|
||||
});
|
||||
|
||||
it('writes each distinct card and description once, not once per frame', () => {
|
||||
const rec = record(880009, 'standard', 4000);
|
||||
const packed = compress(rec.frames);
|
||||
const cellsSeen = rec.frames.reduce((n, f) => n + f.cells.length, 0);
|
||||
assert.ok(
|
||||
packed.cards.length < cellsSeen / 20,
|
||||
`${packed.cards.length} card entries for ${cellsSeen} cell-frames — identity is not interned`,
|
||||
);
|
||||
assert.ok(
|
||||
packed.whats.length < cellsSeen / 20,
|
||||
`${packed.whats.length} descriptions for ${cellsSeen} cell-frames — prose is not interned`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+187
-1
@@ -12,7 +12,9 @@ import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
|
||||
import { cardDescription, describeIntent } from '../src/sim/view.ts';
|
||||
import { officeSvg } from '../src/sim/board-svg.ts';
|
||||
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
||||
import { snapshot } from '../src/sim/view.ts';
|
||||
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
||||
import {
|
||||
actionGroups,
|
||||
actionMenu,
|
||||
@@ -696,8 +698,19 @@ describe('the static build', () => {
|
||||
// page that works perfectly.
|
||||
let cache = new Map<string, Record<string, unknown>[]>();
|
||||
const found = new Map<string, Record<string, unknown>>();
|
||||
// Every element carries a classList. The page folds the district panel by toggling a class on
|
||||
// it, and a stub without one throws on load — which is a page that never starts, not a
|
||||
// cosmetic gap.
|
||||
const ownClasses = new Set<string>();
|
||||
const node: Record<string, unknown> = {
|
||||
textContent: '', style: {}, dataset: {}, onclick: null, scrollTop: 0, scrollHeight: 0,
|
||||
classList: {
|
||||
add: (c: string) => void ownClasses.add(c),
|
||||
remove: (c: string) => void ownClasses.delete(c),
|
||||
contains: (c: string) => ownClasses.has(c),
|
||||
toggle: (c: string) => (ownClasses.has(c) ? ownClasses.delete(c) : ownClasses.add(c)),
|
||||
},
|
||||
classes: ownClasses,
|
||||
// The board is SVG now, and highlighting works by finding a card group and adding a class.
|
||||
// The stub has to model that much or it cannot see whether highlighting happened at all.
|
||||
querySelector(sel: string) {
|
||||
@@ -936,3 +949,176 @@ describe('the static build', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the Division map shows the whole route', () => {
|
||||
const divisionFor = (players: number): string => {
|
||||
const s = createEngineGame({
|
||||
id: `div-${players}`,
|
||||
seed: 7,
|
||||
config: {
|
||||
mode: players === 1 ? 'solitaire' : 'competitive',
|
||||
victory: 'highestAfterDays',
|
||||
length: 'standard',
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['A', 'B', 'C', 'D'].slice(0, players),
|
||||
});
|
||||
return divisionSvg(snapshot(s, [], null).division);
|
||||
};
|
||||
|
||||
it('expands each Office into its Running Track, Limits to Limits', () => {
|
||||
// The map used to collapse a whole district into one "Office" box, so the track a train
|
||||
// actually runs along was invisible on the only view that shows where trains are.
|
||||
const s = createEngineGame({
|
||||
id: 'div-run',
|
||||
seed: 7,
|
||||
config: {
|
||||
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
||||
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
||||
},
|
||||
playerNames: ['Solitaire'],
|
||||
});
|
||||
const f = snapshot(s, [], null);
|
||||
const office = f.division.find((n) => n.kind === 'office');
|
||||
assert.ok(office, 'no Office node in the Division');
|
||||
assert.ok(office!.running && office!.running.length >= 3, 'the Office carries no Running Track');
|
||||
// The through route runs BETWEEN the Limits, so both ends must be Limits cards (§2.1).
|
||||
assert.equal(office!.running![0]!.kind, 'limits', 'the district does not start at a Limits sign');
|
||||
assert.equal(office!.running![office!.running!.length - 1]!.kind, 'limits', 'the district does not end at a Limits sign');
|
||||
// And the columns must run west to east without a hole.
|
||||
const cols = office!.running!.map((c) => c.col);
|
||||
for (let i = 1; i < cols.length; i++) {
|
||||
assert.ok(cols[i]! > cols[i - 1]!, 'the Running Track is not ordered west to east');
|
||||
}
|
||||
});
|
||||
|
||||
it('seats 1 to 4 players without overlapping or spilling off the canvas', () => {
|
||||
// A row, two facing rows, a horseshoe and a square. The layout is geometry with no visual
|
||||
// feedback loop, so this is the only thing standing between a change and an unreadable board.
|
||||
for (const players of [1, 2, 3, 4]) {
|
||||
const svg = divisionFor(players);
|
||||
const vb = /viewBox="0 0 (\d+) (\d+)"/.exec(svg);
|
||||
assert.ok(vb, `${players}p produced no viewBox`);
|
||||
const W = Number(vb![1]);
|
||||
const H = Number(vb![2]);
|
||||
const rects = [...svg.matchAll(/class="bs-dcell[^"]*"[^>]*><rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)]
|
||||
.map((m) => ({ x: +m[1]!, y: +m[2]!, w: +m[3]!, h: +m[4]! }));
|
||||
assert.ok(rects.length >= 5, `${players}p drew only ${rects.length} cells`);
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
const a = rects[i]!;
|
||||
assert.ok(
|
||||
a.x >= 0 && a.y >= 0 && a.x + a.w <= W && a.y + a.h <= H,
|
||||
`${players}p: a cell falls outside the canvas`,
|
||||
);
|
||||
for (let j = i + 1; j < rects.length; j++) {
|
||||
const b = rects[j]!;
|
||||
const hit = a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
|
||||
assert.ok(!hit, `${players}p: two cells overlap`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('draws the Division as a line with two ends, never as a loop', () => {
|
||||
// Seating players around a table invites exactly one misreading: that the route joins up. It
|
||||
// does not — trains enter at one Division Point and leave at the other.
|
||||
for (const players of [1, 2, 3, 4]) {
|
||||
const svg = divisionFor(players);
|
||||
assert.equal((svg.match(/class="bs-stop"/g) ?? []).length, 2, `${players}p has no pair of buffer stops`);
|
||||
assert.match(svg, /trains enter/, `${players}p does not label where trains enter`);
|
||||
assert.match(svg, /trains leave/, `${players}p does not label where trains leave`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('every square the menu offers can actually be clicked (regression)', () => {
|
||||
it('draws a target for it, inside the canvas', () => {
|
||||
// REGRESSION. Empty squares were drawn by a separate `ghostSvg` and spliced into the SVG after
|
||||
// the fact. It sized its origin over cells PLUS spots while `officeSvg` sized itself over cells
|
||||
// alone, so the two disagreed the moment a legal square lay outside the played cards — which is
|
||||
// every square that would EXTEND the district, i.e. most of them.
|
||||
//
|
||||
// Seed 555 offered three placements for a straight; the empty square was drawn at y=99 on a
|
||||
// canvas 103 tall. The list said three, the board showed two, and the third could not be
|
||||
// clicked at all. Ghosts are drawn by `officeSvg` in the same pass now, so there is one origin
|
||||
// and one canvas.
|
||||
let checked = 0;
|
||||
for (const seed of [555, 42, 909, 7, 88, 1234]) {
|
||||
const game = newGame(seed);
|
||||
const draw = actionMenu(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw');
|
||||
if (draw) submit(game, draw);
|
||||
const menu = actionMenu(game);
|
||||
const f = view(game);
|
||||
|
||||
for (const item of menu.placeable.flatMap((g) => g.items)) {
|
||||
const uniq = [...new Map(item.spots.map((sp) => [`${sp.coord.row},${sp.coord.col}`, sp.coord])).values()];
|
||||
const ghosts = uniq.filter((c) => !f.cells.some((x) => x.row === c.row && x.col === c.col));
|
||||
const svg = officeSvg(f.cells, f.runningRow, ghosts);
|
||||
const vb = /viewBox="0 0 ([\d.]+) ([\d.]+)"/.exec(svg);
|
||||
assert.ok(vb, 'the board produced no viewBox');
|
||||
const W = Number(vb![1]);
|
||||
const H = Number(vb![2]);
|
||||
|
||||
for (const c of uniq) {
|
||||
checked++;
|
||||
const key = `${c.row},${c.col}`;
|
||||
const isCard = f.cells.some((x) => x.row === c.row && x.col === c.col);
|
||||
const target = isCard ? `data-cell="${key}"` : `data-ghost="${key}"`;
|
||||
assert.ok(svg.includes(target), `${item.subject}: (${key}) is offered but has no target on the board`);
|
||||
|
||||
// `data-tip` sits between the id and the transform on a real card, so match loosely.
|
||||
const m = new RegExp(
|
||||
'data-(?:cell|ghost)="' + key + '"[^>]*transform="translate\\((-?[\\d.]+),(-?[\\d.]+)\\)"',
|
||||
).exec(svg);
|
||||
assert.ok(m, `${item.subject}: (${key}) has no position`);
|
||||
const x = Number(m![1]);
|
||||
const y = Number(m![2]);
|
||||
assert.ok(
|
||||
x >= 0 && y >= 0 && x + 132 <= W + 0.01 && y + 96 <= H + 0.01,
|
||||
`${item.subject}: (${key}) is drawn off-canvas at (${x}, ${y}) on ${W}x${H} — a legal move that cannot be clicked`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.ok(checked > 50, `only ${checked} squares checked`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the sounds fire on the events they name', () => {
|
||||
it('one cue per Stage boundary, the bell replacing the whistle at a Day', () => {
|
||||
// The model names WHAT happened and the page decides what it sounds like. Getting this wrong is
|
||||
// not a silent failure — it is a whistle every few seconds, or a bell that never rings — so the
|
||||
// count is checked against the clock rather than trusted.
|
||||
const game = newGame(555);
|
||||
const cues: Record<string, number> = {};
|
||||
let stageBoundaries = 0;
|
||||
let dayBoundaries = 0;
|
||||
|
||||
for (let i = 0; i < 4000; i++) {
|
||||
if (currentActor(game) === null) break;
|
||||
const { options, groups } = actionGroups(game);
|
||||
if (groups.length === 0 || options.length === 0) break;
|
||||
const before = { d: game.state.clock.day, s: game.state.clock.stage };
|
||||
submit(game, options[groups[0]!.actions[0]!.index]!);
|
||||
for (const c of game.cues) cues[c] = (cues[c] ?? 0) + 1;
|
||||
game.cues.length = 0;
|
||||
if (game.state.clock.day !== before.d) dayBoundaries++;
|
||||
if (game.state.clock.stage !== before.s || game.state.clock.day !== before.d) stageBoundaries++;
|
||||
}
|
||||
|
||||
assert.ok(stageBoundaries > 20, `only ${stageBoundaries} Stages elapsed — the game did not run`);
|
||||
assert.equal(cues['day'] ?? 0, dayBoundaries, 'the Day bell does not match the Days played');
|
||||
assert.equal(
|
||||
(cues['stage'] ?? 0) + (cues['day'] ?? 0),
|
||||
stageBoundaries,
|
||||
'Stage boundaries and Stage-or-Day cues disagree — some Stage ended silently, or one sounded twice',
|
||||
);
|
||||
assert.ok((cues['train'] ?? 0) > 0, 'no train was ever announced');
|
||||
});
|
||||
|
||||
it('says nothing at all before the first Stage has ended', () => {
|
||||
// A Stage BEGINNING is the previous one ending — except the first, which is the game opening.
|
||||
const game = newGame(555);
|
||||
assert.deepEqual(game.cues, [], 'the game announced a Stage ending before one had');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user