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:
Jesse
2026-08-04 11:26:18 -04:00
parent ec792dfc15
commit c3c5c59a45
16 changed files with 1249 additions and 129 deletions
+4 -4
View File
@@ -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', {
+3 -3
View File
@@ -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,
+63 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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');
});
});