the Roster Pass: every train at the Office visible

Builds "The Roster Pass" / "Two Trains, One Card" (station display for
multiple trains at one Office). The Office is the one square where
more than one train may legally stand at once (one per A/D track), and
CellView.train had room for exactly one — a second train at a busy
Station was counted in the old A/D pips and never drawn.

CellView.train -> CellView.trains: TrainView[], seat-filtered and
collecting every match rather than the first (fixes a latent
cross-district leak in the process: trainOnCard never checked seat).
The A/D pips are replaced with one roster chip per A/D track, always,
free or occupied; clicking a chip sets selectedCrew, wiring the board
and the action panel to the same value.

standingWest moves from CrewTray to TrackCard: two trays sharing one
Office card need one shared split, not one each, and there is no such
thing as "west of one particular A/D track". No save migration — Save
replays through the engine — and a stale value on an emptied card is
inert because nothing reads a split with no train standing there.

The Division map's Office cell is now sized by A/D capacity rather
than occupancy, so it holds still as trains arrive and leave; chips
lay into fixed slots instead of centre-spreading onto the Limits cards
either side.

584 tests, 0 failures. Verified end-to-end against the built app and a
direct render of a 4-train Terminal (screenshotted).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAt2YCXgd5qCjBcF2x34aK
This commit is contained in:
Jesse
2026-08-19 17:32:17 -04:00
co-authored by Claude Sonnet 5
parent fbaa3d4147
commit 37b1e5b969
18 changed files with 506 additions and 187 deletions
+138 -13
View File
@@ -756,8 +756,8 @@ 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: '',
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
trains: [], adTracks: null, cars: [], standingWest: 0, 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)]
@@ -931,7 +931,7 @@ describe('the page explains itself', () => {
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: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
// Said the way a player would ask it: if my train comes in from over there, where can it go?
@@ -1960,10 +1960,11 @@ describe('the static build', () => {
} 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 train = cell.trains[0];
assert.ok(train, 'the card carries no train');
assert.deepEqual(train!.cars, ['loaded tank', 'loaded boxcar', 'caboose'], 'car order is lost');
assert.equal(train!.engineAt, 0, 'the engine has no place in the train');
assert.equal(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');
@@ -2006,16 +2007,97 @@ describe('the static build', () => {
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', () => {
it('shows A/D tracks on the Office card, one chip per track', () => {
// 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).
// decides whether the next arrival is an automatic collision (§8.3). The Roster Pass replaced
// the pips with one roster chip per A/D track (docs/plans/switching-paths.md), free or occupied.
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');
assert.equal(cell.adTracks, 1, 'a Whistle Post has one A/D track');
assert.equal(cell.trains.length, 0, 'no train is standing yet');
const svg = officeSvg([cell], area.runningRow);
assert.match(svg, /bs-adchip/, 'no roster chip is drawn');
assert.match(svg, />free</, 'the one A/D track should read free with nothing standing on it');
});
it('draws every train standing at a busy Office, not just one', () => {
// REPORTED (docs/plans/switching-paths.md — "The Roster Pass"): the old single `train` field on
// a cell showed only whichever tray `s.trays` happened to yield first, so a second train holding
// at a Station was counted in the old A/D pips and never drawn at all.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
area.tier = 'terminal'; // 4 A/D tracks — room for every train this test parks
const at = area.officeCoord;
const trainNumbers = [10, 12, 14, 16];
for (const n of trainNumbers) {
const id = `t${n}`;
game.state.trays.set(id, {
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'boxcar', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
} as never);
area.adOccupancy.push(id);
}
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
assert.equal(cell.trains.length, 4, 'all four trains should be on the Office card');
assert.equal(cell.adTracks, 4, 'a Terminal has four A/D tracks');
const svg = officeSvg([cell], area.runningRow);
const chipCount = [...svg.matchAll(/class="bs-adchip/g)].length;
assert.equal(chipCount, 4, `expected 4 roster chips at a full Terminal, got ${chipCount}`);
assert.doesNotMatch(svg, />free</, 'every track is taken, so nothing should read free');
for (const n of trainNumbers) {
assert.match(svg, new RegExp(`data-crew="t${n}"`), `T${n} has no roster chip to click`);
}
});
it('draws a bare cut left-aligned, whatever the card\'s stored standingWest says', () => {
// "Settled questions" in docs/plans/switching-paths.md — the split only ever predicts something
// while a train stands on the card, and an earlier draft of the plan had this backwards. A stale
// standingWest surviving on an emptied card must not draw a gap nobody would recouple.
const cell = {
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
trains: [], adTracks: null,
cars: ['boxcar', 'loaded hopper', 'reefer'],
standingWest: 2, // stale — as if a train had once stood here and left
facility: null, links: ['ew'], what: '',
};
const svg = officeSvg([cell as never], 0);
const xs = [...svg.matchAll(/class="bs-slot[^"]*" x="([-\d.]+)"/g)].map((m) => Number(m[1]));
assert.equal(xs.length, 3, 'all three cars should be drawn');
const sorted = [...xs].sort((a, b) => a - b);
// Left-aligned means each slot sits exactly 30px past the one before it — no gap anywhere in
// the row for an engine that is not there.
for (let i = 1; i < sorted.length; i++) {
assert.equal(sorted[i]! - sorted[i - 1]!, 30, `a gap survived at index ${i} with no train present`);
}
});
it('reads one split off the shared card, not one per train', () => {
// The whole justification for moving `standingWest` onto the card rather than the tray
// (docs/plans/switching-paths.md): two trays holding at one Office must not be able to disagree
// about the same row of cars, because there is only one number for them to read.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
area.tier = 'station'; // 3 A/D tracks
const at = area.officeCoord;
const card = area.grid.get(`${at.row},${at.col}`)!;
card.standing = [{ type: 'boxcar', loaded: false }, { type: 'hopper', loaded: false }];
card.standingWest = 1;
for (const n of [10, 12]) {
const id = `t${n}`;
game.state.trays.set(id, {
id, trainNumber: n, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
} as never);
}
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
assert.equal(cell.trains.length, 2, 'both trains should be on the Office card');
assert.equal(cell.standingWest, 1, "the card's split is one number, not one per train");
});
it('names the cars a drop would set out, and which end they come off', () => {
@@ -2234,6 +2316,49 @@ describe('the Division map shows the whole route', () => {
}
});
it('keeps every roster chip inside the Office cell it belongs to, at any occupancy', () => {
// "Two Trains, One Card": sizing the cell by OCCUPANCY moved the East Division Point sideways
// every time an A/D track filled or cleared. Sizing by CAPACITY (docs/plans/switching-paths.md)
// holds the map still, and every chip has to actually fit inside the cell that capacity bought.
const s = createEngineGame({
id: 'div-chips', seed: 7,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
const area = areaOf(s, 0);
area.tier = 'terminal'; // 4 A/D tracks
for (const n of [10, 12, 14, 16]) {
const id = `t${n}`;
s.trays.set(id, {
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'boxcar', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
} as never);
area.adOccupancy.push(id);
}
const svg = divisionSvg(snapshot(s, [], null).division);
const cells = [...svg.matchAll(/class="bs-dcell bs-drun[^"]*"[^>]*><rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)]
.map((m) => ({ x: +m[1]!, w: +m[3]! }));
assert.ok(cells.length > 0, 'no Running Track cell drawn');
const office = cells.reduce((a, b) => (b.w > a.w ? b : a));
assert.ok(office.w > 78, `the Office cell did not widen for its 4 A/D tracks: ${office.w}`);
const chips = [...svg.matchAll(/class="bs-train"[^>]*><rect x="([-\d.]+)" y="[-\d.]+" width="([\d.]+)"/g)]
.map((m) => ({ x: +m[1]!, w: +m[2]! }));
assert.equal(chips.length, 4, `expected 4 train chips, got ${chips.length}`);
for (const c of chips) {
assert.ok(
c.x >= office.x - 0.01 && c.x + c.w <= office.x + office.w + 0.01,
`a chip at x=${c.x} w=${c.w} spills outside the Office cell [${office.x}, ${office.x + office.w}]`,
);
}
});
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. Both ends are labelled as ends, not as an entrance and an exit: odd trains run west