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
+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');
});
});