1588 lines
76 KiB
TypeScript
1588 lines
76 KiB
TypeScript
/**
|
|
* The playable browser build.
|
|
*
|
|
* These tests drive `game.ts` exactly as the page does — take the offered actions, submit one, look
|
|
* at the new position — so "it compiles" is never mistaken for "it plays".
|
|
*/
|
|
|
|
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
import { dirname, join, resolve } from 'node:path';
|
|
|
|
import { cardDescription, cardName, describeIntent, variantLabel } from '../src/sim/view.ts';
|
|
import { variantsFor } from '../src/engine/track.ts';
|
|
import { divisionSvg, officeSvg } from '../src/sim/board-svg.ts';
|
|
import { record, renderHtml } from '../src/sim/replay.ts';
|
|
import { snapshot } from '../src/sim/view.ts';
|
|
import { createGame as createEngineGame } from '../src/engine/setup.ts';
|
|
import {
|
|
overHandLimit,
|
|
actionGroups,
|
|
actionMenu,
|
|
handPlayable,
|
|
currentActor,
|
|
fromSave,
|
|
newGame,
|
|
submit,
|
|
toSave,
|
|
view,
|
|
} from '../src/web/game.ts';
|
|
|
|
const root = join(import.meta.dirname, '..');
|
|
const dist = join(root, 'dist');
|
|
|
|
/** Play a whole game by always taking the first offered action. */
|
|
function playThrough(seed: number, maxTurns = 20_000) {
|
|
const game = newGame(seed);
|
|
let turns = 0;
|
|
for (; turns < maxTurns; turns++) {
|
|
if (currentActor(game) === null) break;
|
|
const { options, groups } = actionGroups(game);
|
|
if (groups.length === 0 || options.length === 0) break;
|
|
const first = groups[0]!.actions[0]!;
|
|
if (!submit(game, options[first.index]!)) break;
|
|
}
|
|
return { game, turns };
|
|
}
|
|
|
|
|
|
/**
|
|
* Force a specific TRACK card into hand and return the `card.play` option that would lay it.
|
|
*
|
|
* Track is drawn from the Home Office deck like everything else, so a test that needs a particular
|
|
* piece has to deal itself one rather than assume the opening hand holds it.
|
|
*/
|
|
function dealTrack(
|
|
game: ReturnType<typeof newGame>,
|
|
geometry: string,
|
|
hand: string,
|
|
where?: (o: { row: number; col: number }) => boolean,
|
|
) {
|
|
for (const [id, card] of game.state.cards) {
|
|
const k = card.kind as { kind: string; geometry?: string; hand?: string };
|
|
if (k.kind !== 'track' || k.geometry !== geometry || k.hand !== hand) continue;
|
|
game.state.decks.hands.set(0, [id]);
|
|
const option = actionGroups(game).options.find(
|
|
(o) => o.type === 'card.play' && o.cardId === id && o.placement !== undefined && (!where || where(o.placement)),
|
|
);
|
|
return { cardId: id, option };
|
|
}
|
|
throw new Error(`no track card: ${geometry}/${hand}`);
|
|
}
|
|
|
|
describe('the browser game plays', () => {
|
|
it('reaches the end of a game through the same calls the page makes', () => {
|
|
const { game, turns } = playThrough(77);
|
|
assert.ok(turns > 50, `only ${turns} decisions — the game stalled`);
|
|
assert.equal(game.state.status, 'finished');
|
|
assert.ok(game.state.outcome !== null, 'a finished game must have an outcome');
|
|
});
|
|
|
|
it('never offers an action the engine then refuses', () => {
|
|
// The action list comes from legalActions, which delegates to check — so every button on the
|
|
// page must be accepted. A rejection here means the UI and the rules disagree.
|
|
const game = newGame(21);
|
|
for (let i = 0; i < 300; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const { options, groups } = actionGroups(game);
|
|
if (groups.length === 0) break;
|
|
const pick = groups[groups.length - 1]!.actions[0]!;
|
|
assert.ok(submit(game, options[pick.index]!), 'the engine refused an offered action');
|
|
}
|
|
});
|
|
|
|
it('offers every legal action somewhere, dropping none', () => {
|
|
// Grouping is presentation. If a kind is not named in GROUP_ORDER it must still appear, or a
|
|
// legal move becomes unreachable in a way that is very hard to notice.
|
|
const game = newGame(5);
|
|
for (let i = 0; i < 120; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const { options, groups } = actionGroups(game);
|
|
if (options.length === 0) break;
|
|
const shown = new Set(groups.flatMap((g) => g.actions.map((a) => a.index)));
|
|
const kinds = new Set(options.map((o) => o.type));
|
|
const shownKinds = new Set([...shown].map((i2) => options[i2]!.type));
|
|
assert.deepEqual(shownKinds, kinds, 'a kind of legal action was not offered at all');
|
|
submit(game, options[[...shown][0]!]!);
|
|
}
|
|
});
|
|
|
|
it('restores a saved game to the same position', () => {
|
|
// A save is the seed plus the intents; replaying them must reproduce the game exactly. That is
|
|
// the payoff of event sourcing, and it is only true while the RNG stays seeded.
|
|
const game = newGame(909);
|
|
for (let i = 0; i < 80; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const { options, groups } = actionGroups(game);
|
|
if (groups.length === 0) break;
|
|
submit(game, options[groups[0]!.actions[0]!.index]!);
|
|
}
|
|
|
|
const restored = fromSave(toSave(game));
|
|
assert.equal(restored.state.players[0]!.revenue, game.state.players[0]!.revenue);
|
|
assert.equal(restored.state.clock.day, game.state.clock.day);
|
|
assert.equal(restored.state.clock.stage, game.state.clock.stage);
|
|
assert.equal(restored.state.status, game.state.status);
|
|
assert.deepEqual(view(restored).cells, view(game).cells, 'the board differs after restore');
|
|
});
|
|
|
|
it('is deterministic — the same seed deals the same game', () => {
|
|
const a = playThrough(4242).game;
|
|
const b = playThrough(4242).game;
|
|
assert.equal(a.state.players[0]!.revenue, b.state.players[0]!.revenue);
|
|
assert.deepEqual(view(a).cells, view(b).cells);
|
|
});
|
|
});
|
|
|
|
describe('the action menu presents choices the way they are made', () => {
|
|
it('offers a card ONCE, with its locations underneath', () => {
|
|
// A single turn offered 29 track buttons and 7 card buttons in one flat list, with no way to
|
|
// tell which square each referred to. Subject first, location second.
|
|
const game = newGame(555);
|
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
|
|
|
const menu = actionMenu(game);
|
|
const items = menu.placeable.flatMap((g) => g.items);
|
|
assert.ok(items.length > 0, 'nothing placeable was offered');
|
|
|
|
const keys = items.map((i) => i.subjectKey);
|
|
assert.equal(new Set(keys).size, keys.length, 'the same card appeared as two subjects');
|
|
for (const item of items) {
|
|
const labels = item.spots.map((sp) => sp.label);
|
|
assert.equal(new Set(labels).size, labels.length, `${item.subject} lists a spot twice`);
|
|
assert.ok(item.spots.length > 0, `${item.subject} has nowhere to go but was offered`);
|
|
}
|
|
});
|
|
|
|
it('loses no legal action in the reshuffle', () => {
|
|
// Splitting into direct + placeable must not drop anything: every option must still be reachable.
|
|
const game = newGame(88);
|
|
for (let i = 0; i < 120; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const menu = actionMenu(game);
|
|
if (menu.options.length === 0) break;
|
|
const reachable = new Set([
|
|
...menu.direct.flatMap((g) => g.actions.map((a) => a.index)),
|
|
...menu.placeable.flatMap((g) => g.items.flatMap((it) => it.spots.map((sp) => sp.index))),
|
|
]);
|
|
const kinds = new Set(menu.options.map((o) => o.type));
|
|
const shown = new Set([...reachable].map((k) => menu.options[k]!.type));
|
|
assert.deepEqual(shown, kinds, 'a kind of legal action became unreachable');
|
|
submit(game, menu.options[[...reachable][0]!]!);
|
|
}
|
|
});
|
|
|
|
it('names the card on top of every Department pile', () => {
|
|
// "Department 2" is unusable information: the whole point of a face-up pile is choosing it on
|
|
// sight, and only the top card may be taken.
|
|
const game = newGame(555);
|
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
|
// The draw actions are split by SOURCE now — gambling on the deck, taking a named face-up
|
|
// card, and ending the turn are three different decisions — so gather every group that offers
|
|
// one rather than looking for a single group called "Draw".
|
|
const menu = actionMenu(game);
|
|
const draws = menu.direct.filter((g) =>
|
|
g.actions.some((a) => menu.options[a.index]?.type?.startsWith('draw.')),
|
|
);
|
|
assert.ok(draws.length > 0, 'no draw actions are offered at all');
|
|
for (const g of draws) {
|
|
for (const a of g.actions) {
|
|
assert.ok(!/^draw\.|^slot \d+$/.test(a.label), `raw intent name on a button: ${a.label}`);
|
|
}
|
|
}
|
|
assert.ok(
|
|
draws.flatMap((g) => g.actions).some((a) => /from Department \d/.test(a.label)),
|
|
'Department piles are not named',
|
|
);
|
|
});
|
|
|
|
it('offers all three Departments for a discard, not one collapsed button', () => {
|
|
// REGRESSION. Every discard described itself as just "discard X", and the action list drops
|
|
// duplicate labels — so three genuinely different choices collapsed into one button and the
|
|
// Department could not be picked at all. Naming the pile, and what the card would bury, is what
|
|
// makes the choice a choice.
|
|
const game = newGame(555);
|
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
|
submit(game, actionGroups(game).options.find((o) => o.type === 'draw.fromHomeOffice')!);
|
|
|
|
const menu = actionMenu(game);
|
|
const discards = menu.direct
|
|
.flatMap((g) => g.actions)
|
|
.filter((a) => menu.options[a.index]?.type === 'card.discard');
|
|
assert.ok(discards.length > 0, 'no discard is offered at all');
|
|
|
|
const first = menu.options[discards[0]!.index]!;
|
|
assert.equal(first.type, 'card.discard');
|
|
const forThatCard = discards.filter(
|
|
(a) => (menu.options[a.index] as { cardId?: string }).cardId === (first as { cardId: string }).cardId,
|
|
);
|
|
assert.equal(forThatCard.length, 3, 'all three Departments must be separately choosable');
|
|
for (const slot of [1, 2, 3]) {
|
|
assert.ok(
|
|
forThatCard.some((a) => a.label.includes(`Department ${slot}`)),
|
|
`Department ${slot} is not offered: ${forThatCard.map((a) => a.label).join(' | ')}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it('shows the top card and the depth of the Salvage Yard too', () => {
|
|
// §6.2 sweeps the Salvage Yard back into the deck when it runs out, so watching it fill is the
|
|
// only warning a player gets that the reshuffle is coming.
|
|
const game = newGame(555);
|
|
assert.equal(view(game).salvage.depth, 0, 'nothing is salvaged at setup');
|
|
assert.equal(view(game).salvage.top, '—');
|
|
const spare = game.state.decks.homeOffice.pop()!;
|
|
game.state.decks.salvageYard.push(spare);
|
|
const f = view(game);
|
|
assert.equal(f.salvage.depth, 1);
|
|
assert.equal(f.salvage.top, cardName(game.state, spare), 'the top of the Salvage Yard is not named');
|
|
});
|
|
|
|
it('says how deep a Department pile is, so a discard can be aimed', () => {
|
|
// A discard goes ON TOP, so choosing where to put it is choosing whether to offer a card or to
|
|
// bury one a rival wants. Neither is decidable without seeing what is already stacked up.
|
|
const game = newGame(555);
|
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
|
assert.deepEqual(view(game).departmentDepth, [1, 1, 1], 'each Department starts one card deep');
|
|
|
|
// Bury one, and the count must follow.
|
|
game.state.decks.departments[1]!.push(game.state.decks.hands.get(0)![0]!);
|
|
assert.deepEqual(view(game).departmentDepth, [1, 2, 1], 'the depth does not track the pile');
|
|
const label = describeIntent(game.state, { type: 'draw.fromDepartment', slot: 1 });
|
|
assert.match(label, /1 buried beneath it/, `the buried card is invisible: ${label}`);
|
|
});
|
|
|
|
it('never offers a train card a place on the board', () => {
|
|
// A train card goes to the TIMETABLE. Seed 555 offered Extra X15 at six squares with six
|
|
// rotations each — all identical, because the placement was accepted and then ignored.
|
|
const game = newGame(555);
|
|
for (let i = 0; i < 200; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const { options } = actionGroups(game);
|
|
if (options.length === 0) break;
|
|
for (const o of options) {
|
|
if (o.type !== 'card.play') continue;
|
|
const kind = game.state.cards.get(o.cardId)?.kind.kind;
|
|
if (kind === 'timetabledTrain' || kind === 'extraTrain') {
|
|
assert.equal(o.placement, undefined, 'a train card was offered a board square');
|
|
}
|
|
}
|
|
submit(game, options[0]!);
|
|
}
|
|
});
|
|
|
|
it('names a track card by its hand, because the hand is what it can meet', () => {
|
|
// Track is drawn from the deck like everything else, so it appears in the hand rather than in a
|
|
// supply panel — and "curve" alone does not say which diagonal its 45° leg lies on, which is the
|
|
// only thing that decides what it can be joined to.
|
|
const game = newGame(31);
|
|
const names = new Set<string>();
|
|
for (const [, card] of game.state.cards) {
|
|
if (card.kind.kind === 'track') names.add(cardName(game.state, card.id));
|
|
}
|
|
assert.ok(names.has('left-hand turnout'), `turnouts are not named by hand: ${[...names].join(', ')}`);
|
|
assert.ok(names.has('right-hand curve'), `curves are not named by hand: ${[...names].join(', ')}`);
|
|
assert.ok(names.has('straight'), 'a straight has no hand and must not claim one');
|
|
assert.ok(![...names].some((n) => /none/.test(n)), 'un-handed pieces should not say "none"');
|
|
});
|
|
});
|
|
|
|
describe('the board shows freight work happening', () => {
|
|
it('moves the load across MEN | AT | WORK on the card itself', () => {
|
|
// Advancing a load changed only the side panel, so the card being worked showed nothing. The
|
|
// load crossing green -> MEN|AT|WORK -> a spotted car IS freight; the printed cards put these
|
|
// squares under the track for exactly this reason.
|
|
const game = newGame(5);
|
|
const area = game.state.officeAreas.get(0)!;
|
|
area.grid.set('-1,0', {
|
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
|
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
|
facility: {
|
|
kind: 'freight', subtype: 'mineTipple',
|
|
allows: { outbound: true, inbound: false },
|
|
outboundBox: [{ type: 'hopper', loaded: true }], inboundBox: [],
|
|
capacity: { outbound: 1, inbound: 0 },
|
|
menAtWork: [null, null, null],
|
|
industryTrack: { length: 2, cars: [{ type: 'hopper', loaded: false }] },
|
|
laborers: 3, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
|
},
|
|
} as never);
|
|
|
|
const filled = (): string[] => {
|
|
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
|
|
const svg = officeSvg([cell], 0);
|
|
return [...svg.matchAll(/class="bs-wb (bs-\w+)( bs-occ)?"/g)].map(
|
|
(m) => m[1]!.replace('bs-', '') + (m[2] ? '=FULL' : ''),
|
|
);
|
|
};
|
|
|
|
assert.deepEqual(filled(), ['green=FULL', 'maw', 'maw', 'maw', 'red'], 'the waiting load is not drawn');
|
|
|
|
game.state.clock.phase = 'loadUnload';
|
|
game.state.clock.currentActor = 0;
|
|
assert.ok(submit(game, { type: 'laborer.startLoad', at: { row: -1, col: 0 } }));
|
|
assert.deepEqual(filled(), ['green', 'maw=FULL', 'maw', 'maw', 'red'], 'starting a load is invisible');
|
|
|
|
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 0 }));
|
|
assert.deepEqual(filled(), ['green', 'maw', 'maw=FULL', 'maw', 'red'], 'advancing a load is invisible');
|
|
|
|
assert.ok(submit(game, { type: 'laborer.advanceLoad', at: { row: -1, col: 0 }, box: 1 }));
|
|
assert.deepEqual(filled(), ['green', 'maw', 'maw', 'maw=FULL', 'red'], 'the load did not reach WORK');
|
|
});
|
|
|
|
it('names where the work has got to, in the tooltip', () => {
|
|
const game = newGame(5);
|
|
const area = game.state.officeAreas.get(0)!;
|
|
area.grid.set('-1,0', {
|
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
|
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
|
facility: {
|
|
kind: 'freight', subtype: 'mineTipple',
|
|
allows: { outbound: true, inbound: false },
|
|
outboundBox: [], inboundBox: [],
|
|
capacity: { outbound: 1, inbound: 0 },
|
|
menAtWork: [null, { type: 'hopper', dir: 'out' }, null],
|
|
industryTrack: { length: 2, cars: [] },
|
|
laborers: 2, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
|
},
|
|
} as never);
|
|
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
|
|
assert.match(cell.what, /a load is on AT/, 'the card does not say where the work has got to');
|
|
});
|
|
|
|
it('keeps the enhancement label clear of the pipeline squares', () => {
|
|
// `overpass` and `facingPointLocks` are placed `onCard`, so they can land on a facility. At its
|
|
// old baseline the label printed straight through the green/MEN|AT|WORK/red row.
|
|
const game = newGame(5);
|
|
const area = game.state.officeAreas.get(0)!;
|
|
area.grid.set('-1,0', {
|
|
geometry: { kind: 'facility', facility: 'mineTipple' },
|
|
baseOperationalRail: true, standing: [], modifiers: [], enhancements: ['overpass'],
|
|
facility: {
|
|
kind: 'freight', subtype: 'mineTipple',
|
|
allows: { outbound: true, inbound: false },
|
|
outboundBox: [], inboundBox: [],
|
|
capacity: { outbound: 1, inbound: 0 },
|
|
menAtWork: [null, null, null],
|
|
industryTrack: { length: 2, cars: [] },
|
|
laborers: 2, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
|
},
|
|
} as never);
|
|
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
|
|
const svg = officeSvg([cell], 0);
|
|
|
|
const enhY = Number(/class="bs-enh" x="\d+" y="(\d+)"/.exec(svg)?.[1]);
|
|
const boxTop = Math.min(
|
|
...[...svg.matchAll(/class="bs-wb [^"]*" x="\d+" y="(\d+)"/g)].map((m) => Number(m[1])),
|
|
);
|
|
assert.ok(Number.isFinite(enhY) && Number.isFinite(boxTop), 'label or squares are not drawn');
|
|
// 9px text sits roughly 9px above its baseline, so the baseline itself must clear the squares.
|
|
assert.ok(enhY <= boxTop, `enhancement label at y=${enhY} prints over the squares at y=${boxTop}`);
|
|
});
|
|
});
|
|
|
|
describe('the board draws the printed card (docs/tracks.png)', () => {
|
|
/** One card, alone, so the measurements are in card coordinates. */
|
|
const draw = (links: string[]): { x1: number; y1: number; x2: number; y2: number }[] => {
|
|
const cell = {
|
|
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [],
|
|
tray: null, cars: [], facility: null, links, what: '',
|
|
};
|
|
const svg = officeSvg([cell] as never, 0);
|
|
return [...svg.matchAll(/<line class="bs-rail" x1="([-\d.]+)" y1="([-\d.]+)" x2="([-\d.]+)" y2="([-\d.]+)"\/>/g)]
|
|
.map((m) => ({ x1: Number(m[1]), y1: Number(m[2]), x2: Number(m[3]), y2: Number(m[4]) }));
|
|
};
|
|
|
|
/** Undirected segment angles in degrees, deduplicated. 0 is horizontal. */
|
|
const angles = (links: string[]): number[] => [
|
|
...new Set(
|
|
draw(links).map((r) => {
|
|
const a = (Math.atan2(r.y2 - r.y1, r.x2 - r.x1) * 180) / Math.PI;
|
|
return Math.round((((a % 180) + 180) % 180) * 10) / 10;
|
|
}),
|
|
),
|
|
].sort((a, b) => a - b);
|
|
|
|
it('runs the through track dead centre, edge to edge', () => {
|
|
// The measurement from the printed sheet: on every one of its nine rows the east-west rail sits
|
|
// at the card's exact vertical middle. It used to be drawn at 31% down the card, on the belief
|
|
// that the art put it along the top edge.
|
|
const rails = draw(['ew']);
|
|
assert.equal(rails.length, 2, 'a track is two rails');
|
|
const H = 96;
|
|
for (const r of rails) {
|
|
assert.equal(r.y1, r.y2, 'the through track must be level');
|
|
assert.ok(Math.abs(r.y1 - H / 2) <= 3, `through rail at y=${r.y1}, not the card's middle (${H / 2})`);
|
|
assert.deepEqual([Math.min(r.x1, r.x2), Math.max(r.x1, r.x2)], [0, 166], 'it must span the whole card');
|
|
}
|
|
});
|
|
|
|
it('leaves at exactly 45°, through the middle of the north or south edge', () => {
|
|
for (const links of [['ne'], ['nw'], ['se'], ['sw'], ['ew', 'ws'], ['ew', 'en']]) {
|
|
const found = angles(links);
|
|
assert.ok(found.includes(0), `${links} has no level run along the centre line`);
|
|
assert.ok(
|
|
found.includes(45) || found.includes(135),
|
|
`${links} draws its leg at ${found.join('/')}° — the card prints 45°`,
|
|
);
|
|
assert.equal(found.length, 2, `${links} draws ${found.join('/')}° — a leg is a run and one 45° angle`);
|
|
}
|
|
});
|
|
|
|
it('draws the two diagonals as two different diagonals', () => {
|
|
// The matching rule made visible. `ne` and `sw` lie on one line and `nw` and `se` on the other,
|
|
// so a stacked pair on the same diagonal reads as one unbroken rail and a mismatched pair reads
|
|
// as the V it is. Drawn alike, the picture would claim a join the engine refuses.
|
|
assert.deepEqual(angles(['ne']), angles(['sw']), 'ne and sw must lie on the same diagonal');
|
|
assert.deepEqual(angles(['nw']), angles(['se']), 'nw and se must lie on the same diagonal');
|
|
assert.notDeepEqual(angles(['ne']), angles(['nw']), 'the two diagonals must be distinguishable');
|
|
});
|
|
|
|
it('meets the card edge at its midpoint, so abutting cards line up', () => {
|
|
// A leg that met the edge anywhere else would join to nothing, however right its angle.
|
|
const W = 166;
|
|
const H = 96;
|
|
// Only the sloping segments, and only the end of each that lands on the south edge.
|
|
const atEdge = draw(['sw'])
|
|
.filter((r) => r.y1 !== r.y2)
|
|
.map((r) => (Math.abs(r.y1 - H) < Math.abs(r.y2 - H) ? r.x1 : r.x2));
|
|
assert.equal(atEdge.length, 2, 'the leg is two rails');
|
|
const mid = (atEdge[0]! + atEdge[1]!) / 2;
|
|
assert.ok(Math.abs(mid - W / 2) < 0.5, `the leg crosses the south edge at x=${mid}, not the middle (${W / 2})`);
|
|
});
|
|
});
|
|
|
|
describe('board highlighting', () => {
|
|
it('gives every spot a coordinate to highlight', () => {
|
|
const game = newGame(555);
|
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
|
const items = actionMenu(game).placeable.flatMap((g) => g.items);
|
|
assert.ok(items.length > 0);
|
|
for (const it of items) {
|
|
for (const sp of it.spots) {
|
|
assert.equal(typeof sp.coord.row, 'number', `${it.subject} has a spot with no coordinate`);
|
|
assert.equal(typeof sp.coord.col, 'number', `${it.subject} has a spot with no coordinate`);
|
|
assert.ok(sp.label.includes(`(${sp.coord.row}, ${sp.coord.col})`), 'label and coord disagree');
|
|
}
|
|
}
|
|
});
|
|
|
|
it('highlights squares OUTSIDE the current district', () => {
|
|
// The commonest placement is just beyond the existing cards — that is what extending means. A
|
|
// grid sized only to the cards already down would highlight nothing exactly when it matters.
|
|
const game = newGame(555);
|
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
|
|
|
/**
|
|
* Open the district first, with a TURNOUT on the Running Track.
|
|
*
|
|
* Every office and industry card is a plain east-west straight and the Office carries no stub,
|
|
* so on the opening board the only legal squares are the two Limits signs — on the Running Track
|
|
* itself, inside the existing bounds, and this test would prove nothing. A turnout is what puts
|
|
* a square OFF the main in reach. The lay flag is then cleared rather than playing out a second
|
|
* turn: the subject here is the drawn canvas, not the turn economy.
|
|
*/
|
|
const { option: lay } = dealTrack(game, 'turnout', 'left');
|
|
assert.ok(lay, 'no turnout could be laid — the district can never open');
|
|
assert.ok(submit(game, lay), 'the turnout was refused');
|
|
// A curve of the same hand is what the square below that leg wants; deal one so there is
|
|
// something to highlight there.
|
|
dealTrack(game, 'curved', 'left');
|
|
|
|
const cells = view(game).cells;
|
|
const minCol = Math.min(...cells.map((c) => c.col));
|
|
const maxCol = Math.max(...cells.map((c) => c.col));
|
|
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((i) => i.spots);
|
|
|
|
assert.ok(
|
|
spots.some((sp) => sp.coord.col < minCol || sp.coord.col > maxCol || sp.coord.row < 0),
|
|
'no legal spot lies outside the current cards — the bounds test would be vacuous',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('the page explains itself', () => {
|
|
it('says what every card in hand and every face-up slot does', () => {
|
|
// A hand of bare names is unplayable: "Steam Turbines" carries no hint of its effect, and all of
|
|
// this text already existed in the content tables without reaching the screen.
|
|
for (const seed of [555, 430, 7, 99]) {
|
|
const f = view(newGame(seed));
|
|
assert.equal(f.handWhat.length, f.hand.length, 'a hand card has no description slot');
|
|
f.hand.forEach((name, i) => {
|
|
const what = f.handWhat[i]!;
|
|
assert.ok(what.length > 0, `${name} has no description`);
|
|
// camelCase leaking to the screen is the recurring failure here.
|
|
assert.doesNotMatch(what, /[a-z][A-Z]/, `raw camelCase in "${name}": ${what}`);
|
|
assert.doesNotMatch(what, /playerChoicebound|undefined|NaN/, `broken text: ${what}`);
|
|
});
|
|
f.departments.forEach((name, i) => {
|
|
if (name === '—') return;
|
|
assert.ok(f.departmentsWhat[i]!.length > 0, `face-up ${name} has no description`);
|
|
});
|
|
}
|
|
});
|
|
|
|
it('says what every card on the BOARD does, in readable words', () => {
|
|
// A played card becomes a cell with a name on it — "turnout", "Freight House", "waiting area" —
|
|
// and the explanation that was visible while it sat in hand disappears exactly when it starts
|
|
// mattering. Play a long way in so every card kind reaches the grid.
|
|
const game = newGame(111);
|
|
for (let i = 0; i < 400; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const { options } = actionGroups(game);
|
|
if (options.length === 0) break;
|
|
const pick = options.find((o) => o.type === 'card.play' && o.placement) ?? options[0]!;
|
|
if (!submit(game, pick)) break;
|
|
}
|
|
|
|
const cells = view(game).cells;
|
|
assert.ok(cells.length > 4, 'not enough of the board was built to be a real check');
|
|
for (const c of cells) {
|
|
assert.ok(c.what.length > 0, `(${c.row},${c.col}) ${c.label} has no explanation`);
|
|
// camelCase on the board is the failure that keeps recurring — labels AND descriptions.
|
|
assert.doesNotMatch(c.label, /[a-z][A-Z]/, `raw camelCase label: ${c.label}`);
|
|
assert.doesNotMatch(c.what, /[a-z][A-Z]/, `raw camelCase in "${c.label}": ${c.what}`);
|
|
}
|
|
});
|
|
|
|
it('spells out which way a turnout will and will not let a train run', () => {
|
|
// §A.1 is an ABSENT edge, not a one-way street, and it is invisible on a card that just says
|
|
// "turnout". Getting it wrong is how a crew ends up somewhere it cannot leave.
|
|
const game = newGame(3);
|
|
const area = game.state.officeAreas.get(0)!;
|
|
area.grid.set('-1,0', {
|
|
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } },
|
|
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
|
|
});
|
|
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
|
|
assert.match(cell.what, /stem east/);
|
|
assert.match(cell.what, /diverges south/);
|
|
assert.match(cell.what, /NEVER/, 'the missing edge is not spelled out');
|
|
});
|
|
|
|
it('describes every card kind in the deck, not just the easy ones', () => {
|
|
// Walk the whole deck rather than one hand: the categories differ, and a missing branch would
|
|
// show as a blank line under a card name only for the seeds that happen to deal it.
|
|
const game = newGame(1);
|
|
const seen = new Set<string>();
|
|
for (const [id, card] of game.state.cards) {
|
|
const what = cardDescription(game.state, id);
|
|
assert.ok(what.length > 0, `${card.kind.kind} has no description`);
|
|
seen.add(card.kind.kind);
|
|
}
|
|
assert.ok(seen.size >= 7, `only ${seen.size} card kinds seen — the deck should hold more`);
|
|
});
|
|
|
|
it('marks cards that cannot be played yet, and says why', () => {
|
|
// A Station upgrade drawn at a Whistle Post is dead weight — upgrades are strictly sequential
|
|
// (Gap 3b) — but the hand showed it identically to a playable card, so taking it looked like an
|
|
// action that did nothing.
|
|
const game = newGame(111);
|
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
|
submit(game, actionGroups(game).options.find((o) => o.type === 'draw.fromDepartment' && o.slot === 0)!);
|
|
|
|
const f = view(game);
|
|
const playable = handPlayable(game);
|
|
assert.equal(playable.length, f.hand.length, 'a hand card has no playable flag');
|
|
|
|
const upgrades = f.hand
|
|
.map((name, i) => ({ name, what: f.handWhat[i]!, can: playable[i]! }))
|
|
.filter((c) => /upgrade/.test(c.name));
|
|
assert.ok(upgrades.length > 0, 'this seed should deal an Office upgrade');
|
|
for (const u of upgrades) {
|
|
// Only Depot is reachable from a Whistle Post.
|
|
if (/Depot/.test(u.name)) continue;
|
|
assert.equal(u.can, false, `${u.name} should not be playable from a Whistle Post`);
|
|
assert.match(u.what, /requires the Office to be a/, `${u.name} does not say what it needs`);
|
|
}
|
|
});
|
|
|
|
it('agrees with the buttons about what is playable', () => {
|
|
// The flag is derived from legalActions, so it must never disagree with the offered actions.
|
|
const game = newGame(7);
|
|
for (let i = 0; i < 60; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const hand = game.state.decks.hands.get(0) ?? [];
|
|
const flags = handPlayable(game);
|
|
const offered = new Set(
|
|
actionGroups(game)
|
|
.options.filter((o) => o.type === 'card.play')
|
|
.map((o) => (o as { cardId: string }).cardId),
|
|
);
|
|
hand.forEach((id, k) => {
|
|
assert.equal(flags[k], offered.has(id), 'playable flag disagrees with the action list');
|
|
});
|
|
const { options } = actionGroups(game);
|
|
if (options.length === 0) break;
|
|
submit(game, options[0]!);
|
|
}
|
|
});
|
|
|
|
it('never puts an internal tray id on a BUTTON either', () => {
|
|
// The history check below missed this: `maneuver.redFlags` was labelled "Red Flags on tray3",
|
|
// so the id leaked through the action list rather than the log. Every button, every turn.
|
|
const game = newGame(2345);
|
|
for (let i = 0; i < 400; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const { options } = actionGroups(game);
|
|
if (options.length === 0) break;
|
|
for (const o of options) {
|
|
const label = describeIntent(game.state, o);
|
|
assert.doesNotMatch(label, /\btray\d+\b/, `internal tray id on a button: ${label}`);
|
|
}
|
|
if (!submit(game, options[0]!)) break;
|
|
}
|
|
});
|
|
|
|
it('describes the piece it would actually lay, not its mirror image', () => {
|
|
// REGRESSION, and a silent one: the placement was always right and only the words were wrong.
|
|
// `rotationNote` called `variantsFor(geometry)` without the hand, which answers for the LEFT-hand
|
|
// card whatever you are holding — so every right-hand turnout was offered as "stem west, through
|
|
// east, diverges south", the mirror of the card it would lay, and every right-hand curve named
|
|
// the wrong edge. A player aiming a crossover down onto a siding was reading the opposite of
|
|
// what they would get.
|
|
const say: Record<string, string> = { n: 'north', s: 'south', e: 'east', w: 'west' };
|
|
for (const geometry of ['turnout', 'curved', 'sharpCurved'] as const) {
|
|
for (const hand of ['left', 'right'] as const) {
|
|
variantsFor(geometry, hand).forEach((v, variant) => {
|
|
const label = variantLabel(geometry, variant, hand);
|
|
if (v.turnout) {
|
|
assert.match(label, new RegExp(`stem ${say[v.turnout.stem]}`), `${geometry}/${hand} v${variant}: ${label}`);
|
|
assert.match(label, new RegExp(`diverges ${say[v.turnout.diverge]}`), `${geometry}/${hand} v${variant}: ${label}`);
|
|
} else if (v.arc) {
|
|
const [a, b] = [v.arc[0]!, v.arc[1]!];
|
|
const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b];
|
|
assert.match(label, new RegExp(say[side]!), `${geometry}/${hand} v${variant}: ${label}`);
|
|
assert.match(label, new RegExp(`${say[leg]} edge`), `${geometry}/${hand} v${variant}: ${label}`);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
it('says what a placement would connect to, so a crossover can be aimed', () => {
|
|
// A turnout laid under a turnout is a crossover, and it is how a siding gets a track running
|
|
// parallel to the Running Track. It was always legal; nothing on screen said which card and
|
|
// which rotation would actually meet the leg coming down.
|
|
const game = newGame(555);
|
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
|
const { option: first } = dealTrack(game, 'turnout', 'left');
|
|
assert.ok(first && first.type === 'card.play' && first.placement, 'no turnout could be laid');
|
|
assert.ok(submit(game, first), 'the turnout was refused');
|
|
|
|
// The crossover needs a second turnout of the SAME hand — the diagonal has to match.
|
|
dealTrack(game, 'turnout', 'left');
|
|
const under = { row: first.placement.row - 1, col: first.placement.col };
|
|
const spots = actionMenu(game)
|
|
.placeable.flatMap((g) => g.items)
|
|
.filter((it) => it.subject.includes('turnout'))
|
|
.flatMap((it) => it.spots)
|
|
.filter((sp) => sp.coord.row === under.row && sp.coord.col === under.col);
|
|
|
|
assert.equal(spots.length, 1, 'exactly one turnout rotation should meet the leg coming down');
|
|
assert.match(spots[0]!.label, /diverges north/, 'the crossover turnout must point back up');
|
|
assert.match(spots[0]!.label, /joins the track above/, 'the spot must say what it connects to');
|
|
});
|
|
|
|
it('never offers to load a caboose', () => {
|
|
// A caboose carries the crew, not freight. "add loaded caboose" appeared because the button was
|
|
// formatted by hand instead of using the labeller that already knew.
|
|
const game = newGame(2345);
|
|
for (let i = 0; i < 400; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const { options } = actionGroups(game);
|
|
if (options.length === 0) break;
|
|
for (const o of options) {
|
|
assert.doesNotMatch(
|
|
describeIntent(game.state, o),
|
|
/(loaded|empty) caboose/,
|
|
'a caboose was described as loaded or empty',
|
|
);
|
|
}
|
|
if (!submit(game, options[0]!)) break;
|
|
}
|
|
});
|
|
|
|
it('says an industry on the Running Track does not belong there', () => {
|
|
// It cannot legally BE there any more — the sheet puts every industry on "Straight, Stub (not on
|
|
// Running Track)" and `check` returns ON_RUNNING_TRACK — so this is the card text holding up if
|
|
// one somehow is, not a warning about a placement a player can still make.
|
|
const game = newGame(9);
|
|
const area = game.state.officeAreas.get(0)!;
|
|
const industry = {
|
|
geometry: { kind: 'facility' as const, facility: 'grocersWarehouse' as const },
|
|
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
|
|
facility: {
|
|
kind: 'freight' as const, subtype: 'grocersWarehouse',
|
|
allows: { outbound: false, inbound: true },
|
|
outboundBox: [], inboundBox: [], capacity: { outbound: 0, inbound: 1 },
|
|
menAtWork: [null, null, null], industryTrack: { length: 2, cars: [] },
|
|
laborers: 1, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
|
|
},
|
|
};
|
|
area.grid.set(`${area.runningRow},2`, industry as never);
|
|
area.grid.set(`${area.runningRow - 1},0`, JSON.parse(JSON.stringify(industry)) as never);
|
|
|
|
const cells = view(game).cells;
|
|
const onRunning = cells.find((c) => c.row === area.runningRow && c.col === 2)!;
|
|
const below = cells.find((c) => c.row === area.runningRow - 1 && c.col === 0)!;
|
|
|
|
assert.match(onRunning.what, /RUNNING TRACK/, 'no warning on a Running Track industry');
|
|
assert.match(onRunning.what, /belong on a stub/, 'does not say where an industry belongs');
|
|
assert.doesNotMatch(below.what, /RUNNING TRACK/, 'a Secondary Track industry must not warn');
|
|
});
|
|
|
|
it('never puts an internal tray id in front of a player', () => {
|
|
// The §8.1 clearance question read "may tray2 follow tray3 into the next Subdivision?" — the
|
|
// sharpest decision in the game, phrased in internal identifiers.
|
|
const game = newGame(430);
|
|
for (let i = 0; i < 600; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const { options } = actionGroups(game);
|
|
if (options.length === 0) break;
|
|
submit(game, options[0]!);
|
|
}
|
|
for (const line of game.log) {
|
|
assert.doesNotMatch(line.text, /\btray\d+\b/, `internal tray id shown to the player: ${line.text}`);
|
|
}
|
|
});
|
|
|
|
it('spells out what each clearance ruling costs', () => {
|
|
const game = newGame(5);
|
|
const s = game.state;
|
|
s.trays.set('tray2', {
|
|
id: 'tray2', trainNumber: 4, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
s.trays.set('tray3', {
|
|
id: 'tray3', trainNumber: 7, trainIsExtra: false, engineAt: 0,
|
|
consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
|
|
});
|
|
s.clock.pendingDecision = { train: 'tray2', occupiedBy: 'tray3' };
|
|
|
|
const allow = describeIntent(s, { type: 'mainline.clearance', allow: true });
|
|
const hold = describeIntent(s, { type: 'mainline.clearance', allow: false });
|
|
for (const label of [allow, hold]) {
|
|
assert.match(label, /Train 4/, `the ruling does not name the train: ${label}`);
|
|
assert.doesNotMatch(label, /tray\d/, `internal id on a button: ${label}`);
|
|
}
|
|
// Deliberately NOT asserting that granting clearance mentions a collision: no rear-end is
|
|
// implemented, and promising one would describe a consequence the engine never delivers.
|
|
assert.match(allow, /same Mainline card/, 'granting clearance does not say what happens');
|
|
assert.match(hold, /losing the Stage/, 'holding does not mention the cost');
|
|
});
|
|
|
|
it('states the objective and whether you are keeping up', () => {
|
|
const f = view(newGame(430));
|
|
assert.equal(f.objective.target, 20);
|
|
assert.equal(f.objective.days, 5);
|
|
assert.match(f.objective.note, /of 20/);
|
|
assert.match(f.objective.note, /Days? left/);
|
|
});
|
|
|
|
it('explains what each Local Operations option spends', () => {
|
|
// The most consequential decision of the Stage, previously labelled "choose switch".
|
|
const game = newGame(555);
|
|
const choices = actionGroups(game)
|
|
.options.filter((o) => o.type === 'localOps.choose')
|
|
.map((o) => describeIntent(game.state, o));
|
|
assert.ok(choices.length > 0);
|
|
for (const c of choices) {
|
|
assert.ok(c.length > 25, `too terse to be an explanation: "${c}"`);
|
|
assert.match(c, /—/, `no explanation after the option name: "${c}"`);
|
|
}
|
|
});
|
|
|
|
it('says which way a piece will point, never "rotation 2"', () => {
|
|
const game = newGame(555);
|
|
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
|
|
const track = actionMenu(game)
|
|
.placeable.flatMap((g) => g.items)
|
|
.filter((i) => /straight|curve|turnout/.test(i.subject));
|
|
assert.ok(track.length > 0, 'no track pieces offered');
|
|
|
|
for (const item of track) {
|
|
for (const sp of item.spots) {
|
|
assert.doesNotMatch(sp.label, /rotation \d/, `opaque rotation label: ${sp.label}`);
|
|
}
|
|
// A piece with more than one orientation must say which is which, or the spots are ambiguous.
|
|
const perSquare = new Map<string, number>();
|
|
for (const sp of item.spots) {
|
|
const k = `${sp.coord.row},${sp.coord.col}`;
|
|
perSquare.set(k, (perSquare.get(k) ?? 0) + 1);
|
|
}
|
|
if ([...perSquare.values()].some((n) => n > 1)) {
|
|
assert.ok(
|
|
item.spots.some((sp) => /east|west|north|south/.test(sp.label)),
|
|
`${item.subject} offers two orientations on one square without naming them`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('replays are saves', () => {
|
|
it('rebuilds the exact position a save describes', () => {
|
|
// A replay is `{seed, history}` — a few hundred bytes — because the engine is deterministic and
|
|
// runs in the browser. Re-submitting the same intents must land on the same board, or a shared
|
|
// replay shows something the player never saw.
|
|
const game = newGame(430);
|
|
for (let i = 0; i < 250; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const { options } = actionGroups(game);
|
|
if (options.length === 0) break;
|
|
if (!submit(game, options[0]!)) break;
|
|
}
|
|
const restored = fromSave(toSave(game));
|
|
assert.equal(restored.state.players[0]!.revenue, game.state.players[0]!.revenue);
|
|
assert.equal(restored.state.clock.day, game.state.clock.day);
|
|
assert.equal(restored.state.clock.stage, game.state.clock.stage);
|
|
assert.deepEqual(view(restored).cells, view(game).cells, 'the rebuilt board differs');
|
|
assert.deepEqual(view(restored).division, view(game).division, 'the rebuilt Division differs');
|
|
});
|
|
|
|
it('stays small enough to email', () => {
|
|
const game = newGame(202);
|
|
for (let i = 0; i < 400; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const { options } = actionGroups(game);
|
|
if (options.length === 0) break;
|
|
if (!submit(game, options[0]!)) break;
|
|
}
|
|
const kb = Buffer.byteLength(JSON.stringify(toSave(game))) / 1024;
|
|
assert.ok(kb < 200, `a save is ${kb.toFixed(0)} KB — too big to be worth sharing as a file`);
|
|
});
|
|
|
|
it('publishes every save in public/replays with an index', () => {
|
|
// Static hosting cannot list a directory, so the page needs a manifest or it shows nothing.
|
|
const manifestPath = join(dist, 'replays/manifest.json');
|
|
assert.ok(existsSync(manifestPath), 'no replay manifest was built');
|
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as { file: string; title: string }[];
|
|
for (const entry of manifest) {
|
|
assert.ok(existsSync(join(dist, 'replays', entry.file)), `${entry.file} is indexed but not published`);
|
|
assert.ok(entry.title.length > 0, `${entry.file} has no title`);
|
|
const save = JSON.parse(readFileSync(join(dist, 'replays', entry.file), 'utf8')) as Record<string, unknown>;
|
|
assert.equal(typeof save['seed'], 'number', `${entry.file} has no seed`);
|
|
assert.ok(Array.isArray(save['history']), `${entry.file} has no history`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('the static build', () => {
|
|
|
|
it('builds, and needs nothing but a static host', () => {
|
|
execFileSync('node', ['scripts/build-web.ts'], { cwd: root, stdio: 'pipe' });
|
|
|
|
assert.ok(existsSync(join(dist, 'index.html')), 'no index.html');
|
|
assert.ok(existsSync(join(dist, 'web/main.js')), 'no entry script');
|
|
assert.ok(existsSync(join(dist, 'engine/apply.js')), 'the engine did not emit');
|
|
});
|
|
|
|
it('emits no import that a browser cannot resolve', () => {
|
|
// Node's type-stripping lets the source import './x.ts'; a browser cannot. The build rewrites
|
|
// those to .js, and nothing may reach for a bare module or a node: builtin.
|
|
const files: string[] = [];
|
|
const walk = (dir: string): void => {
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
if (e.isDirectory()) walk(join(dir, e.name));
|
|
else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
|
|
}
|
|
};
|
|
walk(dist);
|
|
assert.ok(files.length > 5, 'suspiciously few emitted files');
|
|
|
|
for (const f of files) {
|
|
const src = readFileSync(f, 'utf8');
|
|
// Anchor to real import/export statements. A loose `from '...'` also matches PROSE — the
|
|
// turnout comment "a train entering a turnout from \"A\"" was read as importing a module
|
|
// called A, which is the kind of false alarm that trains you to ignore a failing test.
|
|
const imports = /^\s*(?:import|export)[^'"\n]*?from\s+['"]([^'"]+)['"]/gm;
|
|
for (const m of src.matchAll(imports)) {
|
|
const spec = m[1]!.split('?')[0]!;
|
|
assert.ok(!spec.endsWith('.ts'), `${f} imports a .ts path: ${spec}`);
|
|
assert.ok(!spec.startsWith('node:'), `${f} imports a Node builtin: ${spec}`);
|
|
assert.ok(spec.startsWith('.') || spec.startsWith('/'), `${f} imports bare module: ${spec}`);
|
|
}
|
|
assert.ok(!/^[^/*]*\bprocess\.\w/m.test(src), `${f} references process`);
|
|
assert.ok(!/\brequire\(/.test(src), `${f} uses require()`);
|
|
}
|
|
});
|
|
|
|
it('renders clickable highlights on the board when a card is picked', async () => {
|
|
// The real check: load the EMITTED bundle with a DOM stub, click a card, and confirm the board
|
|
// came back with highlighted squares wired to handlers. Asserting the data has coordinates says
|
|
// nothing about whether the page draws them.
|
|
const els = new Map<string, Record<string, unknown>>();
|
|
const injected: Record<string, unknown>[] = [];
|
|
const make = (): Record<string, unknown> => {
|
|
let html = '';
|
|
// Cache per selector and clear it when innerHTML changes. Returning fresh objects each call
|
|
// would drop the handlers the page assigns — the test would then report "no button" for a
|
|
// 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) {
|
|
const m = /\[data-(cell|ghost)="([^"]+)"\]/.exec(sel);
|
|
if (!m) return null;
|
|
const key = `${m[1]}:${m[2]}`;
|
|
if (!html.includes(`data-${m[1]}="${m[2]}"`)) return null;
|
|
if (!found.has(key)) {
|
|
const classes = new Set<string>();
|
|
found.set(key, {
|
|
onclick: null,
|
|
classList: { add: (c: string) => void classes.add(c), has: (c: string) => classes.has(c) },
|
|
classes,
|
|
});
|
|
}
|
|
return found.get(key);
|
|
},
|
|
highlighted: () => [...found.values()].filter((f) => (f['classes'] as Set<string>).size > 0),
|
|
querySelectorAll(sel: string) {
|
|
const hit = cache.get(sel);
|
|
if (hit) return hit;
|
|
const out: Record<string, unknown>[] = [];
|
|
const re = sel.startsWith('[')
|
|
? /data-at="([^"]*)"/g
|
|
: new RegExp(`<button class="${sel.split('.')[1]}[^"]*"([^>]*)>`, 'g');
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(html)) !== null) {
|
|
const data: Record<string, string> = {};
|
|
if (sel.startsWith('[')) data['at'] = m[1]!;
|
|
else {
|
|
const attr = /data-(\w+)="([^"]*)"/g;
|
|
let a: RegExpExecArray | null;
|
|
while ((a = attr.exec(m[1]!)) !== null) data[a[1]!] = a[2]!;
|
|
}
|
|
out.push({ dataset: data, onclick: null });
|
|
}
|
|
cache.set(sel, out);
|
|
return out;
|
|
},
|
|
};
|
|
Object.defineProperty(node, 'innerHTML', {
|
|
get: () => html,
|
|
set: (v: string) => {
|
|
html = v;
|
|
cache = new Map();
|
|
},
|
|
});
|
|
return node;
|
|
};
|
|
|
|
// STRICT: only ids that really exist in the served page. The stub used to conjure an element
|
|
// for any id asked for, so a `$('target')` left behind after removing #target from the HTML
|
|
// would pass here and throw on load in a browser — the page would simply never start.
|
|
// `[\w-]` and not `[a-zA-Z]`: a hyphenated id is valid HTML, and the narrower pattern skipped
|
|
// those silently — which would have let a genuinely MISSING `#tc-day` pass this test too.
|
|
const served = new Set(
|
|
[...readFileSync(join(dist, 'play.html'), 'utf8').matchAll(/id="([a-zA-Z][\w-]*)"/g)].map(
|
|
(m) => m[1]!,
|
|
),
|
|
);
|
|
const g = globalThis as Record<string, unknown>;
|
|
// The page installs a tooltip layer on load, so the stub needs enough DOM to let it.
|
|
g['document'] = {
|
|
getElementById: (id: string) => {
|
|
if (!served.has(id)) return null;
|
|
if (!els.has(id)) els.set(id, make());
|
|
return els.get(id);
|
|
},
|
|
createElement: () => make(),
|
|
addEventListener: () => {},
|
|
body: { appendChild: () => {} },
|
|
// Capture injected stylesheets. The board is SVG styled entirely by class, so a page that
|
|
// renders every card correctly and never loads BOARD_CSS draws them black on black — visibly
|
|
// broken, and invisible to a stub that ignores CSS.
|
|
head: { appendChild: (node: Record<string, unknown>) => void injected.push(node) },
|
|
};
|
|
g['location'] = { search: '?seed=555' };
|
|
const store = new Map<string, string>();
|
|
g['localStorage'] = {
|
|
getItem: (k: string) => store.get(k) ?? null,
|
|
setItem: (k: string, v: string) => void store.set(k, v),
|
|
removeItem: (k: string) => void store.delete(k),
|
|
};
|
|
// No parameter properties — `erasableSyntaxOnly` forbids them, since Node strips types rather
|
|
// than compiling them.
|
|
g['URLSearchParams'] = class {
|
|
search: string;
|
|
constructor(search: string) {
|
|
this.search = search;
|
|
}
|
|
get(k: string): string | null {
|
|
return new RegExp(`${k}=([^&]*)`).exec(this.search)?.[1] ?? null;
|
|
}
|
|
};
|
|
|
|
await import(`file://${join(dist, 'web/main.js')}?t=${Date.now()}`);
|
|
|
|
const actions = els.get('actions')!;
|
|
const grid = els.get('grid')!;
|
|
|
|
// Take the draw option, then pick the first placeable subject.
|
|
const clickFirst = (el: Record<string, unknown>, sel: string): boolean => {
|
|
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
|
|
const nodes = fn.call(el, sel);
|
|
const target = nodes[0];
|
|
if (!target) return false;
|
|
(target['onclick'] as (() => void) | null)?.();
|
|
return true;
|
|
};
|
|
|
|
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
|
|
assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');
|
|
|
|
// Without the board stylesheet every shape is drawn black on a near-black background: the page
|
|
// looks empty even though the markup is perfect.
|
|
const styles = injected.map((n) => String(n['textContent'] ?? '')).join('\n');
|
|
assert.match(styles, /\.bs-card\s*\{/, 'the page never loaded the board styles — cards would be invisible');
|
|
assert.match(styles, /\.bs-rail\s*\{/, 'the page never loaded the rail styles');
|
|
|
|
const html = String(grid['innerHTML']);
|
|
// The board draws cards as addressable groups; legality is an added class or a drawn ghost.
|
|
assert.match(html, /data-cell="/, 'the board drew no addressable cards');
|
|
const lit = (grid['highlighted'] as () => unknown[])();
|
|
const ghosts = /data-ghost="/.test(html);
|
|
assert.ok(lit.length > 0 || ghosts, 'picking a card highlighted nothing on the board');
|
|
});
|
|
|
|
it('busts the cache on every module, so a deploy cannot half-load', () => {
|
|
// The first real deploy served a fresh index.html against a CACHED main.js: the HTML had
|
|
// dropped an element the old script still asked for, so the page threw `missing element:
|
|
// target` and never started. Filenames never change, so without a version query a static host
|
|
// will happily mix two builds.
|
|
const html = readFileSync(join(dist, 'index.html'), 'utf8');
|
|
const entry = /<script type="module" src="([^"]+)"/.exec(html)?.[1] ?? '';
|
|
assert.match(entry, /\?v=/, 'the entry script is not cache-busted');
|
|
|
|
const files: string[] = [];
|
|
const walk = (dir: string): void => {
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
if (e.isDirectory()) walk(join(dir, e.name));
|
|
else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
|
|
}
|
|
};
|
|
walk(dist);
|
|
|
|
let checked = 0;
|
|
for (const f of files) {
|
|
const src = readFileSync(f, 'utf8');
|
|
for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*?from\s+['"](\.[^'"]+)['"]/gm)) {
|
|
assert.match(m[1]!, /\.js\?v=/, `${f} imports ${m[1]} without a version`);
|
|
checked++;
|
|
}
|
|
}
|
|
assert.ok(checked > 5, `only ${checked} relative imports seen — the check is too weak`);
|
|
});
|
|
|
|
it('resolves every busted import to a real file', () => {
|
|
// Appending a query must not break resolution: the path before `?` still has to exist.
|
|
const files: string[] = [];
|
|
const walk = (dir: string): void => {
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
if (e.isDirectory()) walk(join(dir, e.name));
|
|
else if (e.name.endsWith('.js')) files.push(join(dir, e.name));
|
|
}
|
|
};
|
|
walk(dist);
|
|
for (const f of files) {
|
|
const src = readFileSync(f, 'utf8');
|
|
for (const m of src.matchAll(/^\s*(?:import|export)[^'"\n]*?from\s+['"](\.[^'"]+)['"]/gm)) {
|
|
const target = resolve(dirname(f), m[1]!.split('?')[0]!);
|
|
assert.ok(existsSync(target), `${f} imports ${m[1]}, which does not exist`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('asks only for elements the page actually has', () => {
|
|
// Cheap and total: compare every $('id') in the source against the ids in the served HTML.
|
|
// Getting this wrong does not degrade the page, it stops the game starting at all.
|
|
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
|
|
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
|
|
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
|
const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
|
|
// `again` is created by the game-over screen before it is looked up.
|
|
present.add('again');
|
|
for (const id of asked) {
|
|
assert.ok(present.has(id), `main.ts asks for #${id}, which the page does not contain`);
|
|
}
|
|
});
|
|
|
|
it('gives every phase the engine can be in a pill on the turn chart', () => {
|
|
// The turn chart exists to say where in the Stage you are, so a phase with no pill is a hole
|
|
// exactly when the player most needs it. `Phase` is a closed union in the engine; this fails if
|
|
// one is added and the chart is not.
|
|
const engine = readFileSync(join(root, 'src/engine/state.ts'), 'utf8');
|
|
const decl = /export type Phase =([^;]+);/.exec(engine);
|
|
assert.ok(decl, 'the Phase union moved — this test cannot see it any more');
|
|
const phases = [...decl![1]!.matchAll(/'([a-zA-Z]+)'/g)].map((m) => m[1]!);
|
|
assert.ok(phases.length >= 5, `only found ${phases.length} phases`);
|
|
|
|
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
|
|
for (const p of phases) {
|
|
assert.match(src, new RegExp(`key: '${p}'`), `no turn-chart pill for the ${p} phase`);
|
|
}
|
|
// And every pill must carry a tooltip: an icon alone does not explain a phase.
|
|
const tips = [...src.matchAll(/key: '[a-zA-Z]+',\s*\n\s*label: '[^']+',\s*\n\s*tip: '/g)];
|
|
assert.equal(tips.length, phases.length, 'a turn-chart pill has no tooltip');
|
|
|
|
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
|
assert.match(html, /id="turnchart"/, 'the page has no turn chart');
|
|
assert.doesNotMatch(html, /phase: <b id="phase">/, 'the old title-bar phase text is still there');
|
|
});
|
|
|
|
it('serves all three pages, each stamped and cache-busted', () => {
|
|
// The splash is the front door now; the game and the replay directory are separate pages.
|
|
for (const [name, entry] of [
|
|
['index.html', 'splash'],
|
|
['play.html', 'main'],
|
|
['replays.html', 'replays'],
|
|
] as const) {
|
|
const html = readFileSync(join(dist, name), 'utf8');
|
|
assert.match(html, new RegExp(`src="\\./web/${entry}\\.js\\?v=`), `${name} is not cache-busted`);
|
|
assert.doesNotMatch(html, /__BUILD__/, `${name} was published without a build stamp`);
|
|
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), `${name} fetches something external`);
|
|
}
|
|
const splash = readFileSync(join(dist, 'index.html'), 'utf8');
|
|
assert.match(splash, /href="\.\/play\.html"/, 'the splash does not link to the game');
|
|
assert.match(splash, /href="\.\/replays\.html"/, 'the splash does not link to the replays');
|
|
});
|
|
|
|
it('serves a page that loads the game as a module', () => {
|
|
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
|
assert.match(html, /<script type="module" src="\.\/web\/main\.js(\?v=[^"]*)?">/);
|
|
assert.ok(!/https?:\/\//.test(html.replace(/<!--[\s\S]*?-->/g, '')), 'page fetches something external');
|
|
for (const id of ['grid', 'division', 'actions', 'log', 'facs', 'hand', 'blocked']) {
|
|
assert.ok(html.includes(`id="${id}"`), `page is missing #${id}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
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);
|
|
|
|
// Track is drawn from the deck now, so an opening hand often holds nothing placeable at all.
|
|
// Open the district with a turnout, then hold the curve that its 45° leg calls for: that curve
|
|
// is offered a square OFF the Running Track, which is the case that used to draw off-canvas.
|
|
const { option: turnout } = dealTrack(game, 'turnout', 'left');
|
|
if (turnout) submit(game, turnout);
|
|
dealTrack(game, 'curved', 'left');
|
|
|
|
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 + 166 <= 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 >= 12, `only ${checked} squares checked — the test would be vacuous`);
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
|
|
describe('the hand limit is a limit, not a toll on drawing (regression)', () => {
|
|
it('only blocks the end of a turn when more than three cards are held', () => {
|
|
// REGRESSION. The page set "must play a card" on ANY draw, so a player who had already played
|
|
// two cards and drew back to three was still forced to spend one. §6.2 is a hand LIMIT —
|
|
// "reduce his hand to no more than three cards" — and the engine says the same: `draw.end` is
|
|
// refused with HAND_LIMIT and otherwise allowed.
|
|
const game = newGame(555);
|
|
const hand = game.state.decks.hands.get(0)!;
|
|
|
|
hand.length = 0;
|
|
assert.equal(overHandLimit(game), false, 'an empty hand blocked the turn from ending');
|
|
|
|
const ids = [...game.state.cards.keys()].slice(0, 5);
|
|
hand.push(...ids.slice(0, 3));
|
|
assert.equal(overHandLimit(game), false, 'three cards is the limit, not over it');
|
|
|
|
hand.push(ids[3]!);
|
|
assert.equal(overHandLimit(game), true, 'four cards must be played down');
|
|
|
|
// A Red Flag raises the limit by one (§6.2).
|
|
game.state.decks.redFlags.set(0, true);
|
|
assert.equal(overHandLimit(game), false, 'a Red Flag allows a fourth card');
|
|
hand.push(ids[4]!);
|
|
assert.equal(overHandLimit(game), true, 'five cards is over even with a Red Flag');
|
|
});
|
|
|
|
it('agrees with the engine about when the turn may end', () => {
|
|
// Two statements of one rule is how they drift. This asserts they cannot.
|
|
const game = newGame(909);
|
|
for (let i = 0; i < 400; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const { options, groups } = actionGroups(game);
|
|
if (groups.length === 0 || options.length === 0) break;
|
|
if (game.state.clock.phase === 'localOps' && game.state.turn.option === 'draw') {
|
|
const engineAllows = options.some((o) => o.type === 'draw.end');
|
|
assert.equal(
|
|
engineAllows,
|
|
!overHandLimit(game),
|
|
'the page and the engine disagree about whether the turn may end',
|
|
);
|
|
}
|
|
submit(game, options[groups[0]!.actions[0]!.index]!);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('a train says what its card calls for', () => {
|
|
it('explains a consist that offers only one kind of car', () => {
|
|
// Reported from playtesting on seed 22222: Extra X22 offered a caboose and nothing else, with
|
|
// no reason given. The rules are right — "Pee-Dee" is a per-diem train whose consist is one
|
|
// caboose and no cars — but the screen never said so, which reads as a broken game.
|
|
const game = newGame(22222);
|
|
const titles = new Set<string>();
|
|
for (let i = 0; i < 3000; i++) {
|
|
if (currentActor(game) === null) break;
|
|
const { options, groups } = actionGroups(game);
|
|
if (groups.length === 0 || options.length === 0) break;
|
|
for (const g of groups) if (g.kind === 'newTrain.') titles.add(g.title);
|
|
submit(game, options[groups[0]!.actions[0]!.index]!);
|
|
}
|
|
assert.ok(titles.size > 0, 'no train was ever made up');
|
|
for (const t of titles) {
|
|
assert.match(t, /its card calls for/, `a train was made up with no consist explained: ${t}`);
|
|
assert.ok(/“[^”]+”/.test(t), `the train is not named: ${t}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('the three places a game is drawn stay in step', () => {
|
|
// A game is drawn on the playable page, in the standalone replay file, and in the website's
|
|
// replay viewer. They had drifted: the viewer offered three speeds where the standalone offered
|
|
// five, and had neither the sound nor the auto-hide the other two grew. Anything a player learns
|
|
// on one screen should hold on the others.
|
|
|
|
it('offers the same playback speeds in both replay viewers', () => {
|
|
const site = readFileSync(join(dist, 'replays.html'), 'utf8');
|
|
const standalone = renderHtml(record(880009, 'standard', 600));
|
|
const speeds = (html: string): string[] =>
|
|
[...html.matchAll(/<option value="\d+"[^>]*>([a-z ]+)<\/option>/g)].map((m) => m[1]!.trim());
|
|
|
|
const a = speeds(site);
|
|
const b = speeds(standalone);
|
|
assert.ok(a.length >= 5, `the site viewer offers only ${a.length} speeds: ${a.join(', ')}`);
|
|
assert.deepEqual(
|
|
new Set(a),
|
|
new Set(b),
|
|
`the two viewers offer different speeds — site: ${a.join(', ')} / standalone: ${b.join(', ')}`,
|
|
);
|
|
for (const wanted of ['extra slow', 'very fast']) {
|
|
assert.ok(a.includes(wanted), `the site viewer has no "${wanted}" speed`);
|
|
}
|
|
});
|
|
|
|
it('offers sound and auto-hide everywhere a game is drawn', () => {
|
|
const pages: [string, string][] = [
|
|
['play.html', readFileSync(join(dist, 'play.html'), 'utf8')],
|
|
['replays.html', readFileSync(join(dist, 'replays.html'), 'utf8')],
|
|
['the standalone replay', renderHtml(record(880009, 'standard', 600))],
|
|
];
|
|
for (const [name, html] of pages) {
|
|
assert.match(html, /id="v?sound"/, `${name} has no sound control`);
|
|
assert.match(html, /id="v?districttoggle"/, `${name} has no auto-hide control`);
|
|
assert.match(html, /id="v?districtsummary"/, `${name} shows nothing when the district is folded`);
|
|
assert.match(html, /district\.folded/, `${name} has no rule to fold the district away`);
|
|
}
|
|
});
|
|
|
|
it('asks the replay viewer for no element its page lacks', () => {
|
|
// The same total check the playable page gets: a `$('vfoo')` left behind after removing #vfoo
|
|
// throws on load, and the page simply never starts.
|
|
const src = readFileSync(join(root, 'src/web/replays.ts'), 'utf8');
|
|
const asked = new Set([...src.matchAll(/\$\('([a-zA-Z][\w-]*)'\)/g)].map((m) => m[1]!));
|
|
const html = readFileSync(join(dist, 'replays.html'), 'utf8');
|
|
const present = new Set([...html.matchAll(/id="([a-zA-Z][\w-]*)"/g)].map((m) => m[1]!));
|
|
for (const id of asked) {
|
|
assert.ok(present.has(id), `replays.ts asks for #${id}, which replays.html does not contain`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('every file the build needs is actually in the repo (regression)', () => {
|
|
it('does not gitignore a source page', () => {
|
|
// REGRESSION. `.gitignore` carried `replay*.html` to catch the throwaway files generated at the
|
|
// repo root — unanchored, so it also matched `src/web/replays.html`, which is a SOURCE file.
|
|
// That page had therefore NEVER been committed: a fresh clone was missing it, and the build
|
|
// copies it unconditionally. The pattern is anchored to the root now.
|
|
//
|
|
// Asked of git rather than read out of .gitignore: the question is whether the file survives a
|
|
// clone, and only git can answer that. `check-ignore -q` exits 0 when ignored, 1 when not.
|
|
for (const page of ['src/web/index.html', 'src/web/play.html', 'src/web/replays.html']) {
|
|
let ignored: boolean;
|
|
try {
|
|
execFileSync('git', ['check-ignore', '-q', page], { cwd: root, stdio: 'pipe' });
|
|
ignored = true;
|
|
} catch {
|
|
ignored = false;
|
|
}
|
|
assert.equal(ignored, false, `${page} is gitignored — it would be missing from a fresh clone`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('the tray is an engine plus its Rolling Stock', () => {
|
|
it('shows where the engine sits, because it may pull, push, or do both', () => {
|
|
// A Crew Tray is an engine and its cars, and the engine may be PULLING (ahead of everything),
|
|
// PUSHING (behind everything) or in the middle doing both — which decides which end cars couple
|
|
// onto (§A.3) and which way the train can shove.
|
|
//
|
|
// REGRESSION on a dead field: `engineFront` was a boolean written in three places and read in
|
|
// NONE, so the engine had a position the game recorded and never used, and every consist was
|
|
// drawn as an anonymous row of cars. It is an index into the consist now.
|
|
const s = createEngineGame({
|
|
id: 'eng', seed: 1038389,
|
|
config: {
|
|
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
|
},
|
|
playerNames: ['Solitaire'],
|
|
});
|
|
|
|
// A tray built by hand, so every position can be checked rather than hoping one turns up.
|
|
// Trays are created when a train is made up, so a fresh game has none.
|
|
const trayId = s.freeTrays.pop()!;
|
|
const tray = {
|
|
id: trayId,
|
|
trainNumber: 9,
|
|
trainIsExtra: false,
|
|
engineAt: 0,
|
|
consist: [
|
|
{ type: 'boxcar', loaded: false },
|
|
{ type: 'hopper', loaded: true },
|
|
],
|
|
direction: 'east',
|
|
position: { at: 'divisionPoint', side: 'west' },
|
|
} as never;
|
|
s.trays.set(trayId, tray);
|
|
const west = s.division.nodes.find((n) => n.kind === 'divisionPoint');
|
|
if (west && west.kind === 'divisionPoint') west.holding.push(trayId);
|
|
|
|
const consistFor = (at: number): string[] => {
|
|
(tray as unknown as { engineAt: number }).engineAt = at;
|
|
const f = snapshot(s, [], null);
|
|
for (const node of f.division) {
|
|
for (const t of node.trains.flat()) {
|
|
if (t.consist.includes('ENG')) return t.consist;
|
|
}
|
|
}
|
|
return [];
|
|
};
|
|
|
|
assert.equal(consistFor(0)[0], 'ENG', 'an engine at 0 is not drawn pulling');
|
|
assert.equal(consistFor(2)[2], 'ENG', 'an engine behind the cars is not drawn pushing');
|
|
const middle = consistFor(1);
|
|
assert.equal(middle[1], 'ENG', 'an engine between the cars is not drawn in the middle');
|
|
assert.equal(middle.length, 3, 'the engine displaced a car instead of sitting between them');
|
|
});
|
|
|
|
it('shows both yards, split loaded and empty', () => {
|
|
// Rolling stock is finite and the Classification Yard returns to service only when the Division
|
|
// Yard is BARE, so watching the Division Yard run down is real information — and neither yard
|
|
// was shown anywhere.
|
|
const s = createEngineGame({
|
|
id: 'yards', seed: 1038389,
|
|
config: {
|
|
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
|
|
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
|
|
},
|
|
playerNames: ['Solitaire'],
|
|
});
|
|
const f = snapshot(s, [], null);
|
|
|
|
assert.ok(f.yards.divisionTotal > 0, 'the Division Yard is dealt nothing');
|
|
assert.equal(
|
|
f.yards.division.reduce((n, r) => n + r.loaded + r.empty, 0),
|
|
f.yards.divisionTotal,
|
|
'the Division Yard breakdown does not add up to its total',
|
|
);
|
|
assert.equal(
|
|
f.yards.classification.reduce((n, r) => n + r.loaded + r.empty, 0),
|
|
f.yards.classificationTotal,
|
|
'the Classification Yard breakdown does not add up to its total',
|
|
);
|
|
// The page must ask for the elements it fills.
|
|
const html = readFileSync(join(dist, 'play.html'), 'utf8');
|
|
for (const id of ['divyard', 'clsyard', 'divtot', 'clstot']) {
|
|
assert.ok(html.includes(`id="${id}"`), `play.html has no #${id}`);
|
|
}
|
|
});
|
|
});
|