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:
+117
-38
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user