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
+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: [],