user card Hand becomes the action surface, the board shows the train, and a reverse stops turning it around, other bug fixes too.

This commit is contained in:
Jesse
2026-08-05 19:18:26 -04:00
parent 1c321d4cea
commit 543584a2ca
20 changed files with 1851 additions and 320 deletions
+16 -5
View File
@@ -254,8 +254,12 @@ describe('replay HTML', () => {
() => run(stub, () => 0, () => undefined),
'the page threw while rendering its first frame',
);
// render() ran, so the clock element should have been filled in.
assert.match(String(els.get('when')?.textContent ?? ''), /Day \d+/);
// render() ran, so the turn chart should have been filled in — with the same Day/Stage/phase
// chart the live game draws, from the shared renderer.
const chart = String(els.get('turnchart')?.innerHTML ?? '');
assert.match(chart, /Day \d+/, 'the turn chart was not rendered');
assert.match(chart, /Stage \d+ of 12/);
assert.match(chart, /class="tc-phase now"/, 'no phase is marked as the current one');
});
it('records what the bot chose, why, and what it passed over', () => {
@@ -394,16 +398,23 @@ describe('the replay behaves like the game it is replaying', () => {
assert.ok(kinds.has('stage'), 'no Stage ever sounded');
assert.ok(kinds.has('day'), 'no Day ever sounded');
assert.ok(kinds.has('train'), 'no train was ever announced');
// Coupling and setting out are what a switching move IS, and both were silent — a car left the
// board and only history said where it went.
assert.ok(kinds.has('couple'), 'coupling a cut never made a sound');
assert.ok(kinds.has('drop'), 'setting cars out never made a sound');
// One cue per Stage boundary, the bell replacing the whistle at a Day — the same accounting the
// live game is held to.
// One CLOCK cue per Stage boundary, the bell replacing the whistle at a Day — the same
// accounting the live game is held to. The switching cues are counted separately: they follow
// what a crew does, not what the clock does.
let boundaries = 0;
for (let k = 1; k < rec.frames.length; k++) {
const a = rec.frames[k - 1]!;
const b = rec.frames[k]!;
if (a.day !== b.day || a.stage !== b.stage) boundaries++;
}
const clock = withCues.flatMap((f) => f.cues ?? []).filter((c) => c !== 'train').length;
const clock = withCues
.flatMap((f) => f.cues ?? [])
.filter((c) => c === 'stage' || c === 'day').length;
assert.equal(clock, boundaries, 'Stage boundaries and Stage-or-Day cues disagree');
});
+43
View File
@@ -901,3 +901,46 @@ describe('a turnout may be laid to face another, making a crossover', () => {
assert.ok(!has(dests, -1, 0), 'and may not stop on the turnout itself');
});
});
// ---------------------------------------------------------------------------
describe('backing up does not turn the train around', () => {
it('keeps the engine pointing the same way through a reverse move', () => {
// REPORTED as forward/reverse being scrambled. `facing` is which way the ENGINE points, and it
// was reset to the direction of travel on EVERY move — so one reverse move silently spun the
// train about, everything read "forward" again, and a run-around became pointless: you could
// change ends for free by backing up twice.
const s = gameWith(
areaFrom(
{
[coordKey(at(0, 0))]: straight(),
[coordKey(at(0, 1))]: straight(),
[coordKey(at(0, 2))]: straight(),
},
at(0, 5),
),
);
const id = 'crew';
s.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 0,
consist: [], direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: at(0, 1) }, movesUsed: 0,
} as never);
s.clock.phase = 'localOps';
s.clock.currentActor = 0;
s.turn.option = 'switch';
s.turn.movesRemaining = 6;
// Running forward, east: the engine leads, so it still points east.
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 2), reverse: false }).ok);
assert.equal(s.trays.get(id)!.facing, 'e', 'a forward move must leave the engine leading');
// Backing up, west: the engine trails. It is still pointing east.
assert.ok(applyIntent(s, 0, { type: 'switch.move', trayId: id, to: at(0, 0), reverse: true }).ok);
assert.equal(
s.trays.get(id)!.facing,
'e',
'backing up turned the train around — a run-around would then be pointless',
);
});
});
+378 -4
View File
@@ -240,6 +240,31 @@ describe('the action menu presents choices the way they are made', () => {
assert.equal(f.salvage.top, cardName(game.state, spare), 'the top of the Salvage Yard is not named');
});
it('offers BOTH rotations of a turnout, each with the shape it would lay', () => {
// REGRESSION, and the reason rotating a turnout felt impossible. The action list drops duplicate
// labels, and `describeIntent` for a card play said only "play X at (0, 1)" — so a turnout's two
// orientations produced the same label and the second was discarded before the menu ever saw it.
// The rotation is the entire decision for a turnout, and it could not be made.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
dealTrack(game, 'turnout', 'right');
const spots = actionMenu(game)
.placeable.flatMap((g) => g.items)
.flatMap((it) => it.spots)
.filter((sp) => sp.coord.row === 0 && sp.coord.col === 1);
assert.equal(spots.length, 2, 'both rotations must be offered on the same square');
assert.ok(spots.some((sp) => /turn to the south/.test(sp.label)), 'the south-diverging rotation is missing');
assert.ok(spots.some((sp) => /turn to the north/.test(sp.label)), 'the north-diverging rotation is missing');
// And each carries the rails it would actually lay, so the UI can draw it rather than only
// describe it. Taken from the engine's connections, so a preview cannot promise a shape the
// placement will not produce.
const shapes = spots.map((sp) => [...sp.links].sort().join('|')).sort();
assert.deepEqual(shapes, ['es|ew', 'we|wn'], `the two rotations do not carry distinct shapes: ${shapes.join(' / ')}`);
});
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.
@@ -1047,8 +1072,10 @@ describe('the static build', () => {
const actions = els.get('actions')!;
const grid = els.get('grid')!;
const hand = els.get('hand')!;
// Take the draw option, then pick the first placeable subject.
// Take the draw option, then pick a card's PLAY verb — the hand is the action surface now, so
// the subject is chosen on the card rather than in a separate list.
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);
@@ -1059,7 +1086,18 @@ describe('the static build', () => {
};
assert.ok(clickFirst(actions, 'button.act'), 'no action button rendered');
assert.ok(clickFirst(actions, 'button.subj'), 'no card/track subject to pick');
// Specifically the PLAY verb: a hand usually holds cards that can only be discarded, and
// discarding highlights the Department piles rather than the board.
const clickVerb = (el: Record<string, unknown>, verb: string): boolean => {
const fn = el['querySelectorAll'] as (s: string) => Record<string, unknown>[];
const target = fn
.call(el, 'button.cardact')
.find((n) => (n['dataset'] as Record<string, string>)['verb'] === verb);
if (!target) return false;
(target['onclick'] as (() => void) | null)?.();
return true;
};
assert.ok(clickVerb(hand, 'play'), 'no card in hand offers a play verb');
// Without the board stylesheet every shape is drawn black on a near-black background: the page
// looks empty even though the markup is perfect.
@@ -1137,6 +1175,342 @@ describe('the static build', () => {
}
});
it('draws the same turn chart on all three screens', () => {
// A game gets drawn in three places — the playable page, the site's replay viewer and the
// standalone replay file — and where you are in the Day is the thing a player glances at most
// often. It lived only in the playable page: a replay reported the Day and phase as two plain
// strings with none of the violet "you are here" the live game uses, so the same position looked
// like a different game depending on which screen you were on.
//
// ONE renderer, asserted here by source rather than by eye, because three copies of a chart is
// exactly the kind of thing that drifts quietly.
for (const f of ['src/web/main.ts', 'src/web/replays.ts', 'src/sim/replay.ts']) {
const src = readFileSync(join(root, f), 'utf8');
assert.match(src, /turnChartHtml/, `${f} does not use the shared turn chart`);
assert.match(src, /TURNCHART_CSS/, `${f} does not ship the shared turn-chart styling`);
}
// And no screen may keep a private copy of the phase table.
for (const f of ['src/web/main.ts', 'src/web/replays.ts', 'src/sim/replay.ts']) {
const src = readFileSync(join(root, f), 'utf8');
assert.doesNotMatch(src, /key: 'shiftChange'/, `${f} has its own copy of the phase table`);
}
});
it('puts every verb on the card it belongs to', () => {
// THE HAND IS THE ACTION SURFACE. A card used to appear twice under two different models: as a
// subject to play, and as one flat button per Department to discard. For a four-card hand that
// discard block alone was twelve buttons repeating three choices four times.
const game = newGame(555);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const menu = actionMenu(game);
const held = game.state.decks.hands.get(0) ?? [];
assert.equal(menu.hand.length, held.length, 'every card in hand needs an entry');
for (const h of menu.hand) {
assert.ok(h.name.length > 0, 'a hand entry with no name');
// A verb is only offered when the engine would accept it.
for (let slot = 0; slot < 3; slot++) {
const idx = h.discard[slot];
if (idx === null || idx === undefined) continue;
const i = menu.options[idx]!;
assert.equal(i.type, 'card.discard');
assert.equal((i as { cardId: string }).cardId, h.cardId);
assert.equal((i as { toSlot: number }).toSlot, slot, 'a discard is keyed to the wrong Department');
}
if (h.playNow !== null) {
const i = menu.options[h.playNow]!;
assert.equal(i.type, 'card.play');
assert.equal((i as { placement?: unknown }).placement, undefined, 'playNow must need no square');
}
if (h.placeKey !== null) {
const item = menu.placeable.flatMap((g) => g.items).find((it) => it.subjectKey === h.placeKey);
assert.ok(item, 'placeKey points at no placeable entry');
assert.equal(item!.spots.length, h.spots);
}
}
});
it('previews a track card in hand even when it has nowhere legal to go', () => {
// REPORTED on a left-hand curve, which showed no preview at all. The shape was read off the
// card's legal PLACEMENTS, so a card with no legal square had nothing to draw — and that is
// exactly when a player most wants to see what the piece is.
const game = newGame(775569289);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
const { cardId } = dealTrack(game, 'curved', 'left');
const entry = actionMenu(game).hand.find((h) => h.cardId === cardId);
assert.ok(entry, 'the curve is not in the hand menu');
assert.deepEqual(entry!.shapes, [['sw'], ['ne']], 'both orientations must be previewable');
assert.equal(entry!.spots, 0, 'this is the case worth guarding: nowhere legal to lay it');
});
it('describes a curve as a curve, not as a turnout', () => {
// REPORTED: the curve read "east-west track with a 45° leg", which is a turnout — a road across
// the card plus a leg off it. A curve has ONE road and nothing runs past it, which is precisely
// why it may not be laid in the Running Track.
const game = newGame(775569289);
const { cardId } = dealTrack(game, 'curved', 'left');
const what = cardDescription(game.state, cardId);
assert.doesNotMatch(what, /east-west track with/, 'a curve is still described as a turnout');
assert.match(what, /single road/);
assert.match(what, /no track runs past it/);
// And a turnout must still say it HAS a road across it.
const t = dealTrack(game, 'turnout', 'left');
assert.match(cardDescription(game.state, t.cardId), /east-west track with/);
});
it('names the Mainline card a modifier would change, and what it becomes', () => {
// REPORTED: "Realignment on Mainline card 3" — a raw node index, with no clue which stretch of
// the Division it meant or what it would do, and no tooltip either, because the action list only
// attaches one when the label happens to contain an em-dash.
const game = newGame(775569289);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
for (const [id, c] of game.state.cards) {
if (c.kind.kind === 'mainlineModifier' && c.kind.key === 'realignment') {
game.state.decks.hands.set(0, [id]);
break;
}
}
const menu = actionMenu(game);
const labels = menu.direct
.filter((g) => g.title === 'Mainline modifiers')
.flatMap((g) => g.actions)
.map((a) => a.label);
assert.ok(labels.length > 0, 'Realignment was not offered at all');
for (const l of labels) {
assert.doesNotMatch(l, /Mainline card \d/, `still naming a raw node index: ${l}`);
assert.match(l, / — /, `no tooltip half, so the button gets none: ${l}`);
assert.match(l, /converts it to|nothing here to convert/, `does not say what it does: ${l}`);
}
});
it('never strands the player with a legal move and no way to make it', () => {
// REGRESSION, and a hard softlock. Moving train make-up onto the Division Yard chips took the
// "Making up …" group out of the action list, and the PASS option went with it — so a train
// being made up when the yard held nothing it could take had no control on screen at all.
// Reported at Stage 10 of seed 775569289, Train 10 waiting at the West Division Point.
//
// The invariant is the general one: every option the engine offers must be reachable through
// SOMETHING the page renders. Menu-level, so it holds for every phase rather than the handful a
// click-through happens to visit.
const reachable = (menu: ReturnType<typeof actionMenu>): Set<number> => {
const out = new Set<number>();
for (const g of menu.direct) for (const a of g.actions) out.add(a.index);
for (const g of menu.placeable) for (const it of g.items) for (const sp of it.spots) out.add(sp.index);
for (const h of menu.hand) {
if (h.playNow !== null) out.add(h.playNow);
for (const d of h.discard) if (d !== null && d !== undefined) out.add(d);
}
if (menu.makeUp) {
for (const c of menu.makeUp.cars) out.add(c.index);
if (menu.makeUp.pass !== null) out.add(menu.makeUp.pass);
}
return out;
};
for (const seed of [775569289, 555, 430]) {
const game = newGame(seed);
for (let i = 0; i < 900 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
if (menu.options.length === 0) break;
const got = reachable(menu);
assert.ok(
got.size > 0,
`seed ${seed}: ${menu.options.length} legal options and not one of them is on screen ` +
`(phase ${game.state.clock.phase}, Day ${game.state.clock.day} Stage ${game.state.clock.stage})`,
);
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
}
});
it('renders every part of the menu it is given', () => {
// THE CHECK THAT WOULD HAVE CAUGHT THE SOFTLOCK ABOVE. The option was in the menu all along —
// `makeUp.pass` was computed correctly and simply never rendered, so the invariant on the menu
// shape passed while the page stranded the player.
//
// Coarse on purpose: it asks only that every field the menu offers is referenced by the page. A
// field nothing reads is either dead or a control that has gone missing, and both are worth a
// failing test.
const src = readFileSync(join(root, 'src/web/main.ts'), 'utf8');
for (const field of ['makeUp.title', 'makeUp.cars', 'makeUp.pass', 'h.playNow', 'h.discard', 'h.shapes']) {
assert.match(src, new RegExp(field.replace('.', '\\.')), `main.ts never reads menu ${field}`);
}
});
it('leaves the action list to what is not a card in hand', () => {
// Measured across a full game of seed 430: the widest action list was 22 buttons, of which up to
// 12 were the discard cross-product and up to 10 were "add loaded hopper"-style make-up buttons.
// Both now live on the objects already on screen — the card, and the Division Yard chip.
const game = newGame(430);
let worst = 0;
for (let i = 0; i < 400 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
const shown = menu.direct
.filter((g) => !/^(Play|Discard) a card from my hand$/.test(g.title) && !/^Making up /.test(g.title))
.reduce((n, g) => n + g.actions.length, 0);
worst = Math.max(worst, shown);
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
assert.ok(worst <= 8, `the action list still reaches ${worst} buttons`);
});
it('keys each make-up car to the yard chip that shows it', () => {
// Ten buttons reading "add loaded hopper" when the Division Yard is already on screen showing
// exactly those cars by type and load state. The yard is the surface.
const game = newGame(430);
for (let i = 0; i < 400 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
if (menu.makeUp && menu.makeUp.cars.length > 0) {
for (const car of menu.makeUp.cars) {
const intent = menu.options[car.index]!;
assert.equal(intent.type, 'newTrain.placeCar');
assert.equal((intent as { carType: string }).carType, car.carType);
assert.equal((intent as { loaded: boolean }).loaded, car.loaded);
}
assert.match(menu.makeUp.title, /Making up/, 'the make-up panel does not name the train');
return;
}
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
assert.fail('no train was ever made up, so this proves nothing');
});
it('offers ABS Signals the Mainline, not the Office Area', () => {
// REPORTED: the card says "any Mainline card" and every option offered was a square in the
// Office Area. `checkEnhancementPlacement` reads `placement.col` as a Division NODE index for a
// mainline-card enhancement, while the candidate list handed it occupied grid cells — so "(0,1)"
// was accepted because node 1 happened to be a Mainline card. The label and the meaning were
// different things.
const game = newGame(775569289);
submit(game, actionGroups(game).options.find((o) => o.type === 'localOps.choose' && o.option === 'draw')!);
for (const [id, c] of game.state.cards) {
if (c.kind.kind === 'enhancement' && c.kind.key === 'absSignals') {
game.state.decks.hands.set(0, [id]);
break;
}
}
const spots = actionMenu(game).placeable.flatMap((g) => g.items).flatMap((it) => it.spots);
assert.ok(spots.length > 0, 'ABS Signals was not offered anywhere');
for (const sp of spots) {
assert.match(sp.label, /out on the Mainline/, `not a Mainline card: ${sp.label}`);
const node = game.state.division.nodes[sp.coord.col];
assert.equal(node?.kind, 'mainline', `spot points at a ${node?.kind}, not a Mainline card`);
}
});
it('draws a train standing on a card, in order, with which way it points', () => {
// REPORTED: with a train in the station there was no way to see which cars it held or in what
// order, so the switching game could not be planned at all — "drop 1 car" says nothing when you
// cannot see what is on the back.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const id = 'crew';
game.state.trays.set(id, {
id, trainNumber: 10, trainIsExtra: false, engineAt: 0,
consist: [
{ type: 'tank', loaded: true },
{ type: 'boxcar', loaded: true },
{ type: 'caboose', loaded: false },
],
direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 0 } }, movesUsed: 0,
} as never);
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
assert.ok(cell.train, 'the card carries no train');
assert.deepEqual(cell.train!.cars, ['loaded tank', 'loaded boxcar', 'caboose'], 'car order is lost');
assert.equal(cell.train!.engineAt, 0, 'the engine has no place in the train');
assert.equal(cell.train!.facing, 'e', 'which way it points is not carried');
const svg = officeSvg([cell], area.runningRow);
assert.match(svg, /bs-t-eng/, 'the engine is not drawn');
assert.match(svg, /bs-t-ld/, 'a loaded car is not drawn');
assert.match(svg, /cab</, 'the caboose is not named on the board');
});
it('shows A/D tracks on the Office card, taken and total', () => {
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that
// decides whether the next arrival is an automatic collision (§8.3).
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const cell = view(game).cells.find((c) => c.kind === 'office')!;
assert.ok(cell.ad, 'the Office card carries no A/D count');
assert.equal(cell.ad!.of, 1, 'a Whistle Post has one A/D track');
assert.equal(cell.ad!.used, 0);
assert.match(officeSvg([cell], area.runningRow), /bs-ad/, 'no A/D pips are drawn');
});
it('names the cars a drop would set out, and which end they come off', () => {
// REPORTED: "drop 1 car(s)" — which car? And because a nose drop and a tail drop read
// identically, the action list's duplicate-label filter discarded one of them outright, so
// setting out from the front of the train could not be chosen at all.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const id = 'crew';
game.state.trays.set(id, {
id, trainNumber: null, trainIsExtra: false, engineAt: 1,
consist: [{ type: 'boxcar', loaded: true }, { type: 'caboose', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', owner: 0, coord: { row: area.runningRow, col: 1 } }, movesUsed: 0,
} as never);
const front = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1, fromNose: true });
const back = describeIntent(game.state, { type: 'switch.dropCars', trayId: id, count: 1 });
assert.match(front, /loaded boxcar/, `the front cut is not named: ${front}`);
assert.match(front, /off the front/);
assert.match(back, /caboose/, `the back cut is not named: ${back}`);
assert.match(back, /off the back/);
assert.notEqual(front, back, 'the two ends still read identically, so one is dropped from the list');
});
it('draws the side panels from one place, on both screens', () => {
// A replay is the game being WATCHED rather than played, so it should look like the game. The
// site's viewer had three panels against the play page's eight — no facilities, no blockers, no
// cards, no yards — which meant it could not answer "why is nothing moving?", the question a
// replay mostly exists to answer.
for (const f of ['src/web/main.ts', 'src/web/replays.ts']) {
const src = readFileSync(join(root, f), 'utf8');
for (const fn of ['pilesHtml', 'yardHtml', 'blockedHtml', 'facilitiesHtml']) {
assert.match(src, new RegExp(fn), `${f} does not use the shared ${fn}`);
}
assert.match(src, /PANEL_CSS/, `${f} does not ship the shared panel styling`);
}
// The HAND is the one panel that differs, and deliberately: on the play page every card carries
// its own play and discard verbs, while a replay's hand is a read-only row of what was held.
assert.match(readFileSync(join(root, 'src/web/replays.ts'), 'utf8'), /handHtml/);
assert.match(readFileSync(join(root, 'src/web/main.ts'), 'utf8'), /button class="cardact/);
// And the viewer's page must actually have somewhere to put them.
const html = readFileSync(join(dist, 'replays.html'), 'utf8');
for (const id of ['vhand', 'vdepts', 'vdivyard', 'vclsyard', 'vblocked', 'vfacs']) {
assert.match(html, new RegExp(`id="${id}"`), `the replay viewer has no #${id} panel`);
}
});
it('lets a replay change pace while it is playing', () => {
// The interval is created with whatever the speed select held when play started. Nothing re-read
// it in the site's viewer, so choosing "extra slow" mid-replay did nothing at all and the pace
// looked stuck — the standalone replay had always restarted its timer on change.
for (const [file, id] of [['src/web/replays.ts', 'vspeed'], ['src/sim/replay.ts', 'speed']]) {
const src = readFileSync(join(root, file!), 'utf8');
assert.match(
src,
new RegExp(`\\$\\('${id}'\\)[^;]*\\.onchange`),
`${file} never reacts to a speed change`,
);
}
});
it('offers the same five paces in both replay viewers', () => {
const standalone = readFileSync(join(root, 'src/sim/replay.ts'), 'utf8');
const viewer = readFileSync(join(root, 'src/web/replays.html'), 'utf8');
const paces = (src: string): string[] =>
[...src.matchAll(/<option value="\d+"[^>]*>([^<]+)<\/option>/g)].map((m) => m[1]!);
assert.deepEqual(paces(standalone), ['extra slow', 'slow', 'normal', 'fast', 'very fast']);
assert.deepEqual(paces(viewer), paces(standalone), 'the two replay viewers offer different paces');
});
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
@@ -1147,12 +1521,12 @@ describe('the static build', () => {
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');
const src = readFileSync(join(root, 'src/sim/turnchart.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)];
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');