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:
+170
-26
@@ -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`);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user