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:
+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