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
+29 -22
View File
@@ -1240,7 +1240,6 @@ function destinationsFor(
occupancy: occupancyFor(s, player, trayId),
consistSize: tray.consist.length,
self: trayId,
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
},
from,
exit,
@@ -1261,7 +1260,8 @@ export function ownCutFor(s: GameState, player: PlayerIndex, trayId: TrayId, rev
const here = tray.position.coord;
const facing = facingPort(s, trayId);
const exit: Port = reverse ? reversePort(s, player, here, facing) : facing;
return cutTowards(tray, carsOn(areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard()), exit);
const card = areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard();
return cutTowards(card, carsOn(card), exit);
}
/**
@@ -1287,7 +1287,6 @@ export function movesFor(
occupancy: occupancyFor(s, player, trayId),
consistSize: tray.consist.length,
self: trayId,
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
};
const facing = facingPort(s, trayId);
const forward = exploreMoves(ctx, from, facing);
@@ -1374,17 +1373,19 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
* from `dest.path` plus the destination, neither of which can ever contain the start — so a
* cut recoupled off your own card was added to the consist and left standing on the board at
* the same time, one car becoming two. The cars that stay behind (a cut set out off the
* OTHER end) ride along on `leaves`, because by the time this is reduced the tray has moved
* and `standingWest` no longer describes this card.
* OTHER end) ride along on `leaves`, computed here against the card's CURRENT split — the
* reducer applying this event goes on to zero `standingWest` on the destination card, never
* the origin, so nothing downstream needs this snapshot to have been taken any earlier.
*/
const grid = areaOf(s, player).grid;
const startCut = cutTowards(tray, carsOn(grid.get(coordKey(from)) ?? emptyCard()), exitPort);
const startCard = grid.get(coordKey(from)) ?? emptyCard();
const startCut = cutTowards(startCard, carsOn(startCard), exitPort);
const lifted = [
...(startCut.length > 0 ? [from] : []),
...dest.path.map((step) => step.coord),
i.to,
].filter((c) => carsOn(grid.get(coordKey(c)) ?? emptyCard()).length > 0);
const sides = standingSides(tray, carsOn(grid.get(coordKey(from)) ?? emptyCard()));
const sides = standingSides(startCard, carsOn(startCard));
const stayed = exitPort === 'e' ? sides.west : exitPort === 'w' ? sides.east : [];
// §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto
// its nose". Running forward the engine meets cars head-on and takes them in front; backing
@@ -1722,14 +1723,17 @@ export function reduce(s: GameState, e: GameEvent): void {
// their turn. The event carries no player of its own.
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
const area = areaAtSeat(s, seat);
// A MOVE ENDS ON AN EMPTY CARD, always: coupling is mandatory, so anything standing on the
// destination has just been lifted into the tray. Whatever this crew had beside it belonged to
// the square it left, so its place in that row is gone with it.
tray.standingWest = 0;
// destination has just been lifted into the tray. Zero the DESTINATION card's split — the
// origin's is left alone rather than cleared, which costs nothing: nothing reads `standingWest`
// on a card with no train standing there, so a stale value left behind is inert, not wrong.
const destCard = area.grid.get(coordKey(e.to));
if (destCard) destCard.standingWest = 0;
// An A/D track is held only while the train is actually standing at the Office (§2.1).
// Leaving it out of sync means the Office looks permanently full and every arrival collides.
const area = areaAtSeat(s, seat);
const atOffice =
e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col;
area.adOccupancy = area.adOccupancy.filter((t) => t !== e.trayId);
@@ -1777,9 +1781,9 @@ export function reduce(s: GameState, e: GameEvent): void {
const card = area.grid.get(coordKey(e.leaves.at));
if (card) carsOn(card).push(...e.leaves.stock.map((c) => ({ ...c })));
}
// A tray that has moved is standing on a card it has just emptied, so nothing of its own is
// left beside it. Any cut it did leave behind is on the square it came FROM.
tray.standingWest = 0;
// `trayMoved`, which always precedes this for the same move, already zeroed the destination
// card's `standingWest` — coupling is mandatory, so a tray that has just moved is standing on
// a card it has just emptied, and nothing of its own is left beside it there.
/**
* Charge only the cars that were ALREADY standing where the crew ran — the own cut at the
* front of `stock` is a drop being undone, so it is refunded on the square it was left on
@@ -1831,20 +1835,20 @@ export function reduce(s: GameState, e: GameEvent): void {
*
* Successive cuts off the same end stack up TOWARDS the engine — the first car set out is left
* furthest away, and each one after it is left in the gap between the train and the last —
* so the insertion point is the train's own position in the row, `standingWest`, from either
* side. That is what makes the result batch-invariant: four cars at once, four singles, or two
* pairs all park in the same order, which is the reported bug and the regression test.
* so the insertion point is the card's own `standingWest`, read against this train's side. That
* is what makes the result batch-invariant: four cars at once, four singles, or two pairs all
* park in the same order, which is the reported bug and the regression test.
*/
if (card) {
const cars = carsOn(card);
const facing = railFacingOf(tray);
const onWestSide = e.fromNose ? facing === 'w' : facing === 'e';
const k = Math.max(0, Math.min(cars.length, tray.standingWest ?? 0));
const k = Math.max(0, Math.min(cars.length, card.standingWest));
const cut = trackOrder(e.stock, facing);
cars.splice(k, 0, ...cut);
// The train has not moved, so cars set out on its WEST side push its index along the row;
// The train has not moved, so cars set out on its WEST side push the split along the row;
// cars set out to the east leave it where it was.
tray.standingWest = onWestSide ? k + cut.length : k;
card.standingWest = onWestSide ? k + cut.length : k;
}
spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock);
break;
@@ -1901,9 +1905,10 @@ export function reduce(s: GameState, e: GameEvent): void {
geometry: { kind: 'modifier', modifier: card.kind.modifier },
baseOperationalRail: false,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
enhancements: [],
});
applyModifier(area, e.placement, card.kind.modifier);
} else if (card.kind.kind !== 'office') {
@@ -2244,7 +2249,7 @@ function protoCard(
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
variant: number | undefined,
): TrackCard | null {
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
const base = { standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [] };
if (kind.kind === 'track') {
const geometry = kind.geometry as TrackGeometry;
@@ -2452,6 +2457,7 @@ function emptyCard(): TrackCard {
geometry: { kind: 'limits' },
baseOperationalRail: false,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -2724,6 +2730,7 @@ function limitsCard(): TrackCard {
geometry: { kind: 'limits' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],