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
+28
View File
@@ -368,6 +368,34 @@ describe('the replay wire format is lossless (regression)', () => {
assert.ok(checked > 100, `only ${checked} frames checked`);
});
it("tolerates an older recording's singular train at the trains slot", () => {
// Before "The Roster Pass" (docs/plans/switching-paths.md), the trains slot held one train
// object or null — `rehydrateCells` has to make sense of that shape too, with no version flag
// anywhere in a packed row to key off.
const cards: unknown[] = [[0, 0, 'office', 'Whistle Post', true, ['ew']]];
const whats = ['a station'];
const facs: unknown[] = [];
const oldTrain = { trayId: 'crew', label: 'crew', cars: [], engineAt: 0, facing: 'e', what: '' };
const [withObject] = rehydrateCells([[0, 0, [], [], -1, oldTrain, 1, [], 0]], cards, whats, facs) as {
trains: unknown[];
}[];
assert.deepEqual(withObject!.trains, [oldTrain], 'a lone train object should wrap into a one-train roster');
const [withNull] = rehydrateCells([[0, 0, [], [], -1, null, 1, [], 0]], cards, whats, facs) as {
trains: unknown[];
}[];
assert.deepEqual(withNull!.trains, [], 'a null train slot should read as no train at all');
const [withArray] = rehydrateCells(
[[0, 0, [], [], -1, [oldTrain, oldTrain], 1, [], 0]],
cards,
whats,
facs,
) as { trains: unknown[] }[];
assert.equal(withArray!.trains.length, 2, 'an array should ride through as-is');
});
it('writes each distinct card and description once, not once per frame', () => {
const rec = record(880009, 'standard', 4000);
const packed = compress(rec.frames);