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
+117 -38
View File
@@ -51,6 +51,14 @@ export function divisionSvg(nodes: DivisionView[]): string {
const CW = { dp: 118, ml: 152, run: 78 };
const CH = 58;
const GAP = 6;
/**
* ONE FIXED SLOT PER A/D TRACK, so the Office Running Track cell is drawn wide enough to hold
* them without spilling onto its neighbours (docs/plans/switching-paths.md — "The Roster Pass").
* Sized by CAPACITY, not by how many are occupied right now: a cell drawn for the trains it HAS
* holds still as they come and go, where sizing by occupancy moved the East Division Point (and
* everything past it) sideways every time an A/D track filled or cleared.
*/
const CHIP_W = 54;
/**
* Room for the buffer stops. THE LABELS NO LONGER LIVE OUT HERE.
*
@@ -128,7 +136,7 @@ export function divisionSvg(nodes: DivisionView[]): string {
// No regions inside a district: a crew moves by Moves there, not by Stages, so it
// occupies a card outright rather than a part of one.
regions: 0,
w: CW.run,
w: isOffice && cap !== null ? Math.max(CW.run, cap * CHIP_W + 12) : CW.run,
});
}
// A crew below the Running Track has no position ON it, so it is reported against the
@@ -301,12 +309,24 @@ export function divisionSvg(nodes: DivisionView[]): string {
const loaded = cars.filter((x) => /^loaded/.test(x) || /caboose/.test(x)).length;
const label = cars.length === 0 ? `${t.label} ${arrow}` : `${t.label} ${arrow}${cars.length}`;
const w = Math.min(c.w - 8, label.length * 6.6 + 12);
/**
* THE OFFICE RUNNING CELL GETS FIXED SLOTS, ONE PER A/D TRACK — never a centre spread.
*
* Centred spreading pushes its outer chips outward as MORE trains arrive, and the cell was
* sized for the cards it holds, not for its trains — so two chips at a Station used to land at
* x 215–267 and 271–316 inside a cell spanning only 230–308, spilling onto the Limits cards
* either side. A fixed slot per A/D track cannot overflow the cell at any occupancy, because
* the cell was sized for exactly that many slots (see `CHIP_W` above).
*/
const isOfficeRun = c.kind === 'run' && c.cap !== null && c.cap > 0;
const slotW = isOfficeRun ? (c.w - 12) / c.cap! : 0;
const w = isOfficeRun ? Math.min(slotW - 4, label.length * 6.6 + 12) : Math.min(c.w - 8, label.length * 6.6 + 12);
// A train on a Mainline card sits in ITS region; anywhere else it just sits on the card.
const inRegion = c.regions > 1 && typeof t.region === 'number';
const slotX = inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2;
const spread = inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4);
const tx = slotX + spread;
const tx = isOfficeRun
? c.x + 6 + slotW * (k + 0.5)
: (inRegion ? c.x + 6 + RW * (t.region ?? 0) + RW / 2 : c.x + c.w / 2) +
(inRegion ? 0 : (k - (c.trains.length - 1) / 2) * (w + 4));
const dir = t.direction === 'west' ? ' \u25c0 west' : t.direction === 'east' ? ' east \u25b6' : '';
const stages =
typeof t.stagesLeft === 'number'
@@ -393,6 +413,12 @@ export function officeSvg(
* there until the square failed to light up.
*/
limits: { west: number; east: number } | null = null,
/**
* Which train's consist is drawn at the rail, on an Office card holding more than one — the value
* the "Which train are you switching?" picker already carries. Defaults to the first train on the
* card, so every call site that has never heard of this feature keeps working unchanged.
*/
selectedTrayId: string | null = null,
): string {
const W = 166;
const H = 96;
@@ -480,10 +506,21 @@ export function officeSvg(
? 'mod'
: 'trk';
// An Office card's roster band (below) takes the y-band the BOARDS + ALIGHTS flow label used to
// print in, so that explanation folds into the card's own tooltip instead of being drawn — it
// prints identically on every Office of every tier and is already implied by the card name and
// the Porter pill, so nothing is lost by reading it on the card rather than beside it.
const isOfficeRoster = cell.adTracks !== null;
const flowFold =
isOfficeRoster && cell.facility
? '\n\nBOARDS + ALIGHTS: passengers only — no freight is handled here. They wait in a green ' +
'box, are boarded onto a coach by Porters, and arrive into a red one.'
: '';
// Addressable so a caller can outline a square as a legal destination without re-rendering.
// The explanation rides on the group as a tooltip rather than being printed: it is reference
// detail, read once, and printing it costs the space the board itself needs.
out += `<g data-cell="${cell.row},${cell.col}" data-tip="${esc(cell.label)} — ${esc(cell.what)}" transform="translate(${x},${y})">`;
out += `<g data-cell="${cell.row},${cell.col}" data-tip="${esc(cell.label)} — ${esc(cell.what)}${esc(flowFold)}" transform="translate(${x},${y})">`;
out += `<rect class="bs-card bs-${kind}${cell.running ? ' bs-run' : ''}" x="0" y="0" width="${W}" height="${H}" rx="4"/>`;
// The rails. A Modifier is not track and deliberately gets none — that is why nothing can be
@@ -515,20 +552,48 @@ export function officeSvg(
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
/**
* A/D TRACKS, as pips.
* THE ROSTER, one chip per A/D track — not one per train.
*
* The Office tooltip said "3 A/D tracks" and the card showed nothing — so the number that
* decides whether the next arrival is an automatic collision (§8.3) was invisible on the card it
* belongs to. There is no room to draw more rails, and the count is what matters: one pip per
* track, filled for taken.
* The Office is the one square where more than one train may legally stand at once (its A/D
* tracks, §2.1), and the old single `cell.train` field only ever showed whichever tray
* `s.trays` happened to yield first: a second train at a busy Station was counted in the old
* A/D pips and never drawn at all. `adTracks` of them, always, whether or not a train is
* holding there — the cell is drawn for the tracks it HAS, so a free one reads a dashed,
* dimmed chip saying "free" rather than simply vanishing.
*
* Selected train's chip is lit the same amber the crew strip and action buttons already use,
* and it is the one whose consist is drawn at the rail below. Every occupied chip carries
* `data-crew`, so the play page can wire a click to switch which train that is.
*/
if (cell.ad) {
for (let i = 0; i < cell.ad.of; i++) {
out += `<circle class="bs-ad${i < cell.ad.used ? ' bs-occ' : ''}" cx="${W - 9 - i * 9}" cy="10" r="3"/>`;
let selectedTrain: CellView['trains'][number] | null = null;
if (cell.adTracks !== null) {
const adTracks = cell.adTracks;
const gaps = Math.max(0, adTracks - 1);
const chipW = Math.min(52, (W - 12 - 4 * gaps) / adTracks);
selectedTrain = cell.trains.find((t) => t.trayId === selectedTrayId) ?? cell.trains[0] ?? null;
for (let i = 0; i < adTracks; i++) {
const train = cell.trains[i] ?? null;
const cx = 6 + i * (chipW + 4);
if (!train) {
out +=
`<g class="bs-adchip mt"><rect x="${cx}" y="20" width="${chipW}" height="14" rx="3"/>` +
`<text x="${cx + chipW / 2}" y="30.5" text-anchor="middle">free</text></g>`;
continue;
}
const arrow = train.facing === 'w' ? '◀' : '▶';
const chipLabel = chipW >= 40 ? `${train.label} ${arrow}${train.cars.length}` : `${train.label}${arrow}`;
const consistWords = train.cars.length === 0 ? 'no cars' : train.cars.join(', ');
const on = train.trayId === selectedTrain?.trayId;
out +=
`<g class="bs-adchip${on ? ' on' : ''}" data-crew="${esc(train.trayId)}" ` +
`data-tip="${esc(`${train.label} — carrying ${consistWords}`)}">` +
`<rect x="${cx}" y="20" width="${chipW}" height="14" rx="3"/>` +
`<text x="${cx + chipW / 2}" y="30.5" text-anchor="middle">${esc(chipLabel)}</text></g>`;
}
}
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
/**
* THE LOAD PIPELINE, on the card itself, DRAWN THE WAY IT FLOWS.
@@ -608,15 +673,19 @@ export function officeSvg(
? 'BOARDS + ALIGHTS'
: f.allowsOut && f.allowsIn ? 'SHIPS + RECEIVES' : f.allowsOut ? 'SHIPS OUT' : 'RECEIVES';
const flowCls = passenger || (f.allowsOut && f.allowsIn) ? 'bs-flow-both' : f.allowsOut ? 'bs-flow-out' : 'bs-flow-in';
out += `<text class="bs-flow ${flowCls}" x="${W - 6}" y="26" text-anchor="end" data-tip="${esc(
passenger
? 'Passengers only — no freight is handled here. They wait in a green box, are boarded onto a coach by Porters, and arrive into a red one. There is no MEN | AT | WORK sign because that is a Freight Facility fitting.'
: f.allowsOut && f.allowsIn
? 'This industry both ships and receives, so it has green boxes AND red ones: loads leave it on a car, and loads arrive on one.'
: f.allowsOut
? 'This industry only SHIPS. A load starts in a green box, is worked along MEN | AT | WORK by Laborers, and comes off onto an empty car spotted on its track. It has no red boxes because nothing arrives here.'
: 'This industry only RECEIVES. A loaded car is spotted on its track and Laborers work the load back along WORK | AT | MEN into a red box. It has no green boxes because nothing ships from here.',
)}">${esc(flow)}</text>`;
// An Office card's roster band now occupies this y-band; its tooltip already folded into the
// card's own data-tip above, so the label is not printed twice.
if (!isOfficeRoster) {
out += `<text class="bs-flow ${flowCls}" x="${W - 6}" y="26" text-anchor="end" data-tip="${esc(
passenger
? 'Passengers only — no freight is handled here. They wait in a green box, are boarded onto a coach by Porters, and arrive into a red one. There is no MEN | AT | WORK sign because that is a Freight Facility fitting.'
: f.allowsOut && f.allowsIn
? 'This industry both ships and receives, so it has green boxes AND red ones: loads leave it on a car, and loads arrive on one.'
: f.allowsOut
? 'This industry only SHIPS. A load starts in a green box, is worked along MEN | AT | WORK by Laborers, and comes off onto an empty car spotted on its track. It has no red boxes because nothing arrives here.'
: 'This industry only RECEIVES. A loaded car is spotted on its track and Laborers work the load back along WORK | AT | MEN into a red box. It has no green boxes because nothing ships from here.',
)}">${esc(flow)}</text>`;
}
/**
* THE WORKERS, ON THE CARD.
@@ -672,14 +741,15 @@ export function officeSvg(
* means "ahead" or "behind" is the engine's facing, which the tooltip says in words rather than
* leaving the player to work out from the compass.
*/
const split = cell.train ? Math.max(0, Math.min(cell.cars.length, cell.standingWest ?? 0)) : 0;
const hasTrain = cell.trains.length > 0;
const split = hasTrain ? Math.max(0, Math.min(cell.cars.length, cell.standingWest)) : 0;
const shown = Math.min(cell.cars.length, 4);
for (let i = 0; i < shown; i++) {
// With no train on the card there is no gap to leave, so the whole cut stays left-aligned.
const westOfEngine = cell.train ? i < split : true;
const side = !cell.train
const westOfEngine = hasTrain ? i < split : true;
const side = !selectedTrain
? 'standing here'
: (cell.train.facing === 'e') === westOfEngine
: (selectedTrain.facing === 'e') === westOfEngine
? 'standing BEHIND the engine — it would couple onto the tail backing up'
: 'standing AHEAD of the engine — it would couple onto the nose pulling forward';
const carTip = `${cell.cars[i]} ${side}`;
@@ -740,17 +810,18 @@ export function officeSvg(
(cell.enhancementsWhat ?? []).join(' · '),
)}">${esc(cell.enhancements.join(' · '))}</text>`;
}
if (cell.train) {
if (selectedTrain) {
/**
* THE TRAIN, DRAWN AS IT SITS IN THE TRAY.
* THE SELECTED TRAIN, DRAWN AS IT SITS IN THE TRAY.
*
* The badge used to be a name and nothing more, which makes the switching game unplayable:
* every decision is about car ORDER — which car comes off next, which end a cut couples onto —
* and none of it was on the board. Now the engine and its cars are drawn in sequence, nose
* first, with an arrow for which way the engine points, so "set out the caboose off the back"
* is a thing you can see before you choose it.
* is a thing you can see before you choose it. Where an Office holds more than one train, only
* this one is drawn here — the rest are the roster chips above, each with its own tooltip.
*/
const t = cell.train;
const t = selectedTrain;
const items: { label: string; kind: string; what: string }[] = t.cars.map((c) => ({
label: /caboose/.test(c) ? 'cab' : /hopper/.test(c) ? 'hop' : /boxcar/.test(c) ? 'box'
: /reefer/.test(c) ? 'rfr' : /tank/.test(c) ? 'tnk' : /coach/.test(c) ? 'cch' : '?',
@@ -908,10 +979,16 @@ export const BOARD_CSS = `
.bs-tcar.bs-t-mt{fill:#6b5a2e}
.bs-tcar.bs-t-eng{fill:#2b2313;stroke:#e0c060;stroke-width:1.4}
.bs-tcarlab{font:8px ui-monospace,monospace;fill:#2b2313;font-weight:700}
/* One pip per A/D track, filled when a train is holding on it. A full Office is why the next
arrival collides, so this is the count worth seeing without hovering. */
.bs-ad{fill:none;stroke:#4d6fa8;stroke-width:1.2}
.bs-ad.bs-occ{fill:#5aa9e6;stroke:#8fc4ee}
/* One chip per A/D track, not one per train — a full Office is why the next arrival collides, so
the count is worth seeing without hovering, and a free track reads "free" rather than vanishing.
The selected train's chip is lit the same amber the crew strip and action buttons use. */
.bs-adchip rect{fill:#20262e;stroke:#4a5361;stroke-width:1}
.bs-adchip text{fill:#cfd6e0;font:600 8px ui-monospace,monospace}
.bs-adchip.on rect{fill:#8a6d1f;stroke:#e0c060;stroke-width:1.4}
.bs-adchip.on text{fill:#fff4d6}
.bs-adchip.mt rect{fill:none;stroke:#3d4650;stroke-dasharray:3 2}
.bs-adchip.mt text{fill:#6b7480}
.bs-adchip[data-crew]{cursor:pointer}
.bs-t-mt + .bs-tcarlab,.bs-t-eng + .bs-tcarlab{fill:#f0e6cf}
.bs-tlab{fill:#eaf6ec;font:600 11px ui-monospace,monospace}
/* The three letters sit ON the slot, so they invert with it: dark on a loaded car's solid face,
@@ -968,9 +1045,11 @@ g[data-cell].bs-legal:hover .bs-card,.bs-ghost:hover rect{fill:#233246}
g[data-cell].bs-point .bs-card,g[data-ghost].bs-point rect{stroke:#f2f5f8;stroke-width:3.5;stroke-dasharray:none}
g[data-cell].bs-point .bs-card{fill:#2b3b52}
/* WHERE THE CREW CAN GO. Amber, the colour this game spends on "you can do this", so a reachable
card reads the same way an action button does. bs-from is where it is standing now. */
card reads the same way an action button does. bs-from is where it is standing now — marked on
the crew STRIP rather than the whole card, because the Office is the one square where a second
train may share that card: outlining the card would claim it belongs to both. */
g[data-cell].bs-focus .bs-card{stroke:#e0c060;stroke-width:2.5}
g[data-cell].bs-from .bs-card{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3}
.bs-crew.bs-from rect:first-of-type{stroke:#7a6a3a;stroke-width:2;stroke-dasharray:4 3}
/* AND WHERE IT CANNOT. Muted red with a barred face — a card that is refusing traffic, not one that
is merely uninteresting. The reason rides on the card's own tooltip. */
g[data-cell].bs-blocked .bs-card{stroke:#8a5a5a;stroke-width:2;stroke-dasharray:2 3}