v0.1.0 — undo, the crew's reach on the board, workers on the card, and four engine bugs

Fifteen items from two playtest sessions. Three that read as drawing faults were engine
bugs: cars could be added to a train that was not being made up (50 offers in 8 games),
the make-up panel merged two trains and could couple a car to the wrong one, and an
Office upgrade silently deleted what a Modifier had added. A fourth was a sentinel
inside a coordinate's own value range — a Mainline placement travelling as row -1, which
is an ordinary district row.

Trains are now drawn the way they stand: west on the left, nose toward the way the engine
faces, on both the district card and the Division chip. Undo steps back through the game
by replaying the save without its last intent. The switching walk keeps its rejections, so
the board can say why a square is not offered. Laborers and Porters are on the card, and
the rule that a district only grows outwards is finally written down.

Versions start here: third digit for fixes, second for a feature set, 1.0 for a release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GgtkX8JnvKa8y2tuJ8aQf4
This commit is contained in:
Jesse
2026-08-08 05:23:17 -04:00
co-authored by Claude Opus 5
parent f4c0f49604
commit 5d825b97d2
20 changed files with 1604 additions and 191 deletions
+272 -12
View File
@@ -14,7 +14,7 @@ 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 { timetableHtml } from '../src/web/panels.ts';
import { facilitiesHtml, timetableHtml } from '../src/web/panels.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';
@@ -28,6 +28,7 @@ import {
newGame,
submit,
toSave,
undo,
view,
} from '../src/web/game.ts';
@@ -253,7 +254,7 @@ describe('the action menu presents choices the way they are made', () => {
const spots = actionMenu(game)
.placeable.flatMap((g) => g.items)
.flatMap((it) => it.spots)
.filter((sp) => sp.coord.row === 0 && sp.coord.col === 1);
.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');
@@ -414,6 +415,104 @@ describe('the board shows freight work happening', () => {
assert.match(cell.what, /a load is on AT/, 'the card does not say where the work has got to');
});
it('captions a legal square that already has a card on it', () => {
/**
* REPORTED: "it shows on the map where the possible places are. It also looks like my depot is
* highlighted as well... New squares where I could place my track say PLACE HERE. Limits are
* also highlighted but do not."
*
* Three different acts light up the same blue — building on empty ground, EXTENDING the Running
* Track at a Limits sign, and ATTACHING an Enhancement to a card already down — and only the
* first said anything. A blue outline with no words reads as a bug, not as an offer.
*/
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const cells = view(game).cells;
const limits = cells.find((c) => c.kind === 'limits')!;
const svg = officeSvg(cells, area.runningRow, [], [
{ row: limits.row, col: limits.col, label: 'EXTEND THE RUNNING TRACK HERE' },
]);
assert.match(svg, /EXTEND THE RUNNING TRACK HERE/, 'the Limits sign is offered without a word');
// And the caption must clear the rail down the middle of the card, where the train is drawn.
const y = Number(/class="bs-legalcap"[^>]*>(?:<rect x="\d+" y="(\d+)")/.exec(svg)?.[1]);
assert.ok(y > 48, `the caption at y=${y} prints over the rail`);
});
it('says on the card which way an industry runs, and how many workers it has', () => {
/**
* TWO REPORTS, ONE CARD.
*
* "Should the display be any different for industries that are send-only versus receive-only?
* At Grocer's Warehouse, which is receive only, and Refinery, which is send only, the men at
* work boxes, the arrows and the green-red boxes look the same in both." They were different —
* by the stroke colour of one 13x12px box and the direction of a 10px chevron, which is not a
* difference a player can see.
*
* And: "how do I see the number of laborers in an industry card?" You could not. The number
* that decides every Cargo phase was in a side panel only.
*/
const game = newGame(5);
const industry = (kind: string, out: boolean, into: boolean, laborers: number) => {
game.state.officeAreas.get(0)!.grid.set('-1,0', {
geometry: { kind: 'facility', facility: kind },
baseOperationalRail: true, standing: [], modifiers: [], enhancements: [],
facility: {
kind: 'freight', subtype: kind,
allows: { outbound: out, inbound: into },
outboundBox: [], inboundBox: [],
capacity: { outbound: out ? 1 : 0, inbound: into ? 1 : 0 },
menAtWork: [null, null, null],
industryTrack: { length: 2, cars: [] },
laborers, porters: 0, usedThisStage: { laborers: 0, porters: 0 },
},
} as never);
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
return officeSvg([cell], 0);
};
const ships = industry('refinery', true, false, 2);
const receives = industry('grocersWarehouse', false, true, 1);
assert.match(ships, /SHIPS OUT/, 'a send-only industry does not say so');
assert.match(receives, /RECEIVES/, 'a receive-only industry does not say so');
assert.doesNotMatch(ships, /RECEIVES/, 'a send-only industry claims to receive');
// And the two cards must not be one another with a different tint.
const strip = (svg: string): string => svg.replace(/(SHIPS OUT|RECEIVES|L \d\/\d)/g, '');
assert.notEqual(strip(ships), strip(receives), 'the two industries draw identically');
assert.match(ships, /bs-crewlab[^>]*>L 2\/2</, 'the Laborer count is not on the card');
assert.match(receives, /bs-crewlab[^>]*>L 1\/1</, 'the Laborer count is not on the card');
});
it('shows the Office its Porters, and what a Modifier added', () => {
// REPORTED: "I added a restaurant. That gave me an extra outbound slot, so I see an extra green
// box. And it gave me an extra porter that I see no indication of anywhere." Porters were on
// the view-model and no renderer had ever drawn them.
const game = newGame(5);
const area = game.state.officeAreas.get(0)!;
const office = area.grid.get(`${area.runningRow},0`)!;
area.tier = 'depot';
office.facility!.porters = 2; // a Depot prints 1; a Restaurant beside it adds one
office.facility!.capacity = { outbound: 2, inbound: 1 };
office.facility!.allows = { outbound: true, inbound: true };
const cell = view(game).cells.find((c) => c.kind === 'office')!;
assert.match(officeSvg([cell], area.runningRow), /bs-crewlab[^>]*>P 2\/2</, 'the card does not show Porters');
assert.match(cell.what, /2 porters/, 'the tooltip still reads the printed tier, not the Office as it stands');
assert.match(cell.what, /Modifiers beside it add/, 'nothing says where the extra came from');
// And the panel must not invent a Modifier that is not there.
const plain = newGame(5);
const parea = plain.state.officeAreas.get(0)!;
parea.tier = 'depot';
const poffice = parea.grid.get(`${parea.runningRow},0`)!;
poffice.facility!.porters = 1;
poffice.facility!.capacity = { outbound: 1, inbound: 1 };
poffice.facility!.allows = { outbound: true, inbound: true };
const html = facilitiesHtml(view(plain));
assert.doesNotMatch(html, /class="added"/, 'a plain Depot is credited with a Modifier it does not have');
});
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.
@@ -517,13 +616,21 @@ describe('the board draws the printed card (docs/tracks.png)', () => {
});
describe('board highlighting', () => {
it('gives every spot a coordinate to highlight', () => {
it('gives every spot a coordinate to highlight, or says it is not on this board', () => {
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) {
// A spot with no coordinate is a placement out on the Mainline, and it must say so — the
// board cannot highlight it, so the label is the only thing telling the player where it
// goes. It used to carry the fake coordinate (-1, node), which is a real district row.
if (sp.coord === null) {
assert.equal(typeof sp.node, 'number', `${it.subject} has neither a coordinate nor a node`);
assert.match(sp.label, /out on the Mainline/, `${it.subject}: no coordinate and no explanation`);
continue;
}
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');
@@ -559,7 +666,7 @@ describe('board highlighting', () => {
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),
spots.some((sp) => sp.coord !== null && (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',
);
});
@@ -753,7 +860,7 @@ describe('the page explains itself', () => {
.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);
.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, /turn to the north/, 'the crossover turnout must point back up');
@@ -884,6 +991,7 @@ describe('the page explains itself', () => {
// 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) {
if (sp.coord === null) continue;
const k = `${sp.coord.row},${sp.coord.col}`;
perSquare.set(k, (perSquare.get(k) ?? 0) + 1);
}
@@ -917,6 +1025,61 @@ describe('replays are saves', () => {
assert.deepEqual(view(restored).division, view(game).division, 'the rebuilt Division differs');
});
it('takes the last action back, and keeps taking', () => {
/**
* "Is there a way to remove a card that has been misplayed? In a solitaire game it might be
* nice to say oops, take that back."
*
* The save IS the game, so undo is a replay without the last intent — which means the position
* it lands on must be EXACTLY the position that was there before, board, Division, clock and
* revenue. Anything less and undo becomes its own source of divergence.
*/
const game = newGame(430);
const marks: { cells: unknown; division: unknown; revenue: number; day: number; stage: number }[] = [];
const mark = (g: typeof game) => ({
cells: view(g).cells,
division: view(g).division,
revenue: g.state.players[0]!.revenue,
day: g.state.clock.day,
stage: g.state.clock.stage,
});
for (let i = 0; i < 60; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0) break;
marks.push(mark(game));
if (!submit(game, options[0]!)) break;
}
assert.ok(marks.length > 20, 'the game did not get far enough to prove anything');
// Walk all the way back, checking each step lands on the position it came from.
let now = game;
for (let i = marks.length - 1; i >= 0; i--) {
const back = undo(now);
assert.ok(back, `undo refused with ${now.history.length} moves still on the clock`);
now = back;
assert.deepEqual(mark(now), marks[i], `undo ${marks.length - i} landed somewhere else`);
}
assert.equal(now.history.length, 0, 'walking all the way back did not empty the history');
assert.equal(undo(now), null, 'undo must refuse when there is nothing to take back');
});
it('leaves nothing in the log describing a move that was taken back', () => {
// The history panel is rebuilt with the game, so it cannot be left narrating an action that no
// longer happened — the single most confusing thing a half-undo could do.
const game = newGame(202);
for (let i = 0; i < 30; i++) {
if (currentActor(game) === null) break;
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
const before = game.log.length;
const back = undo(game)!;
assert.ok(back.log.length <= before, 'the log grew when a move was taken back');
assert.deepEqual(back.log, fromSave(toSave(back)).log, 'the rebuilt log is not the replayed log');
});
it('stays small enough to email', () => {
const game = newGame(202);
for (let i = 0; i < 400; i++) {
@@ -1393,6 +1556,43 @@ describe('the static build', () => {
assert.ok(worst <= 8, `the action list still reaches ${worst} buttons`);
});
it('makes up ONE train at a time, and names that train', () => {
// REPORTED: the history said Train 9 was being made up at the East Division Point while the
// panel above it read "Making up Train 10 — 18 kinds it may take are highlighted". Two trains
// can be built in the same Stage (a timetabled train plus a Second Section or an Extra), and
// every option from every tray was collected into one panel titled with whichever tray came
// first out of the map. Reproduced at seed 99, Day 3 Stage 12: eighteen chips, two trays.
//
// The panel must belong to exactly one tray — otherwise clicking a car in the yard couples it
// to whichever train owns the first matching option, which may not be the one named.
for (const seed of [99, 430, 270861860]) {
const game = newGame(seed);
for (let i = 0; i < 600 && currentActor(game) !== null; i++) {
const menu = actionMenu(game);
if (menu.makeUp) {
const trays = new Set(
menu.makeUp.cars.map((c) => (menu.options[c.index] as { trayId: string }).trayId),
);
if (menu.makeUp.pass !== null) {
trays.add((menu.options[menu.makeUp.pass] as { trayId: string }).trayId);
}
assert.ok(trays.size <= 1, `seed ${seed}: the make-up panel covers ${trays.size} trains at once`);
for (const t of trays) {
assert.equal(t, menu.makeUp.trayId, `seed ${seed}: a car belongs to a train the panel does not name`);
}
const tray = game.state.trays.get(menu.makeUp.trayId);
assert.ok(tray, `seed ${seed}: the panel names a tray that does not exist`);
assert.ok(
menu.makeUp.title.includes(String(tray.trainNumber)),
`seed ${seed}: "${menu.makeUp.title}" does not name Train ${tray.trainNumber}`,
);
}
const { options } = actionGroups(game);
if (options.length === 0 || !submit(game, options[0]!)) break;
}
}
});
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.
@@ -1416,11 +1616,15 @@ describe('the static build', () => {
});
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.
// REPORTED TWICE. First: the card says "any Mainline card" and every option offered was a
// square in the Office Area, because the candidate list handed grid cells to a check that read
// the column as a Division node index.
//
// Then the fix carried its own bug: a Mainline placement travelled as `{ row: -1, col: node }`,
// and row −1 is an ORDINARY DISTRICT ROW — the first one below the Running Track. So ABS
// Signals highlighted whichever district card sat at that column, and a real enhancement laid
// one row down was described as being out on the Mainline. A Mainline placement now carries a
// node and no coordinate at all, which is what this asserts.
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) {
@@ -1433,7 +1637,8 @@ describe('the static build', () => {
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(sp.coord, null, 'a Mainline placement must not claim a square in the district');
const node = game.state.division.nodes[sp.node!];
assert.equal(node?.kind, 'mainline', `spot points at a ${node?.kind}, not a Mainline card`);
}
});
@@ -1468,6 +1673,41 @@ describe('the static build', () => {
assert.match(svg, /cab</, 'the caboose is not named on the board');
});
it('draws the train the way it stands on the map — nose toward the way it faces', () => {
/**
* REPORTED at seed 270861860: Train 10 running EAST arrived at the Whistle Post drawn engine
* first at the WEST end, with the caboose at the east — which reads as an engine shoving its
* whole train ahead of it. "I'd expect it to look just like this, except with the engine and
* caboose reversed so that the engine is the eastmost of the train."
*
* `consist` is ordered nose first, and the renderer drew index 0 leftmost whatever the train was
* doing. A westbound train therefore came out right and an eastbound one came out mirrored.
*/
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const order = (facing: 'e' | 'w'): string[] => {
game.state.trays.set('crew', {
id: 'crew', trainNumber: 10, trainIsExtra: false, engineAt: 0,
consist: [
{ type: 'hopper', loaded: true },
{ type: 'hopper', loaded: false },
{ type: 'caboose', loaded: false },
],
direction: facing === 'e' ? 'east' : 'west', facing,
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)!;
const svg = officeSvg([cell], area.runningRow);
// The three-letter labels in the order they are drawn, west to east.
return [...svg.matchAll(/class="bs-tcarlab"[^>]*>([^<]+)</g)].map((m) => m[1]!);
};
// Nose first is engine, hopper, hopper, caboose. Running west the engine leads at the west end;
// running east the same train is drawn the other way round.
assert.deepEqual(order('w'), ['◀', 'hop', 'hop', 'cab'], 'a westbound train is drawn backwards');
assert.deepEqual(order('e'), ['cab', 'hop', 'hop', '▶'], 'an eastbound train is not turned round');
});
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).
@@ -1702,6 +1942,24 @@ describe('the Division map shows the whole route', () => {
assert.equal((svg.match(/class="bs-stop"/g) ?? []).length, 2, `${players}p has no pair of buffer stops`);
assert.match(svg, /west end · in and out/, `${players}p does not label the west end`);
assert.match(svg, /east end · in and out/, `${players}p does not label the east end`);
/**
* AND THE LABELS MUST BE ON THE CANVAS.
*
* REPORTED: both were clipped. They hung off the OUTSIDE of the end cells, anchored away from
* the board, so fitting them meant padding wider than the caption — which the board did not
* have, and which would have spent that width on two captions rather than on the map. Centred
* under their own Division Point they cost nothing and cannot be cut off.
*/
const vb = /viewBox="0 0 (\d+) (\d+)"/.exec(svg);
const W = Number(vb![1]);
const ends = [...svg.matchAll(/class="bs-end" x="([\d.]+)" y="([\d.]+)" text-anchor="middle">([^<]+)</g)];
assert.equal(ends.length, 2, `${players}p: an end label is not centred under its cell`);
for (const e of ends) {
const x = Number(e[1]);
const half = e[3]!.length * 3; // ~6px a character in the 10px monospace this uses
assert.ok(x - half >= -24 && x + half <= W + 24, `${players}p: "${e[3]}" is clipped at x=${x} of ${W}`);
}
}
});
});
@@ -1740,7 +1998,9 @@ describe('every square the menu offers can actually be clicked (regression)', ()
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()];
// Only the spots that ARE on this board: a Mainline placement has no square to draw.
const onBoard = item.spots.flatMap((sp) => (sp.coord === null ? [] : [sp.coord]));
const uniq = [...new Map(onBoard.map((c) => [`${c.row},${c.col}`, c])).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);