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: [],
+2
View File
@@ -153,6 +153,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
geometry: { kind: 'office' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: buildPassengerFacility('whistlePost'),
modifiers: [],
enhancements: [],
@@ -162,6 +163,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
geometry: { kind: 'limits' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
+27 -20
View File
@@ -137,9 +137,35 @@ export type TrackCard = {
* WEST-TO-EAST IS THE BOARD'S ORIENTATION, not the train's, which is exactly why it is the right
* one: it is a property of the card, so it does not change when a different train touches it. The
* two conversions are stated once, in `apply.ts`'s `carsDropped` and `carsCoupled` reducers, and
* `CrewTray.standingWest` records where a train standing here sits among them.
* `standingWest` below records where a train standing here sits among them.
*/
standing: RollingStock[];
/**
* HOW MANY OF `standing` LIE WEST OF THE TRAIN(S) STANDING HERE.
*
* `standing` runs west to east and a train sits somewhere IN that row: cars `[0, standingWest)`
* are west of it, cars `[standingWest, end)` are east. A train can set out off both ends on the
* same square — §A.3 lets a cut come off either outer end — so "which side is this cut on" is not
* recoverable from the array alone, and it is the question every remaining rule asks: which cars
* are AHEAD of an engine and which BEHIND (combine with `CrewTray.railFacing`), which cut a
* departing train must couple back up (the one at the end it leaves by), and which side of the
* chip the renderer draws the cut on.
*
* WITH NO TRAIN STANDING HERE, THIS NUMBER MEANS NOTHING: a card's cars are simply a cut with no
* near or far side, and the next train to arrive couples all of them regardless — nothing reads
* `standingWest` in that state, so a stale value left over from a train that has since moved on is
* inert, not wrong. That reasoning is unchanged from when this lived on `CrewTray`.
*
* WHAT MOVED IT TO THE CARD: the Office square is the one place more than one train may stand at
* once, and only one route lets two trays disagree about the same row of cars — a train already
* at the Office sets out a cut in place, without moving, while a second train on another A/D track
* still holds whatever split it arrived with. A cut lies west or east of the WHOLE block of A/D
* tracks, never of one particular track and never between two trains, so there is exactly one
* number for the card to carry and every train standing there reads the same one. Everywhere else
* on the board at most one train ever stands on a card, so the two homes for this field are
* identical in every other case.
*/
standingWest: number;
facility: Facility | null;
modifiers: ModifierKind[];
/** Enhancement cards laid on this card (§7 of implications.md). */
@@ -345,25 +371,6 @@ export type CrewTray = {
* puts the engine on the other end, which is the one thing the arrow exists to show.
*/
railFacing?: 'e' | 'w';
/**
* HOW MANY OF THIS CARD'S STANDING CARS LIE WEST OF THIS TRAIN.
*
* `carsOn(card)` runs west to east and a train standing on the card sits somewhere IN that row:
* cars `[0, standingWest)` are west of the engine, cars `[standingWest, end)` are east of it. A
* train can set out off both ends on the same square — §A.3 lets a cut come off either outer end —
* so "which side is this cut on" is not recoverable from the array alone, and it is the question
* every remaining rule asks.
*
* It answers all three: which cars are AHEAD of the engine and which BEHIND (combine with
* `railFacing`), which cut a departing train must couple back up (the one at the end it leaves
* by), and which side of the chip the renderer draws the cut on.
*
* Kept on the TRAY rather than the card because it describes a relationship, not the card: with no
* train standing there, a card's cars are simply a cut with no near or far side, and the next
* train to arrive couples all of them regardless. Reset to 0 on every move, which is sound because
* coupling is mandatory — a train arrives on a card it has just emptied.
*/
standingWest?: number;
position: NodeRef;
movesUsed: number;
/**
+1 -10
View File
@@ -316,15 +316,6 @@ export type MoveContext = {
consistSize: number;
/** The tray making the move, so it does not block itself. */
self: TrayId;
/**
* How many cars on the START card stand west of this train (`CrewTray.standingWest`).
*
* The walk begins at `neighbour(start, initialExit)` and never looked at the start card at all, so
* a train drove straight through the cut it had just set out — in both directions, and without
* counting it against the four-car limit. Coupling is mandatory (§A.4) and a cut on your own
* square is no exception: pulling out through the end it sits at picks it up again.
*/
standingWest?: number;
};
function cardAt(area: OfficeArea, c: GridCoord): TrackCard | undefined {
@@ -459,7 +450,7 @@ export function exploreMoves(
*
* Ordered nearest-first like every other card's, so it simply seeds the accumulator.
*/
const ownCut = cutTowards({ standingWest: ctx.standingWest }, carsOn(startCard), initialExit);
const ownCut = cutTowards(startCard, carsOn(startCard), initialExit);
const startKey = coordKey(start);
const queue: Frontier[] = [
{