Files
station-master/src/sim/view.ts
T
Jesse.MarkowitzandClaude Opus 5 83a5450866 v0.6.1 — five of six playtest bugs: one button per train, and a load that has to go somewhere
Gameplay testing on 0.4.9d returned six reports. Five are fixed; the sixth could not be
reproduced and is written up in TODO.md with the two questions that would pin it down.

TWO TRAINS AT ONE PLATFORM ANSWERED TO ONE BUTTON. `porter.board` and `porter.detrain`
carried no tray, so there was one button per platform however many trains stood at it and
the reducer filled the first empty coach on the A/D tracks. `check` and the reducer were not
even asking the same question: `check` skipped a train whose card refuses passenger work and
the reducer did not. Both intents now carry an optional `trayId`, one function resolves the
train and the coach for check/execute/reduce alike, `legal.ts` offers one candidate per train,
and the label names it.

A LOAD COULD BE MADE AND BROKEN WITHOUT GOING ANYWHERE. A Freight House could unload the
boxcar it had just loaded; a platform could detrain the passengers it had just boarded. Full
Revenue at both ends for a movement that never happened. Jesse's rule: a load made anywhere in
an Office Area may not be broken anywhere in that Office Area, ever — it has to be carried to
another district. The load carries the seat that made it (`RollingStock.origin`), stripped by
`pooled` at every yard push. Measured at -0.60 +/- 0.10 Revenue a game (t = -6.1) over 400
paired deals: 78 worse, 3 better, 319 unchanged — free Revenue coming off the board, not a nerf.

THE GROCER'S WAREHOUSE SHIPPED AND THE REFINERY RECEIVED. Both were `flow: 'both'` on the
reading that "Freight House" was a collective term for exactly those two, and therefore what
§9.3 described. The engine has dealt a Freight House CARD since before v0.4.9, so §9.3 names
it and the argument goes. The card set agrees: all three Refinery modifiers grant +1 outbound.
Refinery outbound-only, Grocer's inbound-only, Freight House the one two-way industry — which
leaves exactly the one same-district pairing the rule above refuses.

NOT REPRODUCED: cars left behind when backing up over them. Five layouts tried, including cars
spotted at an industry; every one couples the lot. Three are pinned in `apply.test.ts`. One way
to create such cars was closed anyway — `flyingSwitch` wrote its cut past `carsOn`.

Both published replays that had gone dead were re-recorded; a rules change retires a save, and
`harness.test.ts` is what catches it.

The same change ships as v0.4.9e on the 0.4.9 line, branched from the v0.4.9d commit — the engine
files these fixes touch are identical across the two lines, so the patch applied cleanly both ways.

Also carries the two "Queued 2026-08-22, from playing on StartOS" TODO items that were staged
before this work started (Games in Progress readability, and getting back into a game after
losing a browser). They are notes, and items 9-12 below them are numbered against them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011nbvwWMef8CuEP6t5cgkTv
2026-08-21 23:54:24 -04:00

1870 lines
88 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, string> = {
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<string, Intent[]>();
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<string>();
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<string, TrainChip[]>();
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<string, string> = {
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<string, { type: string; loaded: number; empty: number }>();
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),
};
}