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 -3
View File
@@ -21,9 +21,10 @@ page as `v0.1.0 · <sha> · <date>`, so what is deployed can always be identifie
## 0.4.8 — 2026-08-19
Three reports from the same game, all about squares: which ones a card may go on, which one a button
in the action list means, and — when there is more than one legal road to the same square — which
one a train actually takes.
Four reports from the same game, all about squares: which ones a card may go on, which one a button
in the action list means, when there is more than one legal road to the same square which one a
train actually takes, and — when more than one train is standing at the same Office — which is
which.
### The Limits bound the district, not just the Running Track
@@ -168,6 +169,46 @@ built a district where the discarded route would have mattered. 400 full games (
ablation flag to run at all and this is not a bot heuristic, so paired A/B was not applicable here;
the enumeration-based argument above is the actual evidence.
### The Roster Pass — every train at the Office visible
**The Office is the one square where more than one train may legally stand at once** — it has
several A/D tracks, each holding one — and the board drew it as if only one ever could. Two trays
at a Station couple onto the identical `officeCoord`; `trainOnCard()` returned on the first match
and `CellView.train` had room for exactly one, so a second train was counted in the old A/D pips
and never drawn at all. Reported directly, and analysed in "Two Trains, One Card": the pips and the
picture were reading different fields.
**`CellView.train` is now `CellView.trains: TrainView[]`** — every train standing on the card, each
carrying its own `trayId`. The A/D pips are gone; in their place is one roster chip per A/D track,
always (`adTracks` of them, not one per train), free tracks reading a dashed, dimmed "free" rather
than vanishing. The selected train's chip is lit the same amber the crew strip and action buttons
use, and its consist is the one drawn at the rail — clicking any occupied chip sets `selectedCrew`,
the same value the "Which train are you switching?" picker already wrote, so the board and the
action panel drive one value in both directions.
**`standingWest` moved from `CrewTray` to `TrackCard`.** The split still means what it always meant
— west/east of the row of standing cars — and it is still only meaningful while an engine stands on
the card (an unattended cut has no near or far side; that reasoning did not change). What forced the
move: two trays sharing one Office card could otherwise hold two different splits of the identical
row, and there is no such thing as "west of one particular A/D track" — a cut lies west or east of
the *whole block* of A/D tracks, so there has to be exactly one number for the card to carry. No
save migration: `Save = {seed, history, rules?}` replays through the engine rather than being
loaded, and a stale value on an emptied card is inert because nothing reads a split with no train
standing there.
**The Division map stopped drawing trains on their neighbours.** The Office's Running Track cell was
a fixed 78px regardless of how many A/D tracks it had, so two chips centre-spread wider than the
cell and spilled onto the Limits cards either side. The cell is now sized by **capacity**
(`max(78, adTracks·54+12)`), not by how many tracks are occupied — sized by occupancy instead, the
board would have shifted the East Division Point sideways every time a train arrived or left — and
chips are laid into fixed slots, one per A/D track, so none can ever overhang the cell.
**Verified:** two trays at a Station are both drawn, each with its own chip; a Terminal's four
tracks all fit; a bare cut with a stale `standingWest` still draws left-aligned; every roster chip's
x-extent lies inside its Office cell at full occupancy; and a packed replay row from before this
feature — a lone train object, not an array — rehydrates into a one-train roster rather than
throwing. 584 tests, 0 failures.
### Also
- The three published replays were re-recorded. A save is a seed and its intents, so tightening a
+28 -21
View File
@@ -1240,7 +1240,6 @@ function destinationsFor(
occupancy: occupancyFor(s, player, trayId),
consistSize: tray.consist.length,
self: trayId,
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
},
from,
exit,
@@ -1261,7 +1260,8 @@ export function ownCutFor(s: GameState, player: PlayerIndex, trayId: TrayId, rev
const here = tray.position.coord;
const facing = facingPort(s, trayId);
const exit: Port = reverse ? reversePort(s, player, here, facing) : facing;
return cutTowards(tray, carsOn(areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard()), exit);
const card = areaOf(s, player).grid.get(coordKey(here)) ?? emptyCard();
return cutTowards(card, carsOn(card), exit);
}
/**
@@ -1287,7 +1287,6 @@ export function movesFor(
occupancy: occupancyFor(s, player, trayId),
consistSize: tray.consist.length,
self: trayId,
...(tray.standingWest === undefined ? {} : { standingWest: tray.standingWest }),
};
const facing = facingPort(s, trayId);
const forward = exploreMoves(ctx, from, facing);
@@ -1374,17 +1373,19 @@ function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
* from `dest.path` plus the destination, neither of which can ever contain the start — so a
* cut recoupled off your own card was added to the consist and left standing on the board at
* the same time, one car becoming two. The cars that stay behind (a cut set out off the
* OTHER end) ride along on `leaves`, because by the time this is reduced the tray has moved
* and `standingWest` no longer describes this card.
* OTHER end) ride along on `leaves`, computed here against the card's CURRENT split — the
* reducer applying this event goes on to zero `standingWest` on the destination card, never
* the origin, so nothing downstream needs this snapshot to have been taken any earlier.
*/
const grid = areaOf(s, player).grid;
const startCut = cutTowards(tray, carsOn(grid.get(coordKey(from)) ?? emptyCard()), exitPort);
const startCard = grid.get(coordKey(from)) ?? emptyCard();
const startCut = cutTowards(startCard, carsOn(startCard), exitPort);
const lifted = [
...(startCut.length > 0 ? [from] : []),
...dest.path.map((step) => step.coord),
i.to,
].filter((c) => carsOn(grid.get(coordKey(c)) ?? emptyCard()).length > 0);
const sides = standingSides(tray, carsOn(grid.get(coordKey(from)) ?? emptyCard()));
const sides = standingSides(startCard, carsOn(startCard));
const stayed = exitPort === 'e' ? sides.west : exitPort === 'w' ? sides.east : [];
// §A.3 — "engines also have couplers on the front end, so a train can pick cars up onto
// its nose". Running forward the engine meets cars head-on and takes them in front; backing
@@ -1722,14 +1723,17 @@ export function reduce(s: GameState, e: GameEvent): void {
// their turn. The event carries no player of its own.
turnOf(s, playerAtSeat(s, seat)).movesRemaining = e.movesRemaining;
const area = areaAtSeat(s, seat);
// A MOVE ENDS ON AN EMPTY CARD, always: coupling is mandatory, so anything standing on the
// destination has just been lifted into the tray. Whatever this crew had beside it belonged to
// the square it left, so its place in that row is gone with it.
tray.standingWest = 0;
// destination has just been lifted into the tray. Zero the DESTINATION card's split — the
// origin's is left alone rather than cleared, which costs nothing: nothing reads `standingWest`
// on a card with no train standing there, so a stale value left behind is inert, not wrong.
const destCard = area.grid.get(coordKey(e.to));
if (destCard) destCard.standingWest = 0;
// An A/D track is held only while the train is actually standing at the Office (§2.1).
// Leaving it out of sync means the Office looks permanently full and every arrival collides.
const area = areaAtSeat(s, seat);
const atOffice =
e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col;
area.adOccupancy = area.adOccupancy.filter((t) => t !== e.trayId);
@@ -1777,9 +1781,9 @@ export function reduce(s: GameState, e: GameEvent): void {
const card = area.grid.get(coordKey(e.leaves.at));
if (card) carsOn(card).push(...e.leaves.stock.map((c) => ({ ...c })));
}
// A tray that has moved is standing on a card it has just emptied, so nothing of its own is
// left beside it. Any cut it did leave behind is on the square it came FROM.
tray.standingWest = 0;
// `trayMoved`, which always precedes this for the same move, already zeroed the destination
// card's `standingWest` — coupling is mandatory, so a tray that has just moved is standing on
// a card it has just emptied, and nothing of its own is left beside it there.
/**
* Charge only the cars that were ALREADY standing where the crew ran — the own cut at the
* front of `stock` is a drop being undone, so it is refunded on the square it was left on
@@ -1831,20 +1835,20 @@ export function reduce(s: GameState, e: GameEvent): void {
*
* Successive cuts off the same end stack up TOWARDS the engine — the first car set out is left
* furthest away, and each one after it is left in the gap between the train and the last —
* so the insertion point is the train's own position in the row, `standingWest`, from either
* side. That is what makes the result batch-invariant: four cars at once, four singles, or two
* pairs all park in the same order, which is the reported bug and the regression test.
* so the insertion point is the card's own `standingWest`, read against this train's side. That
* is what makes the result batch-invariant: four cars at once, four singles, or two pairs all
* park in the same order, which is the reported bug and the regression test.
*/
if (card) {
const cars = carsOn(card);
const facing = railFacingOf(tray);
const onWestSide = e.fromNose ? facing === 'w' : facing === 'e';
const k = Math.max(0, Math.min(cars.length, tray.standingWest ?? 0));
const k = Math.max(0, Math.min(cars.length, card.standingWest));
const cut = trackOrder(e.stock, facing);
cars.splice(k, 0, ...cut);
// The train has not moved, so cars set out on its WEST side push its index along the row;
// The train has not moved, so cars set out on its WEST side push the split along the row;
// cars set out to the east leave it where it was.
tray.standingWest = onWestSide ? k + cut.length : k;
card.standingWest = onWestSide ? k + cut.length : k;
}
spendFreightBudget(s, playerAtSeat(s, trayySeat(tray)), tray, e.at, e.stock);
break;
@@ -1901,6 +1905,7 @@ export function reduce(s: GameState, e: GameEvent): void {
geometry: { kind: 'modifier', modifier: card.kind.modifier },
baseOperationalRail: false,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -2244,7 +2249,7 @@ function protoCard(
kind: { kind: string; geometry?: string; facility?: string; hand?: string },
variant: number | undefined,
): TrackCard | null {
const base = { standing: [], facility: null, modifiers: [], enhancements: [] };
const base = { standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [] };
if (kind.kind === 'track') {
const geometry = kind.geometry as TrackGeometry;
@@ -2452,6 +2457,7 @@ function emptyCard(): TrackCard {
geometry: { kind: 'limits' },
baseOperationalRail: false,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -2724,6 +2730,7 @@ function limitsCard(): TrackCard {
geometry: { kind: 'limits' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
+2
View File
@@ -153,6 +153,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
geometry: { kind: 'office' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: buildPassengerFacility('whistlePost'),
modifiers: [],
enhancements: [],
@@ -162,6 +163,7 @@ function buildOfficeArea(seat: SeatIndex): OfficeArea {
geometry: { kind: 'limits' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
+27 -20
View File
@@ -137,9 +137,35 @@ export type TrackCard = {
* WEST-TO-EAST IS THE BOARD'S ORIENTATION, not the train's, which is exactly why it is the right
* one: it is a property of the card, so it does not change when a different train touches it. The
* two conversions are stated once, in `apply.ts`'s `carsDropped` and `carsCoupled` reducers, and
* `CrewTray.standingWest` records where a train standing here sits among them.
* `standingWest` below records where a train standing here sits among them.
*/
standing: RollingStock[];
/**
* HOW MANY OF `standing` LIE WEST OF THE TRAIN(S) STANDING HERE.
*
* `standing` runs west to east and a train sits somewhere IN that row: cars `[0, standingWest)`
* are west of it, cars `[standingWest, end)` are east. A train can set out off both ends on the
* same square — §A.3 lets a cut come off either outer end — so "which side is this cut on" is not
* recoverable from the array alone, and it is the question every remaining rule asks: which cars
* are AHEAD of an engine and which BEHIND (combine with `CrewTray.railFacing`), which cut a
* departing train must couple back up (the one at the end it leaves by), and which side of the
* chip the renderer draws the cut on.
*
* WITH NO TRAIN STANDING HERE, THIS NUMBER MEANS NOTHING: a card's cars are simply a cut with no
* near or far side, and the next train to arrive couples all of them regardless — nothing reads
* `standingWest` in that state, so a stale value left over from a train that has since moved on is
* inert, not wrong. That reasoning is unchanged from when this lived on `CrewTray`.
*
* WHAT MOVED IT TO THE CARD: the Office square is the one place more than one train may stand at
* once, and only one route lets two trays disagree about the same row of cars — a train already
* at the Office sets out a cut in place, without moving, while a second train on another A/D track
* still holds whatever split it arrived with. A cut lies west or east of the WHOLE block of A/D
* tracks, never of one particular track and never between two trains, so there is exactly one
* number for the card to carry and every train standing there reads the same one. Everywhere else
* on the board at most one train ever stands on a card, so the two homes for this field are
* identical in every other case.
*/
standingWest: number;
facility: Facility | null;
modifiers: ModifierKind[];
/** Enhancement cards laid on this card (§7 of implications.md). */
@@ -345,25 +371,6 @@ export type CrewTray = {
* puts the engine on the other end, which is the one thing the arrow exists to show.
*/
railFacing?: 'e' | 'w';
/**
* HOW MANY OF THIS CARD'S STANDING CARS LIE WEST OF THIS TRAIN.
*
* `carsOn(card)` runs west to east and a train standing on the card sits somewhere IN that row:
* cars `[0, standingWest)` are west of the engine, cars `[standingWest, end)` are east of it. A
* train can set out off both ends on the same square — §A.3 lets a cut come off either outer end —
* so "which side is this cut on" is not recoverable from the array alone, and it is the question
* every remaining rule asks.
*
* It answers all three: which cars are AHEAD of the engine and which BEHIND (combine with
* `railFacing`), which cut a departing train must couple back up (the one at the end it leaves
* by), and which side of the chip the renderer draws the cut on.
*
* Kept on the TRAY rather than the card because it describes a relationship, not the card: with no
* train standing there, a card's cars are simply a cut with no near or far side, and the next
* train to arrive couples all of them regardless. Reset to 0 on every move, which is sound because
* coupling is mandatory — a train arrives on a card it has just emptied.
*/
standingWest?: number;
position: NodeRef;
movesUsed: number;
/**
+1 -10
View File
@@ -316,15 +316,6 @@ export type MoveContext = {
consistSize: number;
/** The tray making the move, so it does not block itself. */
self: TrayId;
/**
* How many cars on the START card stand west of this train (`CrewTray.standingWest`).
*
* The walk begins at `neighbour(start, initialExit)` and never looked at the start card at all, so
* a train drove straight through the cut it had just set out — in both directions, and without
* counting it against the four-car limit. Coupling is mandatory (§A.4) and a cut on your own
* square is no exception: pulling out through the end it sits at picks it up again.
*/
standingWest?: number;
};
function cardAt(area: OfficeArea, c: GridCoord): TrackCard | undefined {
@@ -459,7 +450,7 @@ export function exploreMoves(
*
* Ordered nearest-first like every other card's, so it simply seeds the accumulator.
*/
const ownCut = cutTowards({ standingWest: ctx.standingWest }, carsOn(startCard), initialExit);
const ownCut = cutTowards(startCard, carsOn(startCard), initialExit);
const startKey = coordKey(start);
const queue: Frontier[] = [
{
+112 -33
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,21 +552,49 @@ export function officeSvg(
out += `<text class="bs-cn" x="6" y="12">${esc(cell.label)}</text>`;
/**
* A/D TRACKS, as pips.
*
* 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.
*/
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"/>`;
}
}
out += `<text class="bs-coord" x="${W - 5}" y="12" text-anchor="end">${cell.row},${cell.col}</text>`;
/**
* THE ROSTER, one chip per A/D track — not one per train.
*
* 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.
*/
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>`;
}
}
/**
* THE LOAD PIPELINE, on the card itself, DRAWN THE WAY IT FLOWS.
*
@@ -608,6 +673,9 @@ 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';
// 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.'
@@ -617,6 +685,7 @@ export function officeSvg(
? '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}
+16 -11
View File
@@ -202,9 +202,9 @@ export function compress(frames: Frame[]): Packed {
// Identity, not equality: `snapshot` pushes the very same FacilityView object into both the
// cell and the frame's `facilities`, so this always resolves.
const fi = c.facility ? f.facilities.indexOf(c.facility) : -1;
// `train` rides whole rather than being interned: it changes almost every frame, so a table
// `trains` rides whole rather than being interned: it changes almost every frame, so a table
// of them would be as long as the frames are and buy nothing.
return [ci, wi, c.enhancements, c.tray, c.cars, fi, c.train, c.ad, c.enhancementsWhat, c.standingWest];
return [ci, wi, c.enhancements, c.cars, fi, c.trains, c.adTracks, c.enhancementsWhat, c.standingWest];
});
return { ...f, cells } as unknown as Frame;
});
@@ -238,21 +238,26 @@ export function rehydrateCells(
facs: unknown[],
): unknown[] {
return packed.map((row) => {
const p = row as [number, number, string[], string | null, string[], number, unknown, unknown, string[], number];
const p = row as [number, number, string[], string[], number, unknown, unknown, string[], number];
const c = cards[p[0]] as [number, number, string, string, boolean, string[]];
// Tolerant the same way `standingWest` below is: an array rides through as-is, a lone object
// (an older recording's singular `train`) is wrapped into a one-train roster, and null or
// undefined reads as no train at all.
const rawTrains = p[5];
const trains = Array.isArray(rawTrains) ? rawTrains : rawTrains ? [rawTrains] : [];
return {
row: c[0], col: c[1], kind: c[2], label: c[3], running: c[4], links: c[5],
what: whats[p[1]], enhancements: p[2], tray: p[3], cars: p[4],
facility: p[5] < 0 ? null : facs[p[5]],
train: p[6] ?? null,
ad: p[7] ?? null,
what: whats[p[1]], enhancements: p[2], cars: p[3],
facility: p[4] < 0 ? null : facs[p[4]],
trains,
adTracks: (p[6] as number | undefined) ?? null,
// Carried rather than recomputed: this function is emitted into the page by toString() and so
// cannot reach the card catalogue that produced the text.
enhancementsWhat: p[8] ?? [],
// Where the train on this card stands among the cars standing on it, so the replay draws a cut
// ahead of or behind the engine exactly as the live board does. Absent in older recordings,
enhancementsWhat: p[7] ?? [],
// Where the train(s) on this card stand among the cars standing on it, so the replay draws a
// cut ahead of or behind the engine exactly as the live board does. Absent in older recordings,
// which read as 0 — the whole cut east of the engine, which is what they used to draw anyway.
standingWest: p[9] ?? 0,
standingWest: p[8] ?? 0,
};
});
}
+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,
});
}
+17 -6
View File
@@ -110,8 +110,8 @@ const esc = (s: string): string =>
*/
function piecePreview(links: string[], label: string): string {
const cell = {
row: 0, col: 0, kind: 'trk', label, running: false, enhancements: [],
tray: null, cars: [], facility: null, links, what: '',
row: 0, col: 0, kind: 'trk', label, running: false, enhancements: [], enhancementsWhat: [],
trains: [], adTracks: null, cars: [], standingWest: 0, facility: null, links, what: '',
};
return officeSvg([cell] as never, 99).replace(
/<text class="bs-(coord|rowlab)"[^>]*>[^<]*<\/text>/g,
@@ -301,7 +301,14 @@ function render(): void {
: 'ATTACH TO THIS CARD';
return [{ row: cell.row, col: cell.col, label }];
});
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits);
grid.innerHTML = officeSvg(f.cells, f.runningRow, ghostCoords, legalCaps, f.limits, selectedCrew);
// Wire the roster chips: clicking one sets `selectedCrew`, the same value the "Which train are
// you switching?" picker writes, so the board and the action panel drive one value either way.
for (const g of Array.from(grid.querySelectorAll('g[data-crew]'))) {
const trayId = (g as HTMLElement).dataset['crew'];
if (trayId) (g as unknown as HTMLElement).onclick = () => { selectedCrew = trayId; render(); };
}
// Highlighting rides on top of the drawing: outline the legal squares and make them clickable.
for (const [key, list] of spotsAt) {
@@ -337,8 +344,10 @@ function render(): void {
*/
const crew = pickedCrew(f);
if (crew && !forPlay) {
const here = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"]`);
if (here) here.classList.add('bs-from');
// Marked on the crew strip, not the whole card: the Office is the one square a second train may
// share, and outlining the card would claim it belongs to both.
const strip = grid.querySelector(`g[data-cell="${crew.from.row},${crew.from.col}"] .bs-crew`);
if (strip) strip.classList.add('bs-from');
for (const c of crew.to) {
const g = grid.querySelector(`g[data-cell="${c.row},${c.col}"]`);
if (g) g.classList.add('bs-focus');
@@ -629,7 +638,9 @@ function renderDistrict(f: Frame): void {
else sec.classList.add('folded');
const cars = f.cells.reduce((n, c) => n + c.cars.length, 0);
const crew = f.cells.filter((c) => c.tray).length;
// Trains, not cards-with-a-crew: the Office is the one card that may hold more than one, and a
// card-count silently read "1 crew on the board" with two trains standing at a busy Station.
const crew = f.cells.reduce((n, c) => n + c.trains.length, 0);
$('districtsummary').textContent =
`${f.cells.length} cards · ${f.facilities.length} facilities · ${cars} cars standing` +
(crew > 0 ? ` · ${crew} crew on the board` : '');
+13 -7
View File
@@ -56,6 +56,7 @@ const straight = (standing: TrackCard['standing'] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing,
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -445,6 +446,7 @@ describe('Freight Agent operations (§6.3)', () => {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight',
subtype: 'mineTipple',
@@ -576,6 +578,7 @@ describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight',
subtype: 'mineTipple',
@@ -757,6 +760,7 @@ describe('legalActions shares its rules with apply (component 6)', () => {
geometry: { kind: 'facility', facility: 'mineTipple' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: {
kind: 'freight',
subtype: 'mineTipple',
@@ -944,7 +948,7 @@ describe('the Limits bound the district, and the nine spots reach round a Facili
const area = areaOf(s, 0);
const plain = (geometry: object): TrackCard => ({
geometry: geometry as TrackCard['geometry'],
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
const col = area.limitsEast.col;
area.grid.set(coordKey(at(area.runningRow, col)), plain({
@@ -1294,7 +1298,7 @@ describe('Industry cards go on a stub, and lock each other out', () => {
const area = areaOf(s, 0);
const plain = (geometry: object): TrackCard => ({
geometry: geometry as TrackCard['geometry'],
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
const col = area.limitsEast.col;
area.grid.set(coordKey(at(area.runningRow, col)), plain({
@@ -1326,7 +1330,7 @@ describe('Industry cards go on a stub, and lock each other out', () => {
function build(s: GameState, kind: string, coord: GridCoord): void {
areaOf(s, 0).grid.set(coordKey(coord), {
geometry: { kind: 'facility', facility: kind as never },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
}
@@ -1621,8 +1625,9 @@ describe('the engine is drawn pointing east or west, whatever track it is standi
addCard(s, at(0, 0), straight());
reduce(s, moved(id, 's'));
const cell = snapshot(s, [], null).cells.find((c) => c.row === 0 && c.col === 0);
assert.ok(cell?.train, 'the train should be on the card');
assert.ok(cell.train.facing === 'e' || cell.train.facing === 'w', `got ${cell.train.facing}`);
const train = cell?.trains[0];
assert.ok(train, 'the train should be on the card');
assert.ok(train.facing === 'e' || train.facing === 'w', `got ${train.facing}`);
});
});
@@ -1646,6 +1651,7 @@ describe('a train that rounds a curve points where the curve took it', () => {
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -1782,11 +1788,11 @@ describe('the switching job a player actually does: put a car in a siding, take
*/
const curve = (arc: 'ne' | 'nw' | 'se' | 'sw'): TrackCard => ({
geometry: { kind: 'track', geometry: 'curved', arc, hand: 'right' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
const turnout = (o: { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' }): TrackCard => ({
geometry: { kind: 'track', geometry: 'turnout', turnout: o, hand: 'right' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
it('takes a cut off the Running Track into a siding and sets it out', () => {
+9 -1
View File
@@ -36,6 +36,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing,
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
@@ -149,7 +150,14 @@ describe('a cut parks in the same order however many cars come off at once', ()
// Facing east: the nose cut (boxcar) is set out east of the engine, the tail cut (tank) west.
assert.deepEqual(standingAt(s, at(1, 0)), ['tank', 'boxcar']);
assert.equal(s.trays.get(id)!.standingWest, 1, 'the engine should be standing between the two cuts');
// `standingWest` lives on the CARD now, not the tray (docs/plans/switching-paths.md — "The
// Roster Pass"): the Office is the one square more than one train may share, and a cut lies
// west or east of the block of A/D tracks rather than of any one engine.
assert.equal(
areaOf(s, 0).grid.get(coordKey(at(1, 0)))!.standingWest,
1,
'the engine should be standing between the two cuts',
);
});
});
+1
View File
@@ -29,6 +29,7 @@ const straight = (): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
+1
View File
@@ -313,6 +313,7 @@ describe('Flying Switch rolls a cut into an industry', () => {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing: [],
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
+3 -3
View File
@@ -450,7 +450,7 @@ describe('the view shows one seat at a time', () => {
for (let n = 0; n < p; n++) {
area.grid.set(coordKey({ row: area.runningRow - 1, col: n }), {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
}
s.decks.hands.set(p, source.slice(0, p + 1));
@@ -527,7 +527,7 @@ describe('the view shows one seat at a time', () => {
readyToLeave(s, 1, 'crew-1', 'east');
const withTray = (p: PlayerIndex): number =>
snapshot(s, [], null, null, null, false, p).cells.filter((c) => c.tray != null).length;
snapshot(s, [], null, null, null, false, p).cells.filter((c) => c.trains.length > 0).length;
assert.equal(withTray(1), 1, "the crew is not on its own seat's board");
assert.deepEqual([withTray(0), withTray(2)], [0, 0], "a crew was drawn onto another seat's board");
});
@@ -594,7 +594,7 @@ describe('scoring lands on the right seat', () => {
const sizeBefore = areaAtSeat(s, 2).grid.size;
mine.grid.set(coordKey({ row: mine.runningRow - 1, col: 0 }), {
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
assert.equal(areaAtSeat(s, 2).grid.size, sizeBefore, "one seat's track appeared in another's district");
assert.notEqual(areaAtSeat(s, 1), areaAtSeat(s, 2), 'two seats share one Office Area object');
+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);
+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: [],
+1
View File
@@ -30,6 +30,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({
geometry: { kind: 'track', geometry: 'straight' },
baseOperationalRail: true,
standing,
standingWest: 0,
facility: null,
modifiers: [],
enhancements: [],
+138 -13
View File
@@ -756,8 +756,8 @@ describe('the board draws the printed card (docs/tracks.png)', () => {
/** One card, alone, so the measurements are in card coordinates. */
const draw = (links: string[]): { x1: number; y1: number; x2: number; y2: number }[] => {
const cell = {
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [],
tray: null, cars: [], facility: null, links, what: '',
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
trains: [], adTracks: null, cars: [], standingWest: 0, facility: null, links, what: '',
};
const svg = officeSvg([cell] as never, 0);
return [...svg.matchAll(/<line class="bs-rail" x1="([-\d.]+)" y1="([-\d.]+)" x2="([-\d.]+)" y2="([-\d.]+)"\/>/g)]
@@ -931,7 +931,7 @@ describe('the page explains itself', () => {
const area = game.state.officeAreas.get(0)!;
area.grid.set('-1,0', {
geometry: { kind: 'track', geometry: 'turnout', turnout: { stem: 'e', through: 'w', diverge: 's' } },
baseOperationalRail: true, standing: [], facility: null, modifiers: [], enhancements: [],
baseOperationalRail: true, standing: [], standingWest: 0, facility: null, modifiers: [], enhancements: [],
});
const cell = view(game).cells.find((c) => c.row === -1 && c.col === 0)!;
// Said the way a player would ask it: if my train comes in from over there, where can it go?
@@ -1960,10 +1960,11 @@ describe('the static build', () => {
} as never);
const cell = view(game).cells.find((c) => c.row === area.runningRow && c.col === 0)!;
assert.ok(cell.train, 'the card carries no train');
assert.deepEqual(cell.train!.cars, ['loaded tank', 'loaded boxcar', 'caboose'], 'car order is lost');
assert.equal(cell.train!.engineAt, 0, 'the engine has no place in the train');
assert.equal(cell.train!.facing, 'e', 'which way it points is not carried');
const train = cell.trains[0];
assert.ok(train, 'the card carries no train');
assert.deepEqual(train!.cars, ['loaded tank', 'loaded boxcar', 'caboose'], 'car order is lost');
assert.equal(train!.engineAt, 0, 'the engine has no place in the train');
assert.equal(train!.facing, 'e', 'which way it points is not carried');
const svg = officeSvg([cell], area.runningRow);
assert.match(svg, /bs-t-eng/, 'the engine is not drawn');
@@ -2006,16 +2007,97 @@ describe('the static build', () => {
assert.deepEqual(order('e'), ['cab', 'hop', 'hop', '▶'], 'an eastbound train is not turned round');
});
it('shows A/D tracks on the Office card, taken and total', () => {
it('shows A/D tracks on the Office card, one chip per track', () => {
// REPORTED: the tooltip said "3 A/D tracks" and the card showed nothing — the number that
// decides whether the next arrival is an automatic collision (§8.3).
// decides whether the next arrival is an automatic collision (§8.3). The Roster Pass replaced
// the pips with one roster chip per A/D track (docs/plans/switching-paths.md), free or occupied.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
const cell = view(game).cells.find((c) => c.kind === 'office')!;
assert.ok(cell.ad, 'the Office card carries no A/D count');
assert.equal(cell.ad!.of, 1, 'a Whistle Post has one A/D track');
assert.equal(cell.ad!.used, 0);
assert.match(officeSvg([cell], area.runningRow), /bs-ad/, 'no A/D pips are drawn');
assert.equal(cell.adTracks, 1, 'a Whistle Post has one A/D track');
assert.equal(cell.trains.length, 0, 'no train is standing yet');
const svg = officeSvg([cell], area.runningRow);
assert.match(svg, /bs-adchip/, 'no roster chip is drawn');
assert.match(svg, />free</, 'the one A/D track should read free with nothing standing on it');
});
it('draws every train standing at a busy Office, not just one', () => {
// REPORTED (docs/plans/switching-paths.md — "The Roster Pass"): the old single `train` field on
// a cell showed only whichever tray `s.trays` happened to yield first, so a second train holding
// at a Station was counted in the old A/D pips and never drawn at all.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
area.tier = 'terminal'; // 4 A/D tracks — room for every train this test parks
const at = area.officeCoord;
const trainNumbers = [10, 12, 14, 16];
for (const n of trainNumbers) {
const id = `t${n}`;
game.state.trays.set(id, {
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'boxcar', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
} as never);
area.adOccupancy.push(id);
}
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
assert.equal(cell.trains.length, 4, 'all four trains should be on the Office card');
assert.equal(cell.adTracks, 4, 'a Terminal has four A/D tracks');
const svg = officeSvg([cell], area.runningRow);
const chipCount = [...svg.matchAll(/class="bs-adchip/g)].length;
assert.equal(chipCount, 4, `expected 4 roster chips at a full Terminal, got ${chipCount}`);
assert.doesNotMatch(svg, />free</, 'every track is taken, so nothing should read free');
for (const n of trainNumbers) {
assert.match(svg, new RegExp(`data-crew="t${n}"`), `T${n} has no roster chip to click`);
}
});
it('draws a bare cut left-aligned, whatever the card\'s stored standingWest says', () => {
// "Settled questions" in docs/plans/switching-paths.md — the split only ever predicts something
// while a train stands on the card, and an earlier draft of the plan had this backwards. A stale
// standingWest surviving on an emptied card must not draw a gap nobody would recouple.
const cell = {
row: 0, col: 0, kind: 'trk', label: 'x', running: true, enhancements: [], enhancementsWhat: [],
trains: [], adTracks: null,
cars: ['boxcar', 'loaded hopper', 'reefer'],
standingWest: 2, // stale — as if a train had once stood here and left
facility: null, links: ['ew'], what: '',
};
const svg = officeSvg([cell as never], 0);
const xs = [...svg.matchAll(/class="bs-slot[^"]*" x="([-\d.]+)"/g)].map((m) => Number(m[1]));
assert.equal(xs.length, 3, 'all three cars should be drawn');
const sorted = [...xs].sort((a, b) => a - b);
// Left-aligned means each slot sits exactly 30px past the one before it — no gap anywhere in
// the row for an engine that is not there.
for (let i = 1; i < sorted.length; i++) {
assert.equal(sorted[i]! - sorted[i - 1]!, 30, `a gap survived at index ${i} with no train present`);
}
});
it('reads one split off the shared card, not one per train', () => {
// The whole justification for moving `standingWest` onto the card rather than the tray
// (docs/plans/switching-paths.md): two trays holding at one Office must not be able to disagree
// about the same row of cars, because there is only one number for them to read.
const game = newGame(555);
const area = game.state.officeAreas.get(0)!;
area.tier = 'station'; // 3 A/D tracks
const at = area.officeCoord;
const card = area.grid.get(`${at.row},${at.col}`)!;
card.standing = [{ type: 'boxcar', loaded: false }, { type: 'hopper', loaded: false }];
card.standingWest = 1;
for (const n of [10, 12]) {
const id = `t${n}`;
game.state.trays.set(id, {
id, trainNumber: n, trainIsExtra: false, engineAt: 0, consist: [],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: at }, movesUsed: 0,
} as never);
}
const cell = view(game).cells.find((c) => c.row === at.row && c.col === at.col)!;
assert.equal(cell.trains.length, 2, 'both trains should be on the Office card');
assert.equal(cell.standingWest, 1, "the card's split is one number, not one per train");
});
it('names the cars a drop would set out, and which end they come off', () => {
@@ -2234,6 +2316,49 @@ describe('the Division map shows the whole route', () => {
}
});
it('keeps every roster chip inside the Office cell it belongs to, at any occupancy', () => {
// "Two Trains, One Card": sizing the cell by OCCUPANCY moved the East Division Point sideways
// every time an A/D track filled or cleared. Sizing by CAPACITY (docs/plans/switching-paths.md)
// holds the map still, and every chip has to actually fit inside the cell that capacity bought.
const s = createEngineGame({
id: 'div-chips', seed: 7,
config: {
mode: 'solitaire', victory: 'highestAfterDays', length: 'standard',
optionalRules: { reducedVisibility: false, sisterTrains: false, employeeRotation: false, emergencyToolbox: false },
},
playerNames: ['Solitaire'],
});
const area = areaOf(s, 0);
area.tier = 'terminal'; // 4 A/D tracks
for (const n of [10, 12, 14, 16]) {
const id = `t${n}`;
s.trays.set(id, {
id, trainNumber: n, trainIsExtra: false, engineAt: 0,
consist: [{ type: 'boxcar', loaded: false }],
direction: 'east', facing: 'e',
position: { at: 'grid', seat: 0, coord: area.officeCoord }, movesUsed: 0,
} as never);
area.adOccupancy.push(id);
}
const svg = divisionSvg(snapshot(s, [], null).division);
const cells = [...svg.matchAll(/class="bs-dcell bs-drun[^"]*"[^>]*><rect x="([\d.]+)" y="([\d.]+)" width="([\d.]+)" height="([\d.]+)"/g)]
.map((m) => ({ x: +m[1]!, w: +m[3]! }));
assert.ok(cells.length > 0, 'no Running Track cell drawn');
const office = cells.reduce((a, b) => (b.w > a.w ? b : a));
assert.ok(office.w > 78, `the Office cell did not widen for its 4 A/D tracks: ${office.w}`);
const chips = [...svg.matchAll(/class="bs-train"[^>]*><rect x="([-\d.]+)" y="[-\d.]+" width="([\d.]+)"/g)]
.map((m) => ({ x: +m[1]!, w: +m[2]! }));
assert.equal(chips.length, 4, `expected 4 train chips, got ${chips.length}`);
for (const c of chips) {
assert.ok(
c.x >= office.x - 0.01 && c.x + c.w <= office.x + office.w + 0.01,
`a chip at x=${c.x} w=${c.w} spills outside the Office cell [${office.x}, ${office.x + office.w}]`,
);
}
});
it('draws the Division as a line with two ends, never as a loop', () => {
// Seating players around a table invites exactly one misreading: that the route joins up. It
// does not. Both ends are labelled as ends, not as an entrance and an exit: odd trains run west