Track becomes a deck card, Departments become decks, and trains must be made up to leave, industry restrictions enforced, update display and add a new game button

This commit is contained in:
Jesse
2026-08-05 08:28:22 -04:00
parent 2d348e1ecf
commit 9c76b1a5e2
28 changed files with 1927 additions and 394 deletions
+170 -26
View File
@@ -11,7 +11,8 @@ import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { cardDescription, describeIntent } from '../src/sim/view.ts';
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';
@@ -46,6 +47,31 @@ function playThrough(seed: number, maxTurns = 20_000) {
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);
@@ -148,8 +174,9 @@ describe('the action menu presents choices the way they are made', () => {
}
});
it('names the card in every face-up slot', () => {
// "slot 2" is unusable information: the whole point of a face-up slot is choosing it on sight.
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
@@ -166,11 +193,67 @@ describe('the action menu presents choices the way they are made', () => {
}
}
assert.ok(
draws.flatMap((g) => g.actions).some((a) => /face-up slot/.test(a.label)),
'face-up slots are not named',
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.
@@ -190,14 +273,19 @@ describe('the action menu presents choices the way they are made', () => {
}
});
it('reports what is left of the personal track supply', () => {
// Track is not drawn from the deck — 26 pieces per player, at most one laid a turn — so the
// board alone could never answer "how many straights have I got left".
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 supply = view(game).trackSupply;
assert.ok(supply.length > 0, 'no track supply reported');
assert.equal(supply.reduce((n, t) => n + t.left, 0), 26, 'the opening supply should be 26');
assert.ok(supply.every((t) => !/none/.test(t.piece)), 'un-handed pieces should not say "none"');
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"');
});
});
@@ -396,12 +484,12 @@ describe('board highlighting', () => {
* 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 lay = actionGroups(game).options.find(
(o) => o.type === 'track.lay' && o.geometry === 'turnout' && o.placement !== undefined,
);
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');
game.state.turn.laidThisTurn = false;
// 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));
@@ -445,10 +533,7 @@ describe('the page explains itself', () => {
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.find((o) => o.type === 'track.lay') ??
options[0]!;
const pick = options.find((o) => o.type === 'card.play' && o.placement) ?? options[0]!;
if (!submit(game, pick)) break;
}
@@ -551,6 +636,56 @@ describe('the page explains itself', () => {
}
});
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.
@@ -570,9 +705,10 @@ describe('the page explains itself', () => {
}
});
it('warns that an industry on the Running Track is in the way of arrivals', () => {
// §11.2 — a Facility carries its own rails, so it does NOT block traffic. But §10 makes a car
// left standing between the Limits and the Office a collision, and nothing said so.
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 = {
@@ -594,7 +730,7 @@ describe('the page explains itself', () => {
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, /pass straight through/, 'does not say traffic still passes');
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');
});
@@ -1136,6 +1272,14 @@ describe('every square the menu offers can actually be clicked (regression)', ()
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);
@@ -1163,13 +1307,13 @@ describe('every square the menu offers can actually be clicked (regression)', ()
const x = Number(m![1]);
const y = Number(m![2]);
assert.ok(
x >= 0 && y >= 0 && x + 132 <= W + 0.01 && y + 96 <= H + 0.01,
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 > 50, `only ${checked} squares checked`);
assert.ok(checked >= 12, `only ${checked} squares checked — the test would be vacuous`);
});
});