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
+13 -7
View File
@@ -56,6 +56,7 @@ const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing,
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -445,6 +446,7 @@ describe('Freight Agent operations (§6.3)', () => {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight',
subtype: 'mineTipple',
@@ -576,6 +578,7 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight',
subtype: 'mineTipple',
@@ -757,6 +760,7 @@ describe('legalActions shares its rules with apply (component 6)', () => {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight',
subtype: 'mineTipple',
@@ -944,7 +948,7 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
const area = areaOf(s, 0);
const plain = (geometry: object): TrackCard => ({
geometry: geometry as TrackCard['geometry'],
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
const col = area.limitsEast.col;
area.grid.set(coordKey(at(area.runningRow, col)), plain({
@@ -1294,7 +1298,7 @@ describe('Industry cards go on a stub, and lock each other out', () => {
const area = areaOf(s, 0);
const plain = (geometry: object): TrackCard => ({
geometry: geometry as TrackCard['geometry'],
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
const col = area.limitsEast.col;
area.grid.set(coordKey(at(area.runningRow, col)), plain({
@@ -1326,7 +1330,7 @@ describe('Industry cards go on a stub, and lock each other out', () => {
function build(s: GameState, kind: string, coord: GridCoord): void {
areaOf(s, 0).grid.set(coordKey(coord), {
geometry: { kind: 'facility', facility: kind as never },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
}
@@ -1621,8 +1625,9 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
addCard(s, at(0, 0), straight());
reduce(s, moved(id, 's'));
const cell = snapshot(s, [], null).cells.find((c) => c.row === 0 && c.col === 0);
assert.ok(cell?.train, 'the train should be on the card');
assert.ok(cell.train.facing === 'e' || cell.train.facing === 'w', `got ${cell.train.facing}`);
const train = cell?.trains[0];
assert.ok(train, 'the train should be on the card');
assert.ok(train.facing === 'e' || train.facing === 'w', `got ${train.facing}`);
});
});
@@ -1646,6 +1651,7 @@ describe('a train that rounds a curve points where the curve took it', () => {
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -1782,11 +1788,11 @@ describe('the switching job a player actually does: put a car in a siding, take
*/
const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
const turnout = (o: { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' }): TrackCard => ({
geometry: { kind: 'track', geometry: 'turnout', turnout: o, hand: 'right' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
it('takes a cut off the Running Track into a siding and sets it out', () => {
+9 -1
View File
@@ -36,6 +36,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing,
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -149,7 +150,14 @@ describe('a cut parks in the same order however many cars come off at once', ()
// Facing east: the nose cut (boxcar) is set out east of the engine, the tail cut (tank) west.
assert.deepEqual(standingAt(s, at(1, 0)), ['tank', 'boxcar']);
assert.equal(s.trays.get(id)!.standingWest, 1, 'the engine should be standing between the two cuts');
// `standingWest` lives on the CARD now, not the tray (docs/plans/switching-paths.md — "The
// Roster Pass"): the Office is the one square more than one train may share, and a cut lies
// west or east of the block of A/D tracks rather than of any one engine.
assert.equal(
areaOf(s, 0).grid.get(coordKey(at(1, 0)))!.standingWest,
1,
'the engine should be standing between the two cuts',
);
});
});
+1
View File
@@ -29,6 +29,7 @@ const straight = (): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
+1
View File
@@ -313,6 +313,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
+3 -3
View File
@@ -450,7 +450,7 @@ describe('the view shows one seat at a time', () => {
for (let n = 0; n < p; n++) {
area.grid.set(coordKey({ row: area.runningRow - 1, col: n }), {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
}
s.decks.hands.set(p, source.slice(0, p + 1));
@@ -527,7 +527,7 @@ describe('the view shows one seat at a time', () => {
readyToLeave(s, 1, 'crew-1', 'east');
const withTray = (p: PlayerIndex): number =>
snapshot(s, [], null, null, null, false, p).cells.filter((c) => c.tray != null).length;
snapshot(s, [], null, null, null, false, p).cells.filter((c) => c.trains.length > 0).length;
assert.equal(withTray(1), 1, "the crew is not on its own seat's board");
assert.deepEqual([withTray(0), withTray(2)], [0, 0], "a crew was drawn onto another seat's board");
});
@@ -594,7 +594,7 @@ describe('scoring lands on the right seat', () => {
const sizeBefore = areaAtSeat(s, 2).grid.size;
mine.grid.set(coordKey({ row: mine.runningRow - 1, col: 0 }), {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
assert.equal(areaAtSeat(s, 2).grid.size, sizeBefore, "one seat's track appeared in another's district");
assert.notEqual(areaAtSeat(s, 1), areaAtSeat(s, 2), 'two seats share one Office Area object');
+28
View File
@@ -368,6 +368,34 @@ describe('the replay wire format is lossless (regression)', () => {
assert.ok(checked > 100, `only ${checked} frames checked`);
});
it("tolerates an older recording's singular train at the trains slot", () => {
// Before "The Roster Pass" (docs/plans/switching-paths.md), the trains slot held one train
// object or null — `rehydrateCells` has to make sense of that shape too, with no version flag
// anywhere in a packed row to key off.
const cards: unknown[] = [[0, 0, 'office', 'Whistle Post', true, ['ew']]];
const whats = ['a station'];
const facs: unknown[] = [];
const oldTrain = { trayId: 'crew', label: 'crew', cars: [], engineAt: 0, facing: 'e', what: '' };
const [withObject] = rehydrateCells([[0, 0, [], [], -1, oldTrain, 1, [], 0]], cards, whats, facs) as {
trains: unknown[];
}[];
assert.deepEqual(withObject!.trains, [oldTrain], 'a lone train object should wrap into a one-train roster');
const [withNull] = rehydrateCells([[0, 0, [], [], -1, null, 1, [], 0]], cards, whats, facs) as {
trains: unknown[];
}[];
assert.deepEqual(withNull!.trains, [], 'a null train slot should read as no train at all');
const [withArray] = rehydrateCells(
[[0, 0, [], [], -1, [oldTrain, oldTrain], 1, [], 0]],
cards,
whats,
facs,
) as { trains: unknown[] }[];
assert.equal(withArray!.trains.length, 2, 'an array should ride through as-is');
});
it('writes each distinct card and description once, not once per frame', () => {
const rec = record(880009, 'standard', 4000);
const packed = compress(rec.frames);
+15 -6
View File
@@ -48,6 +48,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing,
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -62,6 +63,7 @@ const curve = (arc: TrackArc, standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'curved', arc },
baseOperationalRail: true,
standing,
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -73,6 +75,7 @@ const turnout = (o?: TurnoutOrientation): TrackCard => ({
: { kind: 'track', geometry: 'turnout' },
baseOperationalRail: false, // no wheel icon — a train may pass through but not stop (§A.1)
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -82,6 +85,7 @@ const officeCard = (): TrackCard => ({
geometry: { kind: 'office' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -91,6 +95,7 @@ const lockedFacility = (): TrackCard => ({
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
// A load sitting on MEN|AT|WORK locks the industry track down (§9.3).
facility: {
kind: 'freight',
@@ -114,7 +119,7 @@ const car = (): RollingStock => ({ type: 'boxcar', loaded: false });
function straightCard(): TrackCard {
return {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
};
}
@@ -210,6 +215,7 @@ describe('ports and geometry', () => {
geometry: { kind: 'track', geometry: 'curved', hand: 'left' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -629,7 +635,7 @@ describe('placement and drop-off', () => {
describe('curves are arcs, and arcs make sidings possible', () => {
const arcCard = (arc: 'ne' | 'nw' | 'se' | 'sw', geometry: 'curved' | 'sharpCurved' = 'curved'): TrackCard => ({
geometry: { kind: 'track', geometry, arc },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
it('joins exactly two adjacent edges, with no through track', () => {
@@ -647,7 +653,7 @@ describe('curves are arcs, and arcs make sidings possible', () => {
c.map((p) => [...p].sort().join('')).sort().join(' ');
const turnout: TrackCard = {
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' } },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
};
for (const arc of ['ne', 'nw', 'se', 'sw'] as const) {
assert.notEqual(norm(connectionsFor(arcCard(arc))), norm(connectionsFor(turnout)),
@@ -690,11 +696,11 @@ describe('curves are arcs, and arcs make sidings possible', () => {
const ew = (): TrackCard => straightCard();
const grid: Record<string, TrackCard> = {
'0,-2': ew(),
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'right' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,-1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'w', through: 'e', diverge: 's' }, hand: 'right' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [] },
'-1,-1': arcCard('ne'),
'-1,0': ew(),
'-1,1': arcCard('nw'),
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' }, hand: 'left' }, baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [] },
'0,1': { geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' }, hand: 'left' }, baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [] },
'0,0': ew(),
};
const area = areaFrom(grid, { row: 0, col: 0 });
@@ -778,6 +784,7 @@ describe('Operational Rail — where a train may stop and leave cars (Appendix A
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
@@ -919,7 +926,7 @@ describe('a turnout may be laid to face another, making a crossover', () => {
/** A turnout stated the way the cards read: who may come in, and where they may go. */
const facing = (stem: Port, through: Port, diverge: Port, hand: 'left' | 'right'): TrackCard => ({
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem, through, diverge }, hand },
baseOperationalRail: false, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: false, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
it('joins a south-diverging turnout to the north-diverging one beneath it', () => {
@@ -1027,6 +1034,7 @@ describe('why a square is NOT offered (the same walk, keeping its rejections)',
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight', subtype: 'mineTipple',
allows: { outbound: true, inbound: false },
@@ -1122,6 +1130,7 @@ describe('two routes to one square', () => {
geometry: { kind: 'limits' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
+1
View File
@@ -30,6 +30,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing,
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
+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