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
+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;
/**