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
1870 lines
88 KiB
TypeScript
1870 lines
88 KiB
TypeScript
/**
|
||
* 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),
|
||
};
|
||
}
|
||
|