diff --git a/CHANGELOG.md b/CHANGELOG.md index 362edae..d0fd744 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,9 +21,10 @@ page as `v0.1.0 · · `, 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 diff --git a/src/engine/apply.ts b/src/engine/apply.ts index ab2d969..943a7d8 100644 --- a/src/engine/apply.ts +++ b/src/engine/apply.ts @@ -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,9 +1905,10 @@ export function reduce(s: GameState, e: GameEvent): void { geometry: { kind: 'modifier', modifier: card.kind.modifier }, baseOperationalRail: false, standing: [], + standingWest: 0, facility: null, modifiers: [], - enhancements: [], + enhancements: [], }); applyModifier(area, e.placement, card.kind.modifier); } else if (card.kind.kind !== 'office') { @@ -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: [], diff --git a/src/engine/setup.ts b/src/engine/setup.ts index cc1e368..3438078 100644 --- a/src/engine/setup.ts +++ b/src/engine/setup.ts @@ -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: [], diff --git a/src/engine/state.ts b/src/engine/state.ts index 0f3b2cd..1a69faf 100644 --- a/src/engine/state.ts +++ b/src/engine/state.ts @@ -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; /** diff --git a/src/engine/track.ts b/src/engine/track.ts index 07f4b85..06fa30f 100644 --- a/src/engine/track.ts +++ b/src/engine/track.ts @@ -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[] = [ { diff --git a/src/sim/board-svg.ts b/src/sim/board-svg.ts index d7e8b75..b0a09b7 100644 --- a/src/sim/board-svg.ts +++ b/src/sim/board-svg.ts @@ -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 += ``; + out += ``; out += ``; // 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 += `${esc(cell.label)}`; + out += `${cell.row},${cell.col}`; + /** - * 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 += ``; + 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 += + `` + + `free`; + 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 += + `` + + `` + + `${esc(chipLabel)}`; } } - out += `${cell.row},${cell.col}`; /** * 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 += `${esc(flow)}`; + // 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 += `${esc(flow)}`; + } /** * 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(' · '))}`; } - 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} diff --git a/src/sim/replay.ts b/src/sim/replay.ts index ba81d5b..f9c1373 100644 --- a/src/sim/replay.ts +++ b/src/sim/replay.ts @@ -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, }; }); } diff --git a/src/sim/view.ts b/src/sim/view.ts index ad96197..5dce3b7 100644 --- a/src/sim/view.ts +++ b/src/sim/view.ts @@ -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(); - 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, }); } diff --git a/src/web/main.ts b/src/web/main.ts index 4975a61..6507ecc 100644 --- a/src/web/main.ts +++ b/src/web/main.ts @@ -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>/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` : ''); diff --git a/test/apply.test.ts b/test/apply.test.ts index 6173052..e4d1323 100644 --- a/test/apply.test.ts +++ b/test/apply.test.ts @@ -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', () => { diff --git a/test/cut-ordering.test.ts b/test/cut-ordering.test.ts index 7adb565..21ea947 100644 --- a/test/cut-ordering.test.ts +++ b/test/cut-ordering.test.ts @@ -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', + ); }); }); diff --git a/test/enhancements.test.ts b/test/enhancements.test.ts index 2f08e3e..cd04154 100644 --- a/test/enhancements.test.ts +++ b/test/enhancements.test.ts @@ -29,6 +29,7 @@ const straight = (): TrackCard => ({ geometry: { kind: 'track', geometry: 'straight' }, baseOperationalRail: true, standing: [], + standingWest: 0, facility: null, modifiers: [], enhancements: [], diff --git a/test/mainline-cards.test.ts b/test/mainline-cards.test.ts index f53a1ac..2135649 100644 --- a/test/mainline-cards.test.ts +++ b/test/mainline-cards.test.ts @@ -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: [], diff --git a/test/multiplayer.test.ts b/test/multiplayer.test.ts index 751fba6..4332b40 100644 --- a/test/multiplayer.test.ts +++ b/test/multiplayer.test.ts @@ -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'); diff --git a/test/replay.test.ts b/test/replay.test.ts index c40b729..d59109a 100644 --- a/test/replay.test.ts +++ b/test/replay.test.ts @@ -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); diff --git a/test/track.test.ts b/test/track.test.ts index 23804d6..458e8f1 100644 --- a/test/track.test.ts +++ b/test/track.test.ts @@ -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 = { '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: [], diff --git a/test/train-rules.test.ts b/test/train-rules.test.ts index e4fff58..416c94d 100644 --- a/test/train-rules.test.ts +++ b/test/train-rules.test.ts @@ -30,6 +30,7 @@ const straight = (standing: RollingStock[] = []): TrackCard => ({ geometry: { kind: 'track', geometry: 'straight' }, baseOperationalRail: true, standing, + standingWest: 0, facility: null, modifiers: [], enhancements: [], diff --git a/test/web.test.ts b/test/web.test.ts index 4e6be5b..60603d2 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -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(//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 { + // 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 { + // "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[^"]*"[^>]*> ({ 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"[^>]*> ({ 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