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
+44 -47
View File
@@ -44,7 +44,7 @@ import {
mainlineDescription,
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState, PlayerIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
import type { Facility, GameState, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
import { carsOn, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
import type { Hand, HouseRules, TrackGeometry } from '../engine/content.ts';
import type { Port } from '../engine/track.ts';
@@ -71,19 +71,25 @@ export type CellView = {
* `toString()`), so it cannot reach the card catalogue itself.
*/
enhancementsWhat: string[];
tray: string | null;
/**
* THE TRAIN STANDING HERE, in order, with the engine in it and which way it points.
* EVERY TRAIN STANDING HERE, in order, each with the engine in it and which way it points.
*
* The switching game is entirely about car ORDER — which car is next to come off, which end a cut
* couples onto — and the board showed a crew badge with a name and nothing else. A player could
* not plan a move at all: "drop 1 car" tells you nothing when you cannot see what is on the back.
*
* An ARRAY because the Office square is the one place more than one train may legally stand at
* once (docs/plans/switching-paths.md — "The Roster Pass"): the old singular `train` field only
* ever showed whichever tray `s.trays` happened to yield first, so a second train at a busy
* Station was never drawn at all, only counted in the A/D pips. Empty, not absent, when the card
* is bare, so callers never need an `?? []`.
*
* `cars` runs nose first, matching the tray; `engineAt` is where the locomotive sits in it, and
* `facing` is which way the engine points — EAST OR WEST, never north or south, whatever the
* track under it runs. See `railFacingOf` in state.ts for why, and why the type says so.
*/
train: {
trains: {
trayId: string;
label: string;
cars: string[];
engineAt: number;
@@ -96,26 +102,31 @@ export type CellView = {
* standing in front of them — the card is face down in a box somewhere by then.
*/
what: string;
} | null;
}[];
/**
* Office card only: A/D tracks taken and how many the tier has.
* Office card only: how many A/D tracks the tier has — null everywhere else.
*
* The tooltip said "3 A/D tracks" and the card showed nothing, so the number that decides whether
* the next arrival collides was invisible on the card it belongs to. Drawn as pips rather than
* extra rails — there is no room on the card for more track, and the count is what matters.
* Capacity, not occupancy: `board-svg.ts` draws one roster chip per track regardless of how many
* are taken (a free one reads "free"), so occupancy is implicit in how many of `trains` land on
* it. Used to carry `{used, of}` and draw one pip per track; the pips are gone (the roster band
* takes their place) but the count is still needed to know how many chips to lay out.
*/
ad: { used: number; of: number } | null;
adTracks: number | null;
cars: string[];
/**
* Cars in `cars` that stand WEST of the train on this card — 0 when no train is here.
* Cars in `cars` that stand WEST of the train(s) here — 0 when no train is here, and unsplit if
* so: with nobody standing on the card, a cut has no near or far side and the whole row simply
* reads west to east (`board-svg.ts` draws the split only while `trains.length > 0`).
*
* `cars` runs west to east like the state it comes from, and a train standing on the card sits
* somewhere IN that row rather than beside it. Reported from play: "right after dropping my cars I
* need to be able to see if those cars are ahead or behind the train" — and the board drew the
* whole cut in one left-aligned strip at the bottom of the card, which cannot answer that at all.
*
* Combined with `train.facing` it is the answer: for an east-facing engine the cars east of it are
* the ones ahead, and the split is drawn as a gap the chip sits in.
* Combined with a train's `facing` it is the answer: for an east-facing engine the cars east of it
* are the ones ahead. ONE number for the whole card — every train standing here (there is at most
* one, except the Office) reads the same split, taken against the block of A/D tracks rather than
* against any one engine, straight off `TrackCard.standingWest`.
*/
standingWest: number;
facility: FacilityView | null;
@@ -591,29 +602,31 @@ function baseOf(
* Zero when there is no train, which is also the right answer for a bare cut: with nobody standing
* there, a cut has no near or far side and the whole row simply reads west to east.
*/
function standingWestOn(s: GameState, key: string, cars: number): number {
for (const t of s.trays.values()) {
if (t.position.at !== 'grid') continue;
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
return Math.max(0, Math.min(cars, t.standingWest ?? 0));
}
return 0;
}
function trainOnCard(s: GameState, key: string): CellView['train'] {
/**
* Every train standing on this card, for THIS viewer's seat.
*
* KEYED BY COORDINATE, so it must be filtered by seat first — every district uses the same (row,
* col) origin, so without the seat check a crew standing at (0,1) in one player's Office Area would
* be drawn onto (0,1) of every other player's board too. Collects every match rather than returning
* on the first: the Office square is the one place more than one train may legally stand at once
* (docs/plans/switching-paths.md), and the old singular version silently drew only whichever tray
* `s.trays` happened to yield first.
*/
function trainsOnCard(s: GameState, viewerSeat: SeatIndex, key: string): CellView['trains'] {
const out: CellView['trains'] = [];
for (const [id, t] of s.trays) {
if (t.position.at !== 'grid') continue;
if (t.position.at !== 'grid' || t.position.seat !== viewerSeat) continue;
if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue;
return {
out.push({
trayId: id,
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
cars: t.consist.map(carLabel),
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
facing: railFacingOf(t),
what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t),
};
void id;
});
}
return null;
return out;
}
/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
@@ -975,18 +988,6 @@ export function snapshot(
): Frame {
const area = areaOf(s, viewer);
const viewerSeat = seatOf(s, viewer);
const trayAt = new Map<string, string>();
for (const [id, tray] of s.trays) {
// KEYED BY COORDINATE, so it must be filtered by seat first. Every district uses the same
// (row, col) origin, so without this a crew standing at (0,1) in one player's Office Area is
// drawn onto (0,1) of every other player's board — the cells come from `area.grid`, which is
// the viewer's, but the train on them came from anybody's.
if (tray.position.at === 'grid' && tray.position.seat === viewerSeat) {
const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying);
}
}
const cells: CellView[] = [];
const facilities: FacilityView[] = [];
@@ -1018,14 +1019,10 @@ export function snapshot(
links: connectionsFor(card).map(([a, b]) => `${a}${b}`),
enhancements: card.enhancements.map(prettyKey),
enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)),
tray: trayAt.get(key) ?? null,
train: trainOnCard(s, key),
ad:
card.geometry.kind === 'office'
? { used: area.adOccupancy.length, of: officeProfile(area.tier).adTracks }
: null,
trains: trainsOnCard(s, viewerSeat, key),
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
cars: carsOn(card).map(carLabel),
standingWest: standingWestOn(s, key, carsOn(card).length),
standingWest: card.standingWest,
facility: fv,
});
}