/** * The view-model: what a Station Master position LOOKS like. * * Split out of `replay.ts` so the playable browser build can use it. `replay.ts` writes files and * reads `process.argv`, so importing it pulled `node:fs` into the bundle — and the engine's whole * claim to run client-side rests on nothing in the import graph needing Node. * * Nothing here decides anything. It turns a `GameState` into boxes and labels, and turns an * `Intent` into a sentence. The live game and the replay both render from this, so they cannot * drift into two different pictures of the same board. */ import { areaAtSeat, areaOf, destinationsFor, facilityCarType, laborersLeft, movesFor, ownCutFor, portersLeft, selectDestination, } from '../engine/apply.ts'; import { ACTION_CARDS, ENHANCEMENT_CARDS, HAND_LIMIT, MAINLINE_MODIFIER_CARDS, MAINLINE_PROFILES, MANEUVER_CARDS, MODIFIER_PROFILES, REALIGNMENTS, REGIONS_PER_MAINLINE_CARD, OFFICE_ORDER, SPACE_USE_CARDS, enhancementRule, enhancementText, industryProfile, mainlineProfile, modifierProfile, officeProfile, trainProfile, houseRules, mainlineDescription, } from '../engine/content.ts'; import type { Intent } from '../engine/intents.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'; import { connectionsFor, slopeOfPair, variantsFor } from '../engine/track.ts'; import type { Impediment } from './narrate.ts'; import { carLabel, carsLabel, clockTime, impediments, phaseLabel } from './narrate.ts'; export type CellView = { row: number; col: number; kind: string; label: string; running: boolean; /** * Enhancements laid ON this card. They were invisible: playing an Overpass onto the Office * announced itself in the log and then changed nothing on the board, so a permanent change to how * the district works left no trace you could see. */ enhancements: string[]; /** * What each of those enhancements does, in the same order — and whether it does it yet. * * Carried on the view model because `board-svg.ts` imports nothing (the replay embeds it via * `toString()`), so it cannot reach the card catalogue itself. */ enhancementsWhat: string[]; /** * 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. */ trains: { trayId: string; label: string; cars: string[]; engineAt: number; facing: 'e' | 'w'; /** * WHAT THIS PARTICULAR TRAIN'S CARD SAYS. * * Reported from a playtest: the Circus Train arrived and there was no way to find out what made * it a Circus Train. A special train is special only if the player can read the rule while it is * standing in front of them — the card is face down in a box somewhere by then. */ what: string; }[]; /** * Office card only: how many A/D tracks the tier has — null everywhere else. * * 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. */ adTracks: number | null; cars: string[]; /** * 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 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; /** * The port pairs this card joins, as two-letter codes — 'ew' for the through track, and 'ne', * 'nw', 'se' or 'sw' for a 45° leg. There is no 'ns': no card joins north to south. Taken from * the engine's own `connectionsFor`, so a drawn rail can never claim a connection the rules do * not have. */ links: string[]; /** * What this card DOES, now that it is on the board. * * A played card becomes a cell with a name on it and nothing else — "turnout", "Freight House", * "waiting area" — so the explanation that was visible while it sat in hand disappears at exactly * the moment it starts mattering. */ what: string; }; export type FacilityView = { /** * Freight or passengers — the one thing the renderers could not previously ask. * * Without it they guessed from side effects (a non-empty industry track, `laborers` starting * "0/0"), and one * of the three simply did not guess at all, so a Depot drew three MEN | AT | WORK boxes it has no * Laborer to work. Reported from playtesting. */ kind: 'freight' | 'passenger'; name: string; commodity: string; flow: string; green: string[]; greenCap: number; maw: (string | null)[]; red: string[]; redCap: number; /** * Cars standing on the industry's track, and nothing about how many more will fit. * * `trackCap` used to ride alongside this and every renderer drew that many empty squares — a * printed siding, as long as the industry's box count. No industry card prints one, and the * number was wrong anyway: a card holds four cars like any other. Both the field and the squares * are gone; the room left is `spaceOn`'s business, not a drawing's. */ track: string[]; laborers: string; porters: string; /** * What the INDUSTRY CARD itself prints, before any Modifier beside it. * * A modifier's whole effect is a number going up, and the panel showed only the number — so an Ice * House raising outbound capacity from 0 to 1 and laborers from 1 to 2 looked like nothing had * happened. Reported after playing an Ice House and Local Small Groceries and seeing no change * anywhere. Keeping the base lets the panel say "2 (1 + 1 from a Modifier)". */ base: { out: number; in: number; laborers: number; porters: number }; /** * Printed grants this host's flow throws away, phrased for a tooltip. * * An industry's flow is absolute, so an Ice House beside a Grocer's Warehouse gives its Laborer and * nothing else — the "+1 out" has no direction to go in. Left unsaid that reads as a bug: reported * from playtesting as "the Ice House added the laborer but not the outbound slot". Naming it turns * a number that failed to move into a rule the player can see. */ suppressed: string[]; /** * Which way freight actually flows here, so the pipeline can be DRAWN in that direction. * * §9.3 runs loading Green → MEN → AT → WORK → car, and unloading the other way: car → WORK → AT → * MEN → red. So green and red both sit beside MEN, and the car sits beside WORK. Drawing red at * the far right — where the car is — made an unload look like it ran backwards across the whole * row and then landed on the wrong end. */ allowsOut: boolean; allowsIn: boolean; /** The Modifier cards standing beside it, by name. */ modifiers: string[]; /** * Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on * WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock * that held freight to a 3% completion rate. */ canFinish: boolean; /** A load is sitting on WORK with no spotted car to receive it. */ jammed: boolean; }; export type TrainChip = { label: string; /** Nose first, with `ENG` seated where the engine actually is. The words, for a tooltip. */ consist: string[]; /** * THE SAME TRAIN THE OFFICE CARD DRAWS, so the Division map can draw it the same way. * * `cars` is nose first and carries no engine; `engineAt` is where the engine sits among them and * `facing` is which way the engine points, east or west. The Division chip used to be a name and a * number — and the number was Stages left to cross, which reads as redundant beside the position * already drawn on the card. A train is worth drawing: what it is carrying, loaded or empty, and * which end leads. */ cars: string[]; engineAt: number; facing: 'e' | 'w'; /** * The train's printed card — what it may and may not do. * * The Office card has carried this for a while; the Division map chip carried only the consist, so * a train out on the Mainline could not be asked what it was. Same text either way, from * `trainRules`, so the two views cannot describe one train differently. */ what: string; /** * Which region of a Mainline card the train is standing in, and which way it is going. Absent * everywhere else: a Division Point is a single queue, and inside a district a train moves by * Moves rather than by Stages, so it occupies a card outright. */ region?: number; direction?: string; /** * Stages still to run before it is off this Mainline card — NOT the same as regions left. * * A card is two regions of fixed distance; the Stages are how long this train takes over them * (`crossingStages`): a 60 card is one Stage, a 30 card two, a Slow train adds one. So they * coincide only in the middle case. It rode on the chip as "· 2⧗" and was read as a car count; * it belongs in the tooltip, where there is room to say which it is. */ stagesLeft?: number; }; /** * One card of a player's Running Track, as the Division sees it. * * The through route between the Limits IS the Running Track — everything hanging beneath it is * secondary, and a train crossing the Division never touches it. So the Division map expands an * Office into this row and leaves sidings, industries and the load pipeline to the district view. */ export type RunningCardView = { row: number; col: number; kind: string; label: string; /** Port pairs, from the engine's `connectionsFor`, so the drawn rail cannot invent a join. */ links: string[]; /** Trains standing ON this card of the Running Track. */ trains: TrainChip[]; }; /** * A seat as a PERSON counts them, from 1. * * Seats are zero-based everywhere inside — `PlayerIndex`, `seating`, the seats array, every route * — and that must not change, since it is what indexes into all of them. But nobody sitting down * at a table calls their chair "seat 0", so the number on screen is the one they would say out * loud. Every user-facing seat goes through here, so the two conventions cannot drift apart. * * It lives here rather than in `web/game.ts` because the page may not import values from that * module — they are the local engine by another name, and `test/session.test.ts` fails the build * for it. This is presentation, which is what `view.ts` is for. */ export function seatLabel(seat: number): number { return seat + 1; } export type DivisionView = { kind: string; label: string; trains: TrainChip[][]; /** * How many trains may stand here, or null for no limit. 1 on most Mainline cards, 2 where the * card prints "trains may pass", the A/D count at an Office, unlimited at a Division Point. */ capacity: number | null; /** Modifiers laid on a Mainline card (Brakeman, Helpers, Realignment …). */ modifiers: string[]; /** * Which way a Heavy Grade climbs. The card prints "(Up)" and "Player sets orientation", so the * direction is a property of the placed card — and without showing it, a Brakeman or Helpers card * on that grade has no visible meaning. */ gradeUp: string | null; /** * Mainline cards only: what the card does to a train, from `mainlineDescription`. * * "I have no idea the impact Hilly and Uncontrolled Siding have on game play" — and there was * nowhere to find out: the tip carried the card's NAME and its modifiers and nothing about the * crossing time or whether trains may pass. */ what?: string; /** Mainline cards only: how many regions the card is divided into (§2.1 — two). */ regions?: number; /** Office nodes only: the Running Track, Limits to Limits, west to east. */ running?: RunningCardView[]; /** Office nodes only: whose district this is. */ /** Which SEAT's district this is — a position on the Division, not a player. */ seat?: number; /** * Office nodes only: crews working BELOW the Running Track. * * A crew down a siding has no position on the through route — projecting one would be a lie — so * it is reported against the district as a whole and drawn exactly where it is in the zoomed view. */ switching?: TrainChip[]; }; export type Frame = { day: number; stage: number; clock: string; phase: string; /** The raw phase, so a caller can mark WHICH of the five is current without parsing the label. */ phaseKey: string; /** * Sounds this frame earned — a Stage ending, a Day turning, a train being built. Filled by the * replay recorder, which sees the events; the live game keeps its own on the Game object. */ cues?: string[]; actor: number | null; superintendent: number; revenue: number; /** * The rest of what a client needs so it never has to reach into `GameState`. * * The browser client used to read `game.state` in eleven places for exactly these. That is fine * with the engine in the same process and impossible with a server, where the client holds no * state at all — so they live on the projection instead. See `docs/architecture/multiplayer.md` §5. */ /** Which of §6's three exclusive options the VIEWER has taken this Stage, if any. */ option: 'switch' | 'draw' | 'freightAgent' | null; /** * The settings this game was dealt under — the opening hand, and what the three economies pay. * * On the Frame rather than read off the config, for the same reason as everything else here: a * remote client holds no `GameState`, and "what does a load pay in this game?" is a question it * must be able to answer. Resolved, never partial, so nobody downstream re-applies defaults. */ houseRules: HouseRules; /** * The victory-condition dials this game was configured with (`GameConfig`, `state.ts`), plus the * running collision counts — same reasoning as `houseRules`: a remote client holds no `GameState` * and needs to show live progress ("2 of 3 collisions today") without guessing a default. `0` on * any `max*`/`minCombinedRevenue` field means that check is off. */ days: number; minCombinedRevenue: number; maxCollisionsPerDay: number; maxCollisionsTotal: number; collisionsToday: number; collisionsTotal: number; status: GameState['status']; outcome: GameState['outcome']; /** * Every PLAYER's public standing — names and Revenue. "The race is the game" (protocol.md §4). * * In player order, not seat order, because the list is about people. `seat` is carried so a client * that wants to draw the table west-to-east can sort by it — which stopped being the same thing as * player order once §4.4's D12 decided who sits where. */ players: { index: number; seat: number; name: string; revenue: number; hand: number }[]; /** * WHO THIS FRAME WAS BUILT FOR. * * Every private thing on a Frame is already scoped to one player — the hand, the Office Area, * `revenue`, `option`, `movesLeft` — but nothing said which player that was, so a page rendering * it could show a railroad without being able to say whose it is. Harmless in solitaire, where * there is only one; the first thing you want to know at a four-player table. */ viewer: number; /** The viewer's position in the west-to-east chain, which is not their player index (§4.4). */ viewerSeat: number; /** * §4.4's opening D12 per player, and the roll that chose the Superintendent — kept so a client * can show the chain being formed rather than only its result (`lobby-and-sessions.md` §4). * Indexed by player, like `s.players`, not by seat. */ openingRolls: { division: number[]; superintendent: number[] }; /** How many cards the VIEWER holds. Other players' counts are in `players`. */ handCount: number; /** * True when the VIEWER's hand is over §6.2's limit and their turn cannot end until it is played * down. Duplicates `game.ts`'s `overHandLimit(game, seat)` at the engine-data level rather than * importing the web layer here — a `RemoteSession` (Phase 2) has no `GameState` to compute this * from, only a `Frame`, so it has to already be resolved on the wire. */ overHandLimit: boolean; lines: { text: string; tone: string }[]; where: { row: number; col: number } | null; /** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */ whereFrom: { row: number; col: number } | null; division: DivisionView[]; cells: CellView[]; /** Which grid row is the Running Track — the spine the district hangs beneath. */ runningRow: number; /** * The columns the Limits signs stand in — the district's east and west edges, at EVERY row. * * Track may not be laid outside them (§2.1), so a player looking at open ground beyond a sign is * looking at ground no card of his will ever go on. Two signs on one row could not say that; the * board draws the boundary down the whole district instead. Carried on the Frame rather than read * off the area for the usual reason: a remote client holds no `GameState`. */ limits: { west: number; east: number }; /** * Moves left in this Local Operations turn, or null outside a switching turn. * * It was reported only in the history, which is the one panel a player is NOT looking at while * switching — the count that decides whether a run-around is still possible belongs next to the * moves themselves. */ movesLeft: number | null; /** * WHERE THE CREW CAN GO, AND WHY NOT ELSEWHERE — while it is switching, and null otherwise. * * The switching game was played off a list of coordinates: "move to (0, -2)" as a button, with * nothing on the board and no account of the squares that were missing from the list. Reported as * trains being blocked from entering an industry "in certain conditions", with no way to see what * the conditions were. * * `blocked` comes out of the movement walk itself (`movesFor`), so a reason on screen is the rule * that actually refused the square rather than a second guess at it. */ /** * ONE ENTRY PER CREW, not one for the district. * * This used to be a single object built from the FIRST tray in the map, with a comment admitting * it: "One crew. Solitaire has one, and with more the answer would depend on which is selected". * More than one crew in a district is ordinary — trains stand on the A/D tracks while a local * shunts — and when it happened the board highlighted one crew's squares while the action list * offered every crew's moves, with nothing saying which was which. */ moves: { trayId: string; /** "Train 8", "the local crew" — what to call it on screen. */ label: string; from: { row: number; col: number }; to: { row: number; col: number }[]; blocked: { coord: { row: number; col: number }; kind: string; why: string }[]; }[]; facilities: FacilityView[]; hand: string[]; /** What each hand card does, in the same order — names alone are not a playable hand. */ handWhat: string[]; deck: number; /** The face-up card on top of each Department pile — the only one that may be drawn. */ departments: string[]; /** What each face-up Department card does. */ departmentsWhat: string[]; /** * How deep each Department pile is. * * A discard goes on TOP, so a deep pile is a card a rival buried and a shallow one is a card * freshly offered. Without the depth the board cannot say which, and choosing where to discard is * the whole of the decision. */ departmentDepth: number[]; /** * The Salvage Yard — face up (§2.6), so its top card and its depth are both public. * * It is where a played card goes when it does not stay on the board, and §6.2 sweeps it back into * the Home Office deck when that runs out. Watching it fill is watching the reshuffle approach, * which is the only warning a player gets that the deck is about to turn over. */ salvage: { top: string; depth: number }; /** * The two yards, by car type. * * Rolling stock is finite and the Classification Yard only returns to service when the Division * Yard is BARE, so watching the Division Yard run down is now real information — and the game * showed neither yard at all. */ yards: { division: { type: string; loaded: number; empty: number }[]; classification: { type: string; loaded: number; empty: number }[]; divisionTotal: number; classificationTotal: number; }; /** 12 slots; the train number due out at each Stage, or null. */ timetable: (number | null)[]; /** * THE TRAIN'S CARD, SLOT BY SLOT — so a card played on Day 1 can still be read on Day 4. * * Reported from play: "once a train card's been played, how would I see that particular train card * again — what it's allowed to do and not allowed to do, and how it has to be loaded?" The card * goes onto the Timetable and is then gone, and its restrictions are what decide whether a train * can be switched, worked by Porters, or loaded at all. The Timetable is where the player already * looks for that train, so the card rides there. */ timetableWhat: (string | null)[]; blocked: Impediment[]; trains: { label: string; where: string }[]; /** * Where you stand against the target. Nothing on screen said what the game was FOR, so a player * had the score but no way to know whether it was good. */ objective: { target: number; days: number; daysLeft: number; onPace: boolean; note: string }; /** What the bot chose here, why, and what it passed over. Null on engine-driven frames. */ decision: Decision | null; /** A Local Operations turn that changed nothing — the frames worth your attention. */ wasted: boolean; }; /** * The choice behind a frame. * * The whole point is `rejected`: the replay could always show what happened, never what COULD have * happened, so a daft move was visible but the alternatives it passed over were not — which is * exactly what you need to say what it should have done instead. */ export type Decision = { actor: number; chose: string; why: string; /** Every legal option not taken, grouped by kind with a count. */ rejected: { kind: string; count: number; detail: string }[]; totalOptions: number; }; // --------------------------------------------------------------------------- // Snapshotting // --------------------------------------------------------------------------- const FACILITY_NAMES: Record = { mineTipple: 'Mine Tipple', produceShed: 'Produce Shed', grocersWarehouse: "Grocer's Warehouse", oilRefinery: 'Oil Refinery', powerPlant: 'Power Plant', }; function facilityView( card: { geometry: { kind: string; facility?: string }; facility: unknown; modifiers?: string[] }, officeName: string, viewerSeat: SeatIndex, ): FacilityView | null { const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility; // Passenger facilities were excluded entirely, so the Office's green and red slots never // appeared — which is why stocking a coach into the green box looked like nothing happening. if (!f) return null; if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null; const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : ''; return { kind: f.kind, name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? prettyKey(key)), commodity: facilityCarType(f) ?? '?', flow: f.kind === 'passenger' ? 'passengers on and off' : f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives', green: f.outboundBox.map(carLabel), greenCap: f.capacity.outbound, // Empty on a Passenger Facility, so a renderer that loops it draws nothing without needing a // guard of its own — which is the whole point of the field being nullable in the engine. maw: (f.menAtWork ?? []).map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)), red: f.inboundBox.map(carLabel), redCap: f.capacity.inbound, // Marked when this district made the load: the spotted car is exactly where a player is looking // when they ask why the Laborer will not unload it. track: f.industryTrack.cars.map((c) => carLabel(c, viewerSeat)), laborers: `${laborersLeft(f)}/${f.laborers}`, porters: `${portersLeft(f)}/${f.porters}`, canFinish: canFinishHere(f), jammed: !!f.menAtWork?.some((l) => l !== null) && !canFinishHere(f), allowsOut: f.allows.outbound, allowsIn: f.allows.inbound, base: baseOf(card, officeName), suppressed: suppressedGrants(card.modifiers ?? [], f), modifiers: (card.modifiers ?? []).map((m) => MODIFIER_NAMES[m] ?? prettyKey(m)), }; } /** * The half of each Modifier beside this facility that its printed flow discards. * * Mirrors `usableGrant` in the engine — the engine decides, this only reports. A Grocer's Warehouse * is `flow: 'inbound'`, so an Ice House's "+1 out" lands nowhere and the panel would otherwise show * a Modifier that visibly did half of what its card says. */ function suppressedGrants(modifiers: string[], f: Facility): string[] { const out: string[] = []; for (const key of modifiers) { const m = MODIFIER_PROFILES.find((p) => p.kind === key); if (!m) continue; if (m.addOut > 0 && !f.allows.outbound) { out.push(`${m.name}: +${m.addOut} outbound has no effect here — this facility only receives`); } if (m.addIn > 0 && !f.allows.inbound) { out.push(`${m.name}: +${m.addIn} inbound has no effect here — this facility only ships`); } } return out; } /** * What the card itself prints, before any Modifier beside it. * * Read from the catalogue rather than remembered on the Facility, so it cannot drift from the card * the player is holding. A passenger facility takes its numbers from the Office tier instead. */ function baseOf( card: { geometry: { kind: string; facility?: string } }, officeName: string, ): { out: number; in: number; laborers: number; porters: number } { const g = card.geometry; if (g.kind === 'facility' && g.facility) { const p = industryProfile(g.facility as never); return { out: p.baseOut, in: p.baseIn, laborers: p.baseLoaders, porters: 0 }; } /** * A PASSENGER FACILITY TAKES ITS NUMBERS FROM THE OFFICE TIER. * * The comment above this function has said so for a long time and the code returned zeros, so the * panel worked out its "+N from a Modifier" against a base of nothing: a plain Depot with no * Modifier anywhere near it displayed `out 1 +1`, crediting a card that had never been played. * The tier is the printed number here, exactly as the industry card is for an industry. */ if (g.kind === 'office') { const tier = OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'; const p = officeProfile(tier); return { out: p.passengerOut, in: p.passengerIn, laborers: 0, porters: p.porters }; } return { out: 0, in: 0, laborers: 0, porters: 0 }; } /** The train standing on a given grid square, drawn as it is seated in the Crew Tray. */ /** * Where the train standing on this card sits among the cars standing on it, clamped to the row. * * 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. */ /** * 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' || t.position.seat !== viewerSeat) continue; if (`${t.position.coord.row},${t.position.coord.col}` !== key) continue; out.push({ trayId: id, label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`, // A coach filled at THIS Office reads "loaded coach (loaded here)" — those passengers may not // alight in the district that boarded them, and the tray is where a player looks for that. cars: t.consist.map((c) => carLabel(c, viewerSeat)), 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), }); } return out; } /** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */ function canFinishHere(f: Facility): boolean { const pending = f.menAtWork?.find((l) => l !== null) ?? f.outboundBox[0]; if (!pending) return false; return f.industryTrack.cars.some((c) => !c.loaded && c.type === pending.type); } /** * Summarise the choice: what was taken, why, and what was passed over. * * Options are grouped by kind because a switching turn can offer 40 destinations, and a list that * long hides the shape of the decision rather than showing it. */ export function describeDecision( s: GameState, actor: number, chosen: Intent, options: Intent[], why: string, ): Decision { const groups = new Map(); for (const o of options) { if (o === chosen) continue; const list = groups.get(o.type) ?? []; list.push(o); groups.set(o.type, list); } const rejected = [...groups.entries()] .map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, kind, list) })) .sort((a, b) => b.count - a.count); return { actor, chose: describeIntent(s, chosen), why, rejected, totalOptions: options.length }; } /** A short, concrete example of what a group of rejected options would have done. */ function sampleDetail(s: GameState, kind: string, list: Intent[]): string { // Deduplicate by DESCRIPTION. Orientation variants and repeated copies of a card describe // identically, so the raw list reads "play Overpass at (0,0)" three times over and hides the // actual range of choices — the opposite of what this panel is for. const seen = new Set(); for (const i of list) seen.add(describeIntent(s, i)); const unique = [...seen]; const shown = unique.slice(0, 4); const more = unique.length - shown.length; return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : ''); } /** " onto Train 8", or nothing at all when the intent names no train (an old save, or one train). */ function onto(s: GameState, trayId: string | undefined, joiner: string): string { return trayId === undefined ? '' : `${joiner}${trainName(s, trayId)}`; } /** One readable line for a single intent. */ export function describeIntent(s: GameState, i: Intent): string { // X,Y — east/west then north/south, not the internal row/col storage order. const at = (c: { row: number; col: number }): string => `(${c.col},${c.row})`; // An intent belongs to whoever is acting, so it is described against THEIR district. // The acting PLAYER, not a seat — `describeIntent` describes an intent against the district of // whoever is making it. Named `seat` once, and then used as an `officeAreas` key, which is the // exact confusion the seat/player split exists to stop. const actor: PlayerIndex = s.clock.currentActor ?? 0; switch (i.type) { case 'localOps.choose': // The most consequential decision of the Stage, and it was labelled "choose switch". Say what // each option actually spends and buys. return i.option === 'switch' ? 'SWITCH — six Moves to shunt cars: spot empties at industries, collect loads' : i.option === 'draw' ? 'DRAW — take a card and play one, and you may lay a piece of track' : 'FREIGHT AGENT — one car moved to or from a facility, or clear a jam'; case 'card.play': { /** * NAME THE ROTATION, or the choice disappears. * * The action list drops duplicate labels, and this said only "play X at (0, 1)" — so a * turnout's two orientations produced one identical label each and the second was silently * discarded before the menu ever saw it. The rotation is the entire decision for a turnout or * a curve, and it could not be made. */ const kind = s.cards.get(i.cardId)?.kind; const turn = i.placement && kind?.kind === 'track' ? variantLabel(kind.geometry, i.variant, kind.hand) : ''; /** * "Play a turnout at (0,1)" and "upgrade the straight at (0,1) to a turnout" are different * moves — the second lifts a card already down — and calling both "play" hid the fact that the * square was not empty. The Limits sign is excluded: laying track there is ordinary growth. */ const over = i.placement ? areaOf(s, actor).grid.get(`${i.placement.row},${i.placement.col}`) : undefined; const upgrade = over?.geometry.kind === 'track'; return ( `${upgrade ? 'upgrade to' : 'play'} ${cardName(s, i.cardId)}` + `${i.placement ? ` at ${at(i.placement)}` : ''}${turn}` ); } case 'card.discard': { /** * NAME THE DEPARTMENT, and what the card would land on. * * All three discards described identically as "discard X", and the action list drops * duplicate labels — so the three choices collapsed into one button and the player could not * pick a Department at all. The choice IS the strategy: onto an empty-ish pile the card is an * offer a rival may take, and on top of a card a rival wants it puts that card out of reach. */ const pile = s.decks.departments[i.toSlot] ?? []; const top = pile[pile.length - 1]; const onto = top ? `, burying ${cardName(s, top)}` : ' (empty)'; return `discard ${cardName(s, i.cardId)} onto Department ${i.toSlot + 1}${onto}`; } case 'switch.move': { /** * SAY WHAT THE MOVE WILL PICK UP. * * Coupling is mandatory (§A.4): run over a card with cars standing on it and they join the * train, whether or not you wanted them. The button said "move to (0, -2)" and the only * account of the coupling was a line in the history panel — which is how a playtester ended up * reporting that "cars magically appeared on my train". * * Taken from the engine's own destination list, so the count on the button is the count that * will actually couple. */ const tray = s.trays.get(i.trayId); const here = tray?.position.at === 'grid' ? tray.position.coord : null; let picks = ''; let routeNote = ''; if (here) { const dests = destinationsFor(s, tray!.position.at === 'grid' ? playerAtSeat(s, tray!.position.seat) : 0, i.trayId, here, i.reverse); const dest = selectDestination(dests, i.to, i.via); // Two routes to the same square (docs/plans/switching-paths.md) would otherwise print the // identical button twice — "move to (0,0)" and "move to (0,0)" — and the action list drops // duplicate labels, silently discarding the second choice. `via` is the one thing that // differs in the DATA, so it is the one thing safe to print without guessing at scenery // this function has no other reason to know the name of. const atSameSquare = dests.filter((d) => d.coord.row === i.to.row && d.coord.col === i.to.col); if (dest && i.via && atSameSquare.length > 1) { routeNote = ` via ${at(i.via)}`; } if (dest && dest.couples.length > 0) { /** * NAME THE CARS THE CREW SET OUT HERE SEPARATELY. They are at the front of `couples` — the * walk seeds itself with the cut at the end the train pulls out through — and a button * reading "couples 2 cars" over cars the player put down thirty seconds ago is exactly the * surprise this label exists to prevent, running the other way. */ const own = ownCutFor(s, playerAtSeat(s, tray!.position.at === 'grid' ? tray!.position.seat : 0), i.trayId, i.reverse).length; const end = i.reverse ? ' (behind)' : ' (onto the nose)'; picks = own > 0 ? ` — takes your own ${carsLabel(dest.couples.slice(0, own))} back off this card` + (dest.couples.length > own ? `, then couples ${carsLabel(dest.couples.slice(own))} on the way` : '') + end : ` — couples ${carsLabel(dest.couples)} on the way${end}`; } } return `move to ${at(i.to)}${routeNote}${i.reverse ? ' (reverse)' : ''}${picks}`; } case 'switch.dropCars': { /** * NAME THE CARS AND THE END THEY COME OFF. * * This said "drop 1 car(s)", which is two failures at once. It never said WHICH car, so a * player who knew the caboose was on the back still had to guess; and it read identically for * a nose drop and a tail drop, so — the action list dropping duplicate labels — setting out * from the front of the train was silently discarded and could not be chosen at all. */ const tray = s.trays.get(i.trayId); if (!tray) return `drop ${i.count} car(s)`; const cut = i.fromNose ? tray.consist.slice(0, i.count) : tray.consist.slice(tray.consist.length - i.count); const end = i.fromNose ? 'off the front' : 'off the back'; return `set out ${carsLabel(cut)} ${end}`; } case 'switch.sortConsist': return `re-order consist [${i.order.join(',')}]`; case 'freightAgent.stockOutbound': { /** * "stock a coach at (0, 0)" reads as putting a CAR on the track, and was reported as exactly * that confusion: no train at the Depot, so how is a coach being stocked there? It is not a * car on the track — it is a load taken from the Division Yard into the green Loading box, * waiting for a train that can carry it. For a passenger facility that load is passengers on * the platform. */ const f = areaOf(s, actor).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null; const where = f?.kind === 'passenger' ? 'onto the platform' : 'into the green Loading box'; return i.carType === 'coach' && f?.kind === 'passenger' ? `bring passengers ${where} at ${at(i.at)} — they wait there for a train with an empty coach` : `bring a ${i.carType} load ${where} at ${at(i.at)} — it waits there for a car to be spotted`; } case 'freightAgent.unjam': return `unjam ${i.from} at ${at(i.at)}`; /** * SAY THAT IT PAYS NOTHING, because the obvious guess is that it does. * * Reported from play: "it wasn't obvious if that was a mechanical thing or if that's the actual * revenue generation — I believe that's actually where you get the revenue, and that completes * unloading the car." It is the first: the Revenue for an inbound load was already paid, one * step earlier, when the Laborer walked it off the car into the red box (`unloadCompleted` pays * `freightPerLoad`). Clearing the box banks nothing — it empties the one slot an inbound load * can finish in, so the NEXT car can be unloaded, and costs the whole Freight Agent action for * the Stage. * * Verified against the engine rather than read off the rules: `freightAgent.clearInbound` emits * `inboundCleared` alone, with no `revenueChanged` beside it. */ case 'freightAgent.clearInbound': { const f = areaOf(s, actor).grid.get(`${i.at.row},${i.at.col}`)?.facility ?? null; const car = f?.inboundBox[i.index]; const full = f ? f.inboundBox.length >= f.capacity.inbound : false; // The red box serves both halves of §9: an inbound freight load that has come off its car, // and a coach whose passengers have detrained. Both were paid for a step earlier, and both // sit in the box until the Freight Agent moves them on. const paid = f?.kind === 'passenger' ? 'the Revenue was paid when the passengers detrained' : 'the Revenue was paid when the load reached the box'; const frees = f?.kind === 'passenger' ? 'more passengers can detrain here' : 'another car can be unloaded here'; return ( `send the ${car ? carLabel(car) : 'car'} in the red Inbound box at ${at(i.at)} to the Classification Yard` + ` — pays nothing (${paid}); it frees${full ? ' the last' : ' a'} slot so ${frees}` ); } case 'laborer.startLoad': return `start a load at ${at(i.at)}`; case 'laborer.advanceLoad': return `advance load in box ${i.box} at ${at(i.at)}`; case 'laborer.beginUnload': return `begin unloading car ${i.carIndex} at ${at(i.at)}`; /** * NAME THE TRAIN. The action list drops duplicate labels within a crew, and with two trains * standing at one station "board passengers at (0,0)" describes both — which is half of why the * v0.4.9d playtest found that picking a train changed nothing. The intent now carries the tray; * the label has to say so or the second button is thrown away before the menu sees it. */ case 'porter.board': return `board passengers at ${at(i.at)}${onto(s, i.trayId, ' onto ')}`; case 'porter.detrain': return `detrain passengers at ${at(i.at)}${onto(s, i.trayId, ' from ')}`; case 'newTrain.startExtra': { const runs = i.trainNumber % 2 === 0 ? 'east' : 'west'; if (i.atSeat === null) { const end = i.trainNumber % 2 === 0 ? 'Western' : 'Eastern'; return `start Extra X${i.trainNumber} at the ${end} Division Point — it runs ${runs}, so that is the end it starts from`; } const tier = officeProfile(areaAtSeat(s, i.atSeat).tier).name; return `start Extra X${i.trainNumber} at the ${tier} in seat ${i.atSeat} — a Control Point, so it may begin its ${runs}bound run there instead`; } case 'newTrain.placeCar': // carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put // "add loaded caboose" on a button. return `add ${carLabel({ type: i.carType, loaded: i.loaded })}`; case 'mainline.modify': { /** * NAME THE CARD, NOT ITS INDEX, AND SAY WHAT HAPPENS TO IT. * * This read "Realignment on Mainline card 3" — a raw node index, which tells a player nothing * about which stretch of the Division it means, and no clue what the card would do to it. * Worse, the action list attaches a tooltip only when a label carries an em-dash, so this one * silently had none at all while the same card in hand did. */ const node = s.division.nodes[i.node]; const key = (s.cards.get(i.cardId)?.kind as { key?: string } | undefined)?.key; const shortWhere = node?.kind === 'mainline' ? `the ${mainlineProfile(node.card).name}` : `Mainline card ${i.node}`; const where = node?.kind === 'mainline' ? `the ${ordinal(mainlineIndex(s, i.node))} Mainline card west to east` : `node ${i.node}`; let effect = ''; if (key === 'realignment' && node?.kind === 'mainline') { const to = REALIGNMENTS.find((r) => r.from === node.card); effect = to ? `converts it to ${mainlineProfile(to.to).name}` : 'nothing here to convert'; } else if (key === 'brakeman' || key === 'airbrakes') { effect = 'one Stage off the descent for a train running downhill'; } else if (key === 'helpers') { effect = 'one Stage off the climb for a train running uphill'; } // Short head, full detail after the em-dash — `actionButton` puts the first on the button and // the second in the tooltip, so the list stays narrow without losing the explanation. return `${cardName(s, i.cardId)} on ${shortWhere} — ${where}${effect ? `; ${effect}` : ''}`; } case 'maneuver.redFlags': return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`; case 'maneuver.flyingSwitch': return `Flying Switch ${i.count} car(s) into ${at(i.to)}`; case 'mainline.clearance': { // The §8.1 ruling is the sharpest decision in the game and read "grant clearance" — no hint // that granting it risks a rear-ender, or that refusing merely costs time. const pending = s.clock.pendingDecision; const who = pending ? trainName(s, pending.train) : 'the train'; const ahead = pending ? trainName(s, pending.occupiedBy) : 'the train ahead'; // NOT "risks a collision, −5". A rear-end on a Mainline card is described by §10 and is what // ABS Signals exists to prevent, but no such collision is implemented — granting clearance is // currently free. Saying otherwise invents a consequence the engine will never deliver. // See implications.md §10 Q13. // THE TRAIN BEING RULED ON GOES ON THE BUTTON, not in the tooltip. `actionButton` splits a // label at the first em-dash and shows only the head, so "ALLOW — Train 7 follows…" left the // one thing the ruling is ABOUT — which train — behind a hover. Reported from play: the // Superintendent could not tell which train he was clearing without pointing at the button. return i.allow ? `ALLOW ${who} to follow ${ahead} — onto the same Mainline card, closing up behind it` : `HOLD ${who} — it waits where it is, losing the Stage but keeping the line clear`; } case 'draw.fromDepartment': { // Naming the card is the whole point of a FACE-UP pile: "Department 2" tells a player nothing, // and the choice between a visible card and a blind draw is unmakeable without it. const pile = s.decks.departments[i.slot] ?? []; const id = pile[pile.length - 1]; if (!id) return `Department ${i.slot + 1} (empty)`; const under = pile.length - 1; const buried = under === 0 ? '' : `, ${under} buried beneath it`; return `take ${cardName(s, id)} from Department ${i.slot + 1}${buried}`; } case 'draw.fromHomeOffice': return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`; case 'draw.end': return 'End Local Operations'; case 'switch.end': return 'End Local Operations'; case 'freightAgent.end': return 'End Local Operations — leave the Freight Agent idle'; case 'loadUnload.end': return 'End my Cargo phase'; case 'newTrain.passCar': return 'add no more cars to this train'; case 'newTrain.secondSection': return `run a Second Section behind Train ${i.trainNumber}`; case 'redFlag.play': return 'play your red flag'; case 'maneuver.redFlags': return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`; default: { // Every Intent now has a sentence, so `i` narrows to never here. Keeping the assignment makes // that a COMPILE error the day someone adds an intent without describing it — the playable UI // labels its buttons from this function, so a missing case ships as a button reading // "maneuver.poling". const unhandled: never = i; return String((unhandled as { type: string }).type); } } } /** * Build the view-model for a state. Shared with the playable web app so the live game and the * replay cannot drift into two different pictures of the same board. */ /** * The board as ONE SEAT sees it. * * `viewer` decides whose district, whose hand and whose facilities the Frame carries — everything * else (the Division, the timetable, the clock) is common to the table. It defaults to seat 0, which * is what solitaire and every replay want, so existing callers are unaffected. * * This was hardcoded to 0 throughout. That was correct while there was one player and would have * been a quiet disaster with more: every seat would have been shown player 0's railroad, including * player 0's hand, which is the one thing the state model calls secret. */ export function snapshot( s: GameState, lines: { text: string; tone: string }[], where: { row: number; col: number } | null, whereFrom: { row: number; col: number } | null = null, decision: Decision | null = null, wasted = false, viewer: PlayerIndex = 0, ): Frame { const area = areaOf(s, viewer); const viewerSeat = seatOf(s, viewer); const cells: CellView[] = []; const facilities: FacilityView[] = []; for (const [key, card] of area.grid) { const [row, col] = key.split(',').map(Number); const g = card.geometry; const kind = g.kind; let label: string; if (g.kind === 'office') label = officeProfile(area.tier).name; else if (g.kind === 'limits') label = 'Limits'; // Fall back to prettyKey, never the raw key. The lookup tables exist for names prettyKey cannot // guess ("Grocer's Warehouse"), not as the only route to a readable label — leaving the raw key // as the fallback put `refinery` and `viscosityBreakers` on the board. else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? prettyKey(g.facility); else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? prettyKey(g.modifier); else if (g.kind === 'spaceUse') label = prettyKey(g.key); else label = geometryLabel(g.geometry); const fv = facilityView(card as never, officeProfile(area.tier).name, viewerSeat); if (fv) facilities.push(fv); cells.push({ row: row!, col: col!, kind, label, running: row === area.runningRow, what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow), links: connectionsFor(card).map(([a, b]) => `${a}${b}`), enhancements: card.enhancements.map(prettyKey), enhancementsWhat: card.enhancements.map((k) => enhancementText(k) ?? prettyKey(k)), trains: trainsOnCard(s, viewerSeat, key), adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null, cars: carsOn(card).map((c) => carLabel(c, viewerSeat)), standingWest: card.standingWest, facility: fv, }); } const division: DivisionView[] = s.division.nodes.map((n) => { if (n.kind === 'divisionPoint') { return { kind: 'dp', label: n.side === 'west' ? 'West DP' : 'East DP', trains: [n.holding.map((id) => trainChip(s, id))], capacity: null, modifiers: [], gradeUp: null, }; } if (n.kind === 'mainline') { // Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it // with how long each still has to run. const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card; const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade'; /** * WHERE ON THE CARD, from what the crossing already cost. * * §2.1 divides a Mainline card into two regions and §8.2 moves a train one region per Stage. * The engine crosses in `crossingStages` Stages instead, which varies by card speed, train * speed, passengers and modifiers — so the printed model is recovered by treating the entry * point as the thing that varies, exactly as the cards do: * * entry = REGIONS - stagesTotal position = entry + elapsed * * A 60 card is one Stage, so the train enters at the second region and is gone — which is * what "Start positions further along the card" means on the printed art. A 30 card is two * Stages, giving one region per Stage, which is §8.2 exactly. A slow train needing three * Stages cannot fit three steps into two regions, so it holds in the first for a Stage: the * card's distance is fixed and the train is simply slow across it. */ const place = (t: { stagesRemaining: number; stagesTotal: number }): number => { // `entry` may be NEGATIVE — a slow train needing three Stages cannot fit three steps into // two regions, so it notionally starts before the card and spends the extra Stage getting // to the first region. Clamping only the final position keeps that Stage at the START, // where being slow shows; clamping `entry` first would have parked it at the exit instead. const entry = REGIONS_PER_MAINLINE_CARD - t.stagesTotal; const elapsed = t.stagesTotal - t.stagesRemaining; return Math.min(REGIONS_PER_MAINLINE_CARD - 1, Math.max(0, entry + elapsed)); }; return { kind: 'ml', label: name, regions: REGIONS_PER_MAINLINE_CARD, trains: [n.transits.map((t) => { const chip = trainChip(s, t.tray); return { ...chip, /** * THE NUMBER COMES OFF THE CHIP. * * It read "TX14 (2)" and was taken for the car count — twice, by the same player — so * it was named "· 2⧗", Stages left to cross. Named, it was then correctly read as * redundant: the card already draws WHERE the train is, and how many Stages it still * needs is a detail for the tooltip. The chip draws the train instead. */ stagesLeft: t.stagesRemaining, region: place(t), direction: t.direction, }; })], capacity: MAINLINE_PROFILES.find((m) => m.kind === n.card)?.trainsMayPass ? 2 : 1, modifiers: [ ...(n.modifiers ?? []).map(prettyKey), ...(n.absSignals ? ['ABS Signals'] : []), ], gradeUp: isGrade ? (n.gradeUp ?? 'east') : null, what: mainlineDescription(n.card, n.gradeUp ?? 'east'), }; } const oa = areaAtSeat(s, n.seat); // Where every crew in this district actually is: on a Running Track card, or below it. const onRunning = new Map(); const below: TrainChip[] = []; for (const [id, tray] of s.trays) { const pos = tray.position; if (pos.at !== 'grid' || pos.seat !== n.seat) continue; const c = trainChip(s, id); if (pos.coord.row === oa.runningRow) { const k = `${pos.coord.row},${pos.coord.col}`; onRunning.set(k, [...(onRunning.get(k) ?? []), c]); } else { below.push(c); } } // Limits to Limits, west to east. §2.1 — the Running Track runs BETWEEN the Limits, so the // signs are the ends of the through route rather than obstacles on it. const running: RunningCardView[] = []; for (let col = oa.limitsWest.col; col <= oa.limitsEast.col; col++) { const card = oa.grid.get(`${oa.runningRow},${col}`); if (!card) continue; const g = card.geometry; let label: string; if (g.kind === 'office') label = officeProfile(oa.tier).name; else if (g.kind === 'limits') label = 'Limits'; else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? prettyKey(g.facility); else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? prettyKey(g.modifier); else if (g.kind === 'spaceUse') label = prettyKey(g.key); else label = geometryLabel(g.geometry); running.push({ row: oa.runningRow, col, kind: g.kind, label, links: connectionsFor(card).map(([a, b]) => `${a}${b}`), trains: onRunning.get(`${oa.runningRow},${col}`) ?? [], }); } return { kind: 'office', label: officeProfile(oa.tier).name, trains: [oa.adOccupancy.map((id) => trainChip(s, id))], capacity: officeProfile(oa.tier).adTracks, modifiers: [], gradeUp: null, seat: n.seat, running, switching: below, }; }); return { day: s.clock.day, stage: s.clock.stage, clock: clockTime(s.clock.stage), phase: phaseLabel(s.clock.phase), phaseKey: s.clock.phase, actor: s.clock.currentActor, superintendent: s.clock.superintendent, revenue: s.players[viewer]?.revenue ?? 0, lines, where, whereFrom, division, cells, facilities, /** * NEWEST FIRST, matching the play page (`actionMenu`). * * The engine pushes a drawn card onto the END of the hand, which put the card just turned over * at the far end of a wrapping row. Reversed here rather than in the engine so the bot's hand * iteration — and every revenue measurement taken with it — is left alone. * * Both lines must reverse together or the descriptions come apart from the names. */ hand: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardName(s, id)), handWhat: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => cardDescription(s, id)), deck: s.decks.homeOffice.length, departments: s.decks.departments.map((pile) => { const top = pile[pile.length - 1]; return top ? cardName(s, top) : '—'; }), departmentsWhat: s.decks.departments.map((pile) => { const top = pile[pile.length - 1]; return top ? cardDescription(s, top) : ''; }), departmentDepth: s.decks.departments.map((pile) => pile.length), salvage: { top: s.decks.salvageYard.length ? cardName(s, s.decks.salvageYard[s.decks.salvageYard.length - 1]!) : '—', depth: s.decks.salvageYard.length, }, yards: { division: countStock(s.yards.divisionYard), classification: countStock(s.yards.classificationYard), divisionTotal: s.yards.divisionYard.length, classificationTotal: s.yards.classificationYard.length, }, timetable: [...s.timetable], timetableWhat: s.timetable.map((n) => (n === null ? null : trainRules({ trainNumber: n, trainIsExtra: false }))), decision, wasted, option: turnOf(s, viewer).option, houseRules: houseRules(s.config), days: s.config.days, minCombinedRevenue: s.config.minCombinedRevenue, maxCollisionsPerDay: s.config.maxCollisionsPerDay, maxCollisionsTotal: s.config.maxCollisionsTotal, collisionsToday: s.collisionsToday, collisionsTotal: s.collisionsTotal, status: s.status, outcome: s.outcome, players: s.players.map((p) => ({ index: p.index, seat: seatOf(s, p.index), name: p.name, revenue: p.revenue, hand: (s.decks.hands.get(p.index) ?? []).length, })), viewer, viewerSeat, openingRolls: { division: [...s.openingRolls.division], superintendent: [...s.openingRolls.superintendent], }, handCount: (s.decks.hands.get(viewer) ?? []).length, overHandLimit: (s.decks.hands.get(viewer) ?? []).length > (s.decks.redFlags.get(viewer) ? HAND_LIMIT + 1 : HAND_LIMIT), objective: objectiveOf(s, viewer), runningRow: area.runningRow, limits: { west: area.limitsWest.col, east: area.limitsEast.col }, movesLeft: s.clock.phase === 'localOps' && turnOf(s, viewer).option === 'switch' ? turnOf(s, viewer).movesRemaining : null, moves: switchingMoves(s, viewer), blocked: impediments(s, viewer), trains: [...s.trays.values()].map((t) => ({ label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`, where: t.position.at === 'divisionPoint' ? `${t.position.side} Division Point` : t.position.at === 'mainline' ? `Mainline card ${t.position.index}` : `Office Area (${t.position.coord.col},${t.position.coord.row})`, })), }; } /** A card id turned into something a person can read. */ export function cardName(s: GameState, id: string): string { const k = s.cards.get(id)?.kind; if (!k) return 'a card'; switch (k.kind) { case 'timetabledTrain': return `Train ${k.number}`; case 'extraTrain': return `Extra X${k.number}`; case 'office': return `${k.tier.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())} upgrade`; // Fall back to prettyKey, never to the raw key: an unnamed card showed as "rotaryDumps" on a // button a player is meant to read. The lookup tables are for names prettyKey cannot guess // (Grocer's Warehouse), not the only source of a readable label. case 'freightFacility': return FACILITY_NAMES[k.facility] ?? prettyKey(k.facility); case 'modifier': return MODIFIER_NAMES[k.modifier] ?? prettyKey(k.modifier); case 'track': // The hand is on the card face and decides which diagonal its 45° leg lies on, so it belongs // in the name: "curve" alone does not tell you what it can be joined to. return `${k.hand === 'none' ? '' : `${k.hand}-hand `}${geometryLabel(k.geometry)}`; case 'spaceUse': case 'enhancement': case 'mainlineModifier': case 'maneuver': case 'action': return prettyKey(k.key); } } /** * What a card actually DOES, in one line. * * The hand showed names only, so "Steam Turbines" or "Facing Point Locks" told a player nothing * about the effect — the difference between playing the game and clicking hopefully. Every fact * here already existed in the content tables; none of it was reaching the screen. */ export function cardDescription(s: GameState, id: string): string { const k = s.cards.get(id)?.kind; if (!k) return ''; switch (k.kind) { case 'timetabledTrain': case 'extraTrain': { const t = trainProfile(k.number, k.kind === 'extraTrain'); if (!t) return ''; const parts: string[] = []; if (t.consist.freight > 0) { const types = t.consist.freightTypes; parts.push(`${t.consist.freight} freight${types ? ` (${types.join('/')})` : ''}`); } if (t.consist.coach > 0) parts.push(`${t.consist.coach} coach`); if (t.consist.caboose > 0) parts.push('caboose'); const extra = k.kind === 'extraTrain' ? 'runs ONCE, unscheduled' : 'runs every Day once scheduled'; // Several Extras leave the direction to the player, so "playerChoicebound" is not a word. const dir = t.direction === 'playerChoice' ? 'either direction' : `${t.direction}bound`; const rule = t.rules.note ? ` · ${t.rules.note}` : ''; return `${t.name} · ${t.speed}, ${dir} · ${parts.join(' + ') || 'no cars'} · ${extra}${rule}`; } case 'office': { const p = officeProfile(k.tier); // Upgrades are strictly sequential (Gap 3b), so a Station card is dead weight until the // Office is a Depot. Without saying so, the card looks playable and simply never is. const needs = OFFICE_ORDER[OFFICE_ORDER.indexOf(k.tier) - 1]; const prereq = needs ? ` · requires the Office to be a ${prettyKey(needs)} first` : ''; return ( `upgrade the Office: ${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'}, ` + `${p.porters} porter${p.porters === 1 ? '' : 's'}, ` + `${p.passengerOut} passenger out / ${p.passengerIn} in` + (p.isControlPoint ? ' · a Control Point' : '') + prereq ); } case 'freightFacility': { const f = industryProfile(k.facility); const flow = f.flow === 'both' ? 'ships out AND receives' : f.flow === 'outbound' ? 'ships out' : 'receives'; const lock = f.lockouts.length ? ` · cannot share a district with ${f.lockouts.map(facilityLabel).join(', ')}` : ''; return `${flow} ${f.carTypes.join('/')} · ${f.baseLoaders} laborer${f.baseLoaders === 1 ? '' : 's'}${lock}`; } case 'modifier': { const m = modifierProfile(k.modifier); const adds: string[] = []; if (m.addOut) adds.push(`+${m.addOut} out`); if (m.addIn) adds.push(`+${m.addIn} in`); if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`); if (m.addPorters) adds.push(`+${m.addPorters} porter`); /** * WARN BEFORE IT IS PLAYED, not only after. * * An industry's printed flow is absolute, so a Modifier granting capacity in the other * direction gives that host nothing — an Ice House lists both Packing Sheds and a Grocer's * Warehouse, and only the Packing Sheds can use its "+1 out". Which host you choose is the * whole decision, so it has to be answerable while the card is still in hand. Computed from * the two catalogues, so no facility need be on the board yet. */ const caveats = m.hosts .filter((h) => h !== 'office') .map((h) => ({ host: h, flow: industryProfile(h as never).flow })) .filter(({ flow }) => (m.addOut > 0 && flow === 'inbound') || (m.addIn > 0 && flow === 'outbound')) .map(({ host, flow }) => `${facilityLabel(host)} only ${flow === 'inbound' ? 'receives' : 'ships'}`); const warn = caveats.length ? ` · ${caveats.join(' and ')}, so the ${m.addOut > 0 ? 'outbound' : 'inbound'} slot does nothing there` : ''; return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}${warn}`; } case 'track': { // Track is the largest category in the deck, so a player holds it constantly — and what it // can be joined to is decided by the hand, which is not something the name alone conveys. const cost = k.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : ''; const stop = k.geometry === 'turnout' ? ' · a train may pass through but not stop on it' : ''; if (k.geometry === 'straight') { return `east-west through track · lay it anywhere the rail continues${cost}`; } const diagonal = k.hand === 'right' ? 'north–east / south–west' : 'north–west / south–east'; const ways = variantsFor(k.geometry, k.hand) .map((v) => (v.arc ? curvePhrase(v.arc) : v.turnout ? turnoutPhrase(v.turnout) : '')) .join(', or turned about, '); /** * A CURVE IS NOT A TURNOUT, and it used to be described as one. * * Both said "east-west track with a 45° leg", which is a turnout: a road straight across the * card plus a leg off it. A curve has ONE road and no choice to make — it comes in from an * east or west edge, runs along the centre line to the frog, and leaves at 45° through the * middle of a north or south edge. Nothing runs past it, which is exactly why it may not be * laid in the Running Track. */ const what = k.geometry === 'turnout' ? 'east-west track with a 45° leg through the middle of the north or south edge' : 'a single road: in from the east or west edge, then out at 45° through the middle of the north or south edge — no track runs past it'; return `${what} · lay it so it ${ways} · its 45° leg is on the ${diagonal} diagonal and only meets a card on the same one${stop}${cost}`; } default: { // The recovered categories carry their own prose — effect plus where it may be played. const card = SIMPLE_CARDS.find((c) => c.key === (k as { key: string }).key); if (!card) return ''; /** * An Enhancement also says whether its effect is wired up. Four of the ten are read by nothing * at all, and a card that describes a power it does not have is worse than one that says * nothing — the player cannot tell a misread from a bug. */ const rule = k.kind === 'enhancement' ? enhancementRule(card.key) : null; const note = rule?.effect === 'unbuilt' ? ' · NOT YET IMPLEMENTED — no effect in play' : rule?.effect === 'dormantSolo' ? ' · never fires in solitaire — it answers an opponent card the solo deck omits' : ''; return `${card.effect} · played on ${card.placement}${note}`; } } } /** * EVERYTHING THIS TRAIN'S CARD PRINTS, in one line. * * The name, its class, what its consist should be, and — the part that prompted this — whatever * special rule the card carries. Nine of the twelve rule flags are declared on the profiles and read * by nothing in the engine, so those are marked as not yet implemented rather than quietly listed: * telling a player a rule applies when it does not is worse than saying nothing. */ export function trainRules(t: { trainNumber: number | null; trainIsExtra: boolean; }): string { const p = trainProfile(t.trainNumber ?? 0, t.trainIsExtra); if (!p) return ''; const parts: string[] = [`${p.isExtra ? 'Extra X' : 'Train '}${p.number} “${p.name}” · ${p.speed}`]; const consist: string[] = []; if (p.consist.freight > 0) { consist.push(`${p.consist.freight} ${p.consist.freightTypes?.join('/') ?? 'freight'}${p.consist.emptiesOnly ? ' (empties only)' : ''}`); } if (p.consist.coach > 0) consist.push(`${p.consist.coach} coach${p.consist.coach > 1 ? 'es' : ''}`); if (p.consist.caboose > 0) consist.push(`${p.consist.caboose} caboose`); parts.push(`its card calls for ${consist.join(' + ') || 'no cars'}`); if (p.rules.note) parts.push(p.rules.note); /** * §7's operating rules, ALL of which the engine now enforces. * * These used to be listed under "NOT YET ENFORCED BY THE ENGINE", which was honest at the time and * is not any more — every one below is checked in `apply.ts` or `advance.ts`. Saying what a rule * DOES rather than that it exists, because the restriction is the whole character of the card: a * Military train that cannot be worked by Porters plays nothing like a Local. */ if (p.rules.noSwitching) { parts.push('NO SWITCHING — may not add or drop cars, but may still be moved clear of the mainline'); } if (p.rules.terminalsOnly) parts.push('TERMINALS ONLY — Porters may work it at a Terminal and nowhere else'); if (p.rules.coachStaysOnStationTrack) { // Said "may only be set out at the Office", which reads as a place you can do it. You cannot: // §A.4 refuses the Office square outright, so the coach can never be set out anywhere — which // is why the make-up order decides whether this train can switch at all. parts.push( 'THE COACH IS NEVER SET OUT — so keep it OFF the outer end of the train, or nothing can come ' + 'off at all. Add the coach before the freight car when making up.', ); } if (p.rules.oneFreightPerLocation) { parts.push('ONE FREIGHT CAR PER LOCATION — dropped or picked up, one each square per turn'); } if (p.rules.noPassengerWork) parts.push('NO PASSENGER WORK — Porters may not board or detrain it'); if (p.rules.dropOnly) parts.push('MAY DROP BUT NOT PICK UP — it cannot couple anything'); if (p.rules.pickUpEmptiesOnly) parts.push('EMPTIES ONLY — it may not couple a loaded car'); if (p.rules.stopThenExpedite) { parts.push('STOPS ONCE FOR SPEECHES, then runs expedited from its next Office onward'); } if (p.rules.expedite) { // It is released and switched exactly like any other train — the restriction is on where it may // be LEFT, not on when it leaves. parts.push( 'EXPEDITED — must be kept ready to highball. It works and switches normally, but if it is not ' + 'back on the Office square when the next Mainline Phase begins, that is a Station Master ' + 'fault and costs 1 Revenue.', ); } if (p.rules.stopEarnsPoint) parts.push('EARNS A POINT for one Stage spent standing still, once'); return parts.join(' · '); } /** The train riding a Crew Tray, for anything that has to talk about it. */ export function trainName(s: GameState, trayId: string): string { const tray = s.trays.get(trayId); if (!tray) return trayId; if (tray.trainNumber === null) return 'the local crew'; return `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`; } /** What a card on the board does, in one short line. */ function cellDescription(card: TrackCard, officeName: string, onRunning: boolean): string { const g = card.geometry; switch (g.kind) { case 'limits': /** * THE WHOLE RULE, not half of it. * * This said "lay track HERE to extend the Running Track", which is true and leaves out the * part a player has to know: the sign is the ONLY growth point on the main, it moves outward * with the card, and nothing anywhere on the board can be inserted between two cards already * down. Asked for directly after a playtest — "somewhere it should be clear that track can * only be added to expand outwards". */ return ( 'the edge of your control area. Lay track HERE and the sign moves one card further out — ' + 'this is the only way the Running Track grows, and nothing may be built beyond the sign. ' + 'A district only ever expands: no card can be inserted between two cards already down.' ); case 'office': { const p = officeProfile( (OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'), ); /** * READ THE OFFICE AS IT STANDS, not as its card was printed. * * This took the porter count and the passenger slots from the TIER PROFILE, so a Waiting Area, * Restaurant or Hotel standing beside the Office — each +1 porter and +1 passenger out — * changed the Office and left this line saying what a bare Depot has. Reported as an extra * porter with "no indication of it anywhere": the Modifier had worked and nothing said so. * * The facility record is the Office; the profile is only what it started as. */ const f = card.facility; const porters = f ? f.porters : p.porters; const out = f ? f.capacity.outbound : p.passengerOut; const inb = f ? f.capacity.inbound : p.passengerIn; const added = porters - p.porters + (out - p.passengerOut) + (inb - p.passengerIn); return ( `${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'} — trains stand here to be worked · ` + (p.isPassengerFacility ? `${porters} porter${porters === 1 ? '' : 's'}, passengers ${out} out / ${inb} in` + (added > 0 ? ` (the card prints ${p.porters}/${p.passengerOut}/${p.passengerIn}; the Modifiers beside it add the rest)` : '') : 'not a Passenger Facility — no porters, no passenger boxes') ); } case 'modifier': { const m = modifierProfile(g.modifier); const adds: string[] = []; if (m.addOut) adds.push(`+${m.addOut} outbound slot`); if (m.addIn) adds.push(`+${m.addIn} inbound slot`); if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`); if (m.addPorters) adds.push(`+${m.addPorters} porter`); return `${adds.join(', ') || 'no effect'} for the adjacent ${m.hosts.map(facilityLabel).join('/')}`; } case 'spaceUse': return SIMPLE_CARDS.find((c) => c.key === g.key)?.effect ?? 'takes up space'; case 'facility': { const f = card.facility; if (!f) return 'a facility'; if (f.kind === 'passenger') return 'passengers board and detrain here'; // Say where the work has actually got to — the squares on the card show it, this names it. const inWork = (f.menAtWork ?? []).findIndex((l) => l !== null); const progress = inWork >= 0 ? ` · a load is on ${['MEN', 'AT', 'WORK'][inWork]}, ${ inWork === (f.menAtWork?.length ?? 0) - 1 ? 'one more Laborer action and it goes onto a spotted car' : 'each Laborer action moves it one square right' }` : f.outboundBox.length > 0 ? ' · a load waits in the green box for a Laborer to start it' : ''; const p = industryProfile(g.facility as never); const flow = p.flow === 'both' ? 'ships out AND receives' : p.flow === 'outbound' ? 'ships out' : 'receives'; // An industry can no longer BE on the Running Track — the sheet puts every one of them on a // straight stub off it — so this reads as a leftover only if one is somehow there. const where = onRunning ? ' · ON THE RUNNING TRACK — industries belong on a stub; a car left standing here is hit by the next arrival' : ''; return `${flow} ${p.carTypes.join('/')} · spot a matching car on its track to work a load${progress}${where}`; } case 'track': switch (g.geometry) { case 'straight': return 'through track, east–west'; case 'curved': case 'sharpCurved': { const arc = g.arc ?? (g.hand === 'right' ? 'sw' : 'se'); const cost = g.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : ''; return `curve — ${curvePhrase(arc)}${cost}${slopePhrase(arc[0] as Port, arc[1] as Port)}`; } case 'turnout': { const t = g.turnout; if (!t) return 'turnout'; // §A.1 is the subtlety worth spelling out: the missing edge, not a one-way street. return ( `turnout — ${turnoutPhrase(t)} · a train coming the other way, from the ` + `${compass(t.through)} or the ${compass(t.diverge)}, may only leave by the ` + `${compass(t.stem)} — the two roads never join` + `${slopePhrase(t.stem as Port, t.diverge as Port)}` ); } } } } /** Which Mainline card this is, counting only the Mainline cards from the west end. */ function mainlineIndex(s: GameState, node: number): number { let n = 0; for (let k = 0; k <= node; k++) if (s.division.nodes[k]?.kind === 'mainline') n++; return n; } const ORDINALS = ['', 'first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seventh', 'eighth']; const ordinal = (n: number): string => ORDINALS[n] ?? `${n}th`; const compass = (p: string): string => ({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p; /** * WHAT A TURNOUT DOES, in the words a player would use at the table. * * "Right-hand turnout, stem east, through west, diverges north" is three pieces of jargon and a * compass reading, and none of it answers the only question being asked: if my train comes in from * over there, where can it go? §A.1's rule falls straight out of the same sentence — traffic from * the stem may take either road, and traffic arriving on either road may only leave by the stem, so * the two roads never join. */ function turnoutPhrase(t: TurnoutOrientation): string { return `allows traffic from the ${compass(t.stem)} to travel ${compass(t.through)} or turn to the ${compass(t.diverge)}`; } /** The same, for a curve: it has one road and no choice to make. */ function curvePhrase(arc: string): string { const [a, b] = [arc[0] as Port, arc[1] as Port]; const [side, leg] = a === 'n' || a === 's' ? [b, a] : [a, b]; return `carries traffic from the ${compass(side!)} round to the ${compass(leg!)}`; } /** * The matching rule, said out loud. Which diagonal a leg sits on decides what may sit above or * below it, and that is not something a player can read off the card's shape at a glance. */ function slopePhrase(a: Port, b: Port): string { const slope = slopeOfPair(a, b); if (!slope) return ''; const mate = slope === 'ne_sw' ? 'north–east / south–west' : 'north–west / south–east'; return ` · its 45° leg lies on the ${mate} diagonal, and only meets a card whose leg lies on the same one`; } /** An industry's name for the screen. `office` is the Passenger Facility, not an industry. */ function facilityLabel(key: string): string { return key === 'office' ? 'the Office' : (FACILITY_NAMES[key] ?? prettyKey(key)); } /** Every card category that carries a written effect rather than a profile. */ const SIMPLE_CARDS = [ ...ENHANCEMENT_CARDS, ...MAINLINE_MODIFIER_CARDS, ...MANEUVER_CARDS, ...SPACE_USE_CARDS, ...ACTION_CARDS, ]; /** * The goal, and whether the VIEWER's score is keeping up with the clock. * * `target` is `config.minCombinedRevenue` now (2026-08-20) — the floor below which everyone loses, * not a per-player win threshold; `days` and `daysLeft` come off `config.days`. Paced against the * VIEWER's own Revenue, same as before: exact for solitaire (the viewer IS the whole table), an * approximation for competitive/coop until Phase 2 gives the objective panel a combined-progress * view of its own. `0` means no floor is configured — nothing to pace against. */ function objectiveOf(s: GameState, viewer: PlayerIndex): Frame['objective'] { const { days, minCombinedRevenue: target } = s.config; const revenue = s.players[viewer]?.revenue ?? 0; const daysLeft = Math.max(0, days - s.clock.day + 1); const elapsed = days - daysLeft + 1; if (target <= 0) { const note = daysLeft === 0 ? 'the last Day is over' : `${revenue} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · no minimum this game`; return { target: 0, days, daysLeft, onPace: true, note }; } // Straight-line pace: by the end of Day N you want N/days of the target. const expected = (target * elapsed) / days; const onPace = revenue >= expected; const note = daysLeft === 0 ? 'the last Day is over' : `${revenue} of ${target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` + (onPace ? 'on pace' : `behind pace (about ${Math.ceil(expected)} by now)`); return { target, days, daysLeft, onPace, note }; } /** * Which way a piece will point, in words. * * "rotation 2" is not a choice anyone can make — for a curve or a turnout the orientation IS the * decision. Read from `variantsFor`, the same list the placement uses, so the label cannot describe * one rotation while the engine lays another. There are only two: a printed card turns 180° but * never flips, so `hand` and not the rotation decides which diagonal the 45° leg lies on. */ export function variantLabel( geometry: TrackGeometry, variant: number | undefined, hand: Hand = 'none', ): string { const v = variantsFor(geometry, hand)[variant ?? 0]; if (!v) return ''; if (v.turnout) return ` — ${turnoutPhrase(v.turnout)}`; if (v.arc) return ` — ${curvePhrase(v.arc)}`; return ' — straight through, east to west'; } /** * A track shape in words. One place, because it is written on the board, in the action buttons and * in the supply list — and `sharpCurved` was reaching the screen raw in two of the three. */ export function geometryLabel(geometry: string): string { switch (geometry) { case 'sharpCurved': return 'sharp curve'; case 'curved': return 'curve'; case 'turnout': return 'turnout'; case 'straight': return 'straight'; default: return prettyKey(geometry); } } /** camelCase key → readable name, for the card categories that carry only a key. */ function prettyKey(key: string): string { return key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase()); } const MODIFIER_NAMES: Record = { teamTrack: 'Team Track', loadingDock: 'Loading Dock', storageShed: 'Storage Shed', extraPlatform: 'Extra Platform', sectionGang: 'Section Gang', }; /** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */ /** Cars in a yard, grouped by type and split loaded / empty, in a stable order. */ function countStock( stock: readonly { type: string; loaded: boolean }[], ): { type: string; loaded: number; empty: number }[] { const order = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose']; const by = new Map(); for (const c of stock) { const row = by.get(c.type) ?? { type: c.type, loaded: 0, empty: 0 }; if (c.loaded) row.loaded += 1; else row.empty += 1; by.set(c.type, row); } return [...by.values()].sort((a, b) => order.indexOf(a.type) - order.indexOf(b.type)); } /** * The switching crew's reach, for the board to draw. * * Only while a switching turn is actually running and only while Moves remain: a highlight that * survives into the Cargo phase is an invitation to click something that is no longer offered. * * EVERY crew in the district, each with its own squares. It used to return the first one it found, * which is the same thing in solitaire's opening but not once a train is standing at the Office * while a local shunts: the board then drew one crew's reachable squares and the action list offered * both crews' moves, so half the highlights belonged to a train the player was not moving. */ function switchingMoves(s: GameState, player: PlayerIndex): Frame['moves'] { const turn = turnOf(s, player); if (s.clock.phase !== 'localOps' || turn.option !== 'switch') return []; if (turn.movesRemaining < 1) return []; const out: Frame['moves'] = []; for (const [id, tray] of s.trays) { if (tray.position.at !== 'grid' || tray.position.seat !== seatOf(s, player)) continue; // A no-switching train may still be moved clear of the mainline (§7) — only coupling, setting // out and sorting are refused, and `check` rejects those the same way it rejects a pick-up X13 // (dropOnly) is not allowed to make, so this row is not filtered any differently for either. const { to, blocked } = movesFor(s, player, id); out.push({ trayId: id, label: trainName(s, id), from: tray.position.coord, to, blocked }); } return out; } function trainChip(s: GameState, id: string): TrainChip { const t = s.trays.get(id); if (!t) return { label: id, consist: [], cars: [], engineAt: 0, facing: 'e', what: '' }; /** * The engine is drawn IN the consist, at the position it occupies. * * A Crew Tray is an engine plus its Rolling Stock, and the engine may be pulling, pushing, or in * the middle doing both — which is a thing a player has to be able to see, since it decides which * end cars couple onto (§A.3) and which way the train can shove. It was recorded as a boolean * that nothing read, so every consist was drawn as an anonymous row of cars. */ const cars = t.consist.map(carLabel); const at = Math.max(0, Math.min(cars.length, t.engineAt)); const seated = [...cars]; seated.splice(at, 0, 'ENG'); return { label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`, consist: seated, cars, engineAt: at, facing: railFacingOf(t), what: t.trainNumber === null ? 'A local crew — no timetable, no card, no special rules.' : trainRules(t), }; }