All four reported from a table on Day 1 of v0.8.0.16, and all the same shape. ABS SIGNALS COULD ONLY BE PLAYED ON ONE MAINLINE CARD, while its tooltip said "any Mainline card". The engine was never wrong: check accepts any node whose kind is mainline and legalActions filters by check, so all of them were legal. The failure was the LABEL — describeIntent named i.placement and never i.node, so every placement described itself as plain "play ABS Signals", and the action list drops duplicate labels. All but the lowest-index node were discarded before the menu saw them. This is the THIRD time that trap has fired and the file documents the other two three lines apart: a turnout's two rotations, and three Department discards. Same fix — name what distinguishes them. The card is also called what the card face calls it. prettyKey rendered absSignals as "Abs Signals" beside a tooltip saying ABS, an acronym no key-splitter can recover, so the authored names now win. Three of those names were transcribed in sentence case and were CORRECTED rather than adopted: the repository says "Yard Office" 36 times against "Yard office" twice. A lookup that imports its own source's typos is the drift it exists to prevent. NOTHING ON A MAINLINE CARD SHOWED WHAT WAS STANDING ON IT. Played, ABS left no mark and you found out by hovering — the same complaint the Heavy Grade wedge answered, and it matters more here because ABS decides whether a second train on that card is safe. It draws a signal mast with a lit lamp now; a signal is the literal object and needs no room for words, which is what lets it sit clear of a name as long as "Uncontrolled Siding" on a 152px cell. The Mainline modifiers draw as BRK, AIR and HLP. Realignment is deliberately not among them: reduce takes the `became` branch and changes node.card, so a realigned Trestle IS an Uncontrolled Siding afterwards. Asserted, so the absence reads as a finding. A FREIGHT AGENT TURN SAID A CAR MOVED WHEN NONE HAD. Three faults behind one line. It asserted an outcome, where §6.3 requires no action and the bot declines deliberately — unjamming a healthy box destroys a load that cost a whole action to stock. An idle Agent was then silent, which read as a dropped turn; a new freightAgentIdled event says so and why, reducing to nothing exactly like switchingEnded. And the work named a coordinate rather than the industry, though a `place` helper has existed for precisely that since the switching lines moved to it. "Loaded a loaded boxcar INTO the green Outbound box at the Freight House", with the direction in capitals because to-or-from was the question asked. THE LOG AND THE ACTION MENU SPELLED THE SAME SQUARE DIFFERENTLY. view.ts wrote (col,row) — X,Y, east/west then north/south — with a comment saying why; narrate.ts wrote the internal storage order with no comment at all. So the menu offered a move to "(1,-1)" and the log reported it at "(-1,1)", side by side. Pinned by a test that renders one square through BOTH describers and compares them to each other: a test written against either file alone would have passed. THE DOCUMENTATION IS REACHABLE FROM A RUNNING GAME, AND ALL OF IT IS PUBLISHED. v0.8.0.16 published the Quickstart and nothing it points at — its §8 links five documents by relative path and every one 404'd on the package, verified against the running container. The build publishes the full set, and the test reads the links OUT OF the guide rather than listing them. They are linked from the This Game card, where reference already lives, rather than the header that must not wrap; no mode awareness is needed, because solitaire and multiplayer are the same page on the same origin. THE REFERENCES DROPPED THE VERSION FROM THEIR NAMES. Four described v0.8.0.16 and had since the v0.8.0.15 audit; the v0.4.5 was the prototype edition they were first written against, kept only because 36 citations pointed at it — and it read as documentation five minor versions stale. They are quickstart.md, rules.md, home-deck.md, mainline-deck.md and components.md now, kept current with each release rather than published as editions. Two errors surfaced while checking them against this release, which is the argument for doing it: home-deck.md filed ABS Signals under Enhancements "played into your district" that "change what a square does" — it does neither, this release's bug written down — and mainline-deck.md, which lists everything playable onto a Mainline card, never mentioned it at all. 1010 fast tests and 35 sim tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MUizFYCMHRWhbWwXhp7WPR
2501 lines
120 KiB
TypeScript
2501 lines
120 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 { badlyMadeUp, isExpedited, regionOfTransit } from '../engine/advance.ts';
|
||
import {
|
||
areaAtSeat,
|
||
areaOf,
|
||
destinationsFor,
|
||
facilityCarType,
|
||
isBeingMadeUp,
|
||
laborersLeft,
|
||
movesFor,
|
||
ownCutFor,
|
||
keepReason,
|
||
portersLeft,
|
||
resolveExtraStart,
|
||
selectDestination,
|
||
} from '../engine/apply.ts';
|
||
import {
|
||
ACTION_CARDS,
|
||
ENHANCEMENT_CARDS,
|
||
MAINLINE_MODIFIER_CARDS,
|
||
MAINLINE_PROFILES,
|
||
MANEUVER_CARDS,
|
||
MODIFIER_PROFILES,
|
||
REALIGNMENTS,
|
||
OFFICE_ORDER,
|
||
SPACE_USE_CARDS,
|
||
STAGES_PER_SHIFT,
|
||
crewTrayCount,
|
||
enhancementRule,
|
||
enhancementText,
|
||
industryProfile,
|
||
mainlineProfile,
|
||
modifierProfile,
|
||
officeProfile,
|
||
trainProfile,
|
||
houseRules,
|
||
mainlineDescription,
|
||
} from '../engine/content.ts';
|
||
import type { Intent } from '../engine/intents.ts';
|
||
import type { Facility, GameConfig, GameState, OfficeArea, PlayerIndex, SeatIndex, TrackCard, TurnoutOrientation } from '../engine/state.ts';
|
||
import { actingPlayer, carsOn, overHandLimit, playerAtSeat, railFacingOf, seatOf, turnOf } from '../engine/state.ts';
|
||
import type { Direction, 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[];
|
||
/**
|
||
* Which of those enhancements is SPENT for today, in the same order (#101).
|
||
*
|
||
* Only ever true of a dispatch device — Telegraph, Telephone, Radio — which is "once a day". The
|
||
* reason and the Fedora caveat are already written into `enhancementsWhat`; this is the flag the
|
||
* board styles from, because `board-svg.ts` imports nothing and cannot work it out for itself.
|
||
*/
|
||
enhancementsSpent: boolean[];
|
||
/**
|
||
* 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;
|
||
/**
|
||
* HELD AT THE LIMITS BY AN INTERLOCKING, rather than standing on this square (#99).
|
||
*
|
||
* The engine keeps these in `OfficeArea.heldAtLimits` and deliberately does NOT move
|
||
* `tray.position` onto the grid — a held train is not on a square anything may switch it from.
|
||
* So the map has to draw it from the held list, and mark it, or it reads as an ordinary arrival
|
||
* the player could work.
|
||
*/
|
||
heldAtLimits?: true;
|
||
}[];
|
||
/**
|
||
* 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;
|
||
/**
|
||
* BEING MADE UP RIGHT NOW — §7's round, one car at a time, at a Division Point.
|
||
*
|
||
* The make-up panel names the train and the yard chips load it, and both are in the right-hand
|
||
* column; the train itself is drawn on the Division strip at the top left, looking exactly like
|
||
* every other chip on the map. So the two halves of the same activity never pointed at each other
|
||
* (Jesse, playtest 2026-09-16). Absent rather than false everywhere else, like `region` above.
|
||
*/
|
||
beingMadeUp?: true;
|
||
};
|
||
/**
|
||
* 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: a Red Flag standing at this Office's Limits, and which approach it guards
|
||
* (#94). `null` when none is out.
|
||
*
|
||
* PUBLIC STATE, and the reason it has to be here: the flag is a token set out ON the board that
|
||
* holds the next train arriving from that side until it is spent. It was announced once in the
|
||
* log and then drawn nowhere, so a train would stop short with its only explanation scrolled out
|
||
* of the panel.
|
||
*/
|
||
redFlag?: string | null;
|
||
/** 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;
|
||
/**
|
||
* Interchange only: trains standing in its yard, not out on the running line (state.ts).
|
||
*
|
||
* Separate from `trains` for the same reason `switching` is separate from an Office's A/D list —
|
||
* they are not occupying the thing whose capacity is being counted. An Extra made up here has to
|
||
* be VISIBLE, though, or the player who placed it has a train that exists nowhere on the map.
|
||
*/
|
||
yard?: TrainChip[];
|
||
/**
|
||
* 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[];
|
||
/**
|
||
* WHO THE GAME IS WAITING ON — the phase's actor, or the owner of a pending interruption when
|
||
* there is one. It carried `clock.currentActor` alone until 2026-08-30, which is null during the
|
||
* Mainline Phase, so a game stopped dead on a Superintendent's clearance ruling reported "waiting
|
||
* on nobody — the Division is running itself" while it waited on a named person to click
|
||
* (reported by Jesse). The engine had the answer the whole time in `actingPlayer`.
|
||
*/
|
||
actor: number | null;
|
||
/**
|
||
* WHAT that player is being asked, when the game is stopped on a question rather than a turn.
|
||
* Null whenever the phase is simply running. Naming the person is not enough on its own: three
|
||
* different interruptions can be waiting, and "waiting on Bob" with no more than that is a game
|
||
* that looks stuck to everyone except Bob.
|
||
*/
|
||
awaiting: { asks: string; train: string } | 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;
|
||
/**
|
||
* WHICH GAME THIS IS — the mode it is scored under and Appendix B's three switches.
|
||
*
|
||
* Added 2026-08-23 with the game types (`web/presets.ts`). Everything else needed to name a game
|
||
* "Co-op" or "Cutthroat" was already here; `mode` and `optionalRules` were the two missing pieces,
|
||
* so a remote client could see the dials but not what they added up to — and a Cutthroat game
|
||
* looked exactly like a Co-op one from the board.
|
||
*/
|
||
mode: GameConfig['mode'];
|
||
optionalRules: GameConfig['optionalRules'];
|
||
/**
|
||
* 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;
|
||
/** What the Day that just ended finished on — see `collisionsPrevDay` in `engine/state.ts`. */
|
||
collisionsPrevDay: number;
|
||
collisionsTotal: number;
|
||
status: GameState['status'];
|
||
outcome: GameState['outcome'];
|
||
/**
|
||
* §3.3, EXTENDED PLAY (Gitea#11). `days` above stays the ORIGINAL timetable — it is what the
|
||
* official result was decided at — so the Day the game now runs to is `days + extraDays`.
|
||
*/
|
||
extraDays: number;
|
||
/** Per PLAYER, while `status` is `awaitingExtension`. `null` is a seat that has not voted. */
|
||
extensionVotes: (boolean | null)[];
|
||
/** The official result, frozen when the original timetable ran out. Null until then. */
|
||
official: GameState['official'];
|
||
/**
|
||
* Gitea#16 — everything interesting that has happened, folded from the event stream.
|
||
*
|
||
* Aggregate counts only, which is why it can ride the Frame at all: `test/redaction.test.ts`
|
||
* proves a Frame carries no other seat's secrets, and a count of trains is nobody's secret. Being
|
||
* here rather than on a side channel is what gets the results screen the same numbers in
|
||
* multiplayer as in solitaire, from one implementation.
|
||
*/
|
||
tally: GameState['tally'];
|
||
/**
|
||
* 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).
|
||
*
|
||
* CARRIED AHEAD OF ITS CALLER, DELIBERATELY (#45). Nothing renders this today — the 2026-08-30
|
||
* dead-field audit found it read only by one test, and Jesse deferred the delete-or-document
|
||
* call. Documenting rather than deleting, because Gitea#20's common board keys every district by
|
||
* SEAT and resolves the player through `playerAtSeat` (§Employee Rotation moves players between
|
||
* districts), so a client that must pick its own district out of a seat-keyed board needs exactly
|
||
* this and cannot derive it from `viewer`. If step 2 ships without using it, delete it then —
|
||
* this note is the reason it survived one audit, not a permanent exemption from the next.
|
||
*/
|
||
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[];
|
||
/**
|
||
* Whether each hand card may be DISCARDED, in the same order.
|
||
*
|
||
* The player has to be told which cards those are, not merely find that a button is missing —
|
||
* that silence is the whole of the Gitea#2 complaint, where a blocked platform left the board with
|
||
* nothing to click and no reason. Named for the rule rather than for trains, since it answers the
|
||
* question the panel is asking.
|
||
*/
|
||
handDiscardable: boolean[];
|
||
/**
|
||
* WHY a card may not be discarded, in the same order; `null` where it may.
|
||
*
|
||
* Carried rather than written on the page because §6.2 now fails for two different reasons
|
||
* (Gitea#9): an Extra is never discardable, and a Timetabled train is not discardable only when
|
||
* the `discardTimetabled` house rule is off. A panel that hard-codes one sentence tells half the
|
||
* players the wrong thing, and a panel that reconstructs the rule is a second implementation of
|
||
* it. `keepReason` is the engine's own, so the card says the rule that actually refused.
|
||
*/
|
||
handKeepWhy: (string | null)[];
|
||
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;
|
||
/**
|
||
* A WHISTLE POST TAKES NOTHING AT ALL, AND SAYING "IT ONLY RECEIVES" WOULD BE A LIE.
|
||
*
|
||
* Jesse, playtest 2026-09-16: a Restaurant appeared to do nothing. It does nothing — a Whistle
|
||
* Post is not a Passenger Facility, so it allows neither direction and has 0 capacity each way;
|
||
* the engine's `usableGrant` discards the capacity while the porter is granted regardless, which
|
||
* leaves a porter with nothing to carry. Playing it there stays LEGAL on Jesse's call, so the
|
||
* card is not wasted — it starts working the moment the Office is upgraded — but the panel has
|
||
* to say so, or the player is left believing the card is broken.
|
||
*
|
||
* Only a Whistle Post can reach this: every freight flow allows at least one direction, and
|
||
* every Office above the first allows both.
|
||
*/
|
||
if (f.kind === 'passenger' && !f.allows.outbound && !f.allows.inbound) {
|
||
out.push(
|
||
`${m.name}: DORMANT — a Whistle Post works no passengers at all, so nothing this card ` +
|
||
`grants is in use yet. It all starts working when the Office is upgraded to a Depot.`,
|
||
);
|
||
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'] = [];
|
||
|
||
/**
|
||
* TRAINS HELD AT THE LIMITS — drawn here or drawn nowhere (#99).
|
||
*
|
||
* `arriveAtOffice` takes the tray out of the Mainline node's `transits` and, when an Interlocking
|
||
* saves it from Gap 2d's collision, pushes it onto `heldAtLimits` without giving it a grid
|
||
* position. The map draws mainline nodes from `transits` and squares from `position.at === 'grid'`
|
||
* — so between the two the train was drawn in NEITHER, and simply vanished off the board until an
|
||
* A/D track freed some Stages later.
|
||
*
|
||
* At WHICH Limits: the end it came in by. An eastbound train entered from the west, so it is held
|
||
* at `limitsWest`; a westbound one at `limitsEast`.
|
||
*/
|
||
const area = areaAtSeat(s, viewerSeat);
|
||
for (const id of area.heldAtLimits) {
|
||
const t = s.trays.get(id);
|
||
if (!t) continue;
|
||
const at = t.direction === 'east' ? area.limitsWest : area.limitsEast;
|
||
if (`${at.row},${at.col}` !== key) continue;
|
||
out.push({
|
||
trayId: id,
|
||
label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
|
||
cars: t.consist.map((c) => carLabel(c, viewerSeat)),
|
||
engineAt: Math.max(0, Math.min(t.consist.length, t.engineAt)),
|
||
facing: railFacingOf(t),
|
||
what:
|
||
'HELD AT THE LIMITS — the Interlocking stopped it on the Limit Track instead of letting it ' +
|
||
'collide with a full Office. It takes the first A/D track that frees, ahead of any train ' +
|
||
`arriving after it. ${trainRules(t)}`,
|
||
heldAtLimits: true,
|
||
});
|
||
}
|
||
|
||
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';
|
||
/**
|
||
* NAME THE MAINLINE CARD, for exactly the reason the rotation is named above — reported from
|
||
* a table on Day 1 Stage 1 of v0.8.0.16 and the THIRD time this trap has been sprung.
|
||
*
|
||
* ABS Signals is played on a Division NODE rather than a grid square, so `i.placement` is
|
||
* absent and every one of its placements described itself as plain "play ABS Signals". The
|
||
* action list drops duplicate labels, so all but the lowest-index Mainline card were discarded
|
||
* before the menu saw them: the tooltip promised "any Mainline card" and the board offered
|
||
* one. Naming the card is what makes the choice both legible and survivable.
|
||
*/
|
||
const onNode = i.node === undefined ? undefined : s.division.nodes[i.node];
|
||
const mainline =
|
||
onNode?.kind === 'mainline' ? ` on the ${mainlineProfile(onNode.card).name}, out on the Mainline` : '';
|
||
return (
|
||
`${upgrade ? 'upgrade to' : 'play'} ${cardName(s, i.cardId)}` +
|
||
`${i.placement ? ` at ${at(i.placement)}` : ''}${mainline}${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': {
|
||
/**
|
||
* THE TRAIN IT WOULD MAKE, DRAWN THE WAY THE BOARD DRAWS IT.
|
||
*
|
||
* This read `re-order consist [1,2,3,0]` — the engine's own array indices offered to a person
|
||
* — and the option Jesse wanted was the first of five and unidentifiable (playtest,
|
||
* 2026-09-17). Naming the cars fixed that and left a second ambiguity he caught immediately:
|
||
* a list "front to back" means nothing at a table looking at a map, because which end is the
|
||
* front depends on which way the train is pointed.
|
||
*
|
||
* SO IT IS LAID OUT WEST TO EAST, exactly as `board-svg.ts` lays the crew strip: the consist
|
||
* is stored nose first, and a train facing EAST is reversed so its nose lands at the east end
|
||
* where it actually is. The engine is the same ◀ / ▶ arrow the board uses, seated where it
|
||
* will be, so "ahead of the engine" and "behind the engine" are read off the picture rather
|
||
* than asserted in words — and the button and the board cannot disagree.
|
||
*/
|
||
const sorting = s.trays.get(i.trayId);
|
||
if (!sorting) return `re-order consist [${i.order.join(',')}]`;
|
||
const after = i.order.map((n) => sorting.consist[n]!).filter((c) => c !== undefined);
|
||
const engineAt = i.engineAt ?? 0;
|
||
const facing = railFacingOf(sorting);
|
||
|
||
const items = after.map((c) => carLabel(c));
|
||
items.splice(engineAt, 0, facing === 'w' ? '◀ ENGINE' : 'ENGINE ▶');
|
||
// West on the left, like the map and like the crew strip on the board.
|
||
const strip = (facing === 'e' ? [...items].reverse() : items).join(' · ');
|
||
|
||
/**
|
||
* WHAT IT WOULD MEAN, from §8.2's own predicate rather than a copy of it: a train with its
|
||
* whole consist ahead of the engine is a PUSHING train and fit to run, a buried engine is not,
|
||
* and a caboose has to ride at the end away from the engine. Numbered trains only — a local
|
||
* crew has no card and never departs, so a departure verdict on one is noise.
|
||
*/
|
||
const unfit =
|
||
sorting.trainNumber === null
|
||
? null
|
||
: badlyMadeUp({ ...sorting, consist: after, engineAt });
|
||
// `badlyMadeUp` leads with "not made up — ", which reads as a stutter in front of HELD. The
|
||
// reason after it is the part worth showing, so the prefix comes off.
|
||
const because = unfit?.replace(/^not made up — /, '') ?? '';
|
||
const verdict =
|
||
sorting.trainNumber === null
|
||
? ''
|
||
: unfit === null
|
||
? ' · MADE UP, ready to leave'
|
||
: ` · HELD at the Office: ${because}`;
|
||
|
||
// A sort that only moves the engine says which errand it is running, rather than reprinting a
|
||
// car order that has not changed.
|
||
const sameOrder = i.order.every((n, at) => n === at);
|
||
const lead = sameOrder
|
||
? engineAt === 0
|
||
? 'pull the engine back to the front'
|
||
: engineAt === after.length
|
||
? 'put the whole consist ahead of the engine'
|
||
: `move the engine behind ${engineAt} car${engineAt === 1 ? '' : 's'}`
|
||
: 're-order';
|
||
return `${lead} — west to east: ${strip}${verdict}`;
|
||
}
|
||
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 ')}`;
|
||
/**
|
||
* NAME THE PLACE AND THE DIRECTION, because the player is choosing both.
|
||
*
|
||
* This used to explain why the Extra had no choice — "it runs west, so that is the end it
|
||
* starts from". It has one now (§7, Jesse's ruling), and every candidate is on screen at once,
|
||
* so each label has to be distinguishable from its three or four siblings at a glance.
|
||
*/
|
||
case 'newTrain.startExtra': {
|
||
const where = resolveExtraStart(s, s.clock.currentActor ?? 0, i);
|
||
if (typeof where === 'string') return `start Extra X${i.trainNumber}`;
|
||
const { direction } = where;
|
||
if (where.at.kind === 'divisionPoint') {
|
||
const end = where.at.side === 'west' ? 'Western' : 'Eastern';
|
||
return `start Extra X${i.trainNumber} at the ${end} Division Point — it runs ${direction} from there`;
|
||
}
|
||
if (where.at.kind === 'mainline') {
|
||
return (
|
||
`start Extra X${i.trainNumber} ${direction}bound in the Interchange — it is made up in the ` +
|
||
'yard and highballs onto the Mainline once the Subdivision is clear'
|
||
);
|
||
}
|
||
const tier = officeProfile(areaAtSeat(s, where.at.seat).tier).name;
|
||
return `start Extra X${i.trainNumber} ${direction}bound at the ${tier} in seat ${where.at.seat} — a Control Point, so it may begin its run there`;
|
||
}
|
||
|
||
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 (
|
||
`FLAG ${i.side === 'east' ? 'EAST' : 'WEST'} — hold the next ${i.side === 'east' ? 'westbound' : 'eastbound'} ` +
|
||
'train short of your Limits, so you can finish switching'
|
||
);
|
||
// §Q, the out-of-phase play (Gitea#19) — "COLLISION RISK! FLAG AGAINST T2?"
|
||
case 'mainline.redFlag':
|
||
return i.flag
|
||
? 'FLAG IT — stop the train short of your Limits, spending a Red Flags card'
|
||
: 'wave it through — let it come in';
|
||
/**
|
||
* §11, the Yard Office (Gitea#5). The offer interrupts the Mainline Phase, so the label has to
|
||
* carry the whole question — there is no surrounding context on screen to lean on, and the
|
||
* player is being asked about a train they were not otherwise thinking about.
|
||
*/
|
||
case 'mainline.yardOffice':
|
||
return i.take
|
||
? 'take the YARD OFFICE — straight into the yard, leaving the Train Order Office free'
|
||
: 'keep it at the Train Order Office — the ordinary arrival, onto an A/D track';
|
||
// §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its
|
||
// options through this list like any other, and the label is what the history says it chose.
|
||
case 'game.extend':
|
||
return i.agree
|
||
? 'play one more Day — the result already recorded still stands'
|
||
: 'end the game here';
|
||
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.
|
||
// Narrowed to the clearance question: `pendingDecision` is a union since Gitea#5, and only
|
||
// this member names a train ahead.
|
||
const pending = s.clock.pendingDecision;
|
||
const clearance = pending?.kind === 'clearance' ? pending : null;
|
||
const who = clearance ? trainName(s, clearance.train) : 'the train';
|
||
const ahead = clearance ? trainName(s, clearance.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 (
|
||
`FLAG ${i.side === 'east' ? 'EAST' : 'WEST'} — hold the next ${i.side === 'east' ? 'westbound' : 'eastbound'} ` +
|
||
'train short of your Limits, so you can finish switching'
|
||
);
|
||
// §Q, the out-of-phase play (Gitea#19) — "COLLISION RISK! FLAG AGAINST T2?"
|
||
case 'mainline.redFlag':
|
||
return i.flag
|
||
? 'FLAG IT — stop the train short of your Limits, spending a Red Flags card'
|
||
: 'wave it through — let it come in';
|
||
/**
|
||
* §11, the Yard Office (Gitea#5). The offer interrupts the Mainline Phase, so the label has to
|
||
* carry the whole question — there is no surrounding context on screen to lean on, and the
|
||
* player is being asked about a train they were not otherwise thinking about.
|
||
*/
|
||
case 'mainline.yardOffice':
|
||
return i.take
|
||
? 'take the YARD OFFICE — straight into the yard, leaving the Train Order Office free'
|
||
: 'keep it at the Train Order Office — the ordinary arrival, onto an A/D track';
|
||
// §3.3, extended play (Gitea#11). The results screen draws its own buttons, but a bot reads its
|
||
// options through this list like any other, and the label is what the history says it chose.
|
||
case 'game.extend':
|
||
return i.agree
|
||
? 'play one more Day — the result already recorded still stands'
|
||
: 'end the game here';
|
||
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.
|
||
*/
|
||
/**
|
||
* ONE DISTRICT'S BOARD, BY SEAT — the cards on the table and the cars standing on them (Gitea#20
|
||
* step 1).
|
||
*
|
||
* A district's BOARD is public. Everyone at the table can see the cards somebody has laid, the cars
|
||
* standing on them and the trains in the Office Area; what is private is a player's HAND, their
|
||
* objective and their Revenue detail, none of which is here. That split is why this can be handed to
|
||
* a seatless spectator unchanged.
|
||
*
|
||
* **KEYED BY SEAT, NOT BY PLAYER, and that is not a detail.** Employee Rotation moves players
|
||
* between districts, so district ownership cannot be assumed to match player index — the board
|
||
* belongs to the POSITION on the Division and the player is whoever is currently sitting there
|
||
* (`playerAtSeat`). Taking a player here would silently draw the wrong district the first time
|
||
* anybody rotated.
|
||
*
|
||
* `seat` is also the "home seat" for `carLabel`, which marks a load THIS district made — the printed
|
||
* game turns the chip upside down in the tray, and a load may not be broken in the Office Area that
|
||
* made it. For a player's own view that seat is theirs; for a spectator's view of district N it is
|
||
* N, which is the same fact asked from outside.
|
||
*/
|
||
/**
|
||
* WHAT AN ENHANCEMENT DOES — AND WHETHER IT CAN DO IT RIGHT NOW (#101).
|
||
*
|
||
* `enhancementText(key)` takes only the key, so it says the same thing for ever. That is right for
|
||
* every enhancement except the three dispatch devices, which are "once a day": a spent Radio read
|
||
* "Once a day, add +12…" all Day after it was gone, which is `trainRules` before #100 in a different
|
||
* corner of the same view.
|
||
*
|
||
* AND THE FEDORA, which is the half that actually surprises. `spendDispatchBonus` (advance.ts) reads
|
||
* the SUPERINTENDENT's own devices, not the train owner's, and the Fedora moves every
|
||
* `STAGES_PER_SHIFT` Stages — so a device does nothing at all while somebody else is dispatching,
|
||
* and is spent automatically, without its owner being asked, while they are.
|
||
*
|
||
* `dispatchBonus` decides what counts as a device, rather than a list of three keys written out
|
||
* here: the ladder lives in `ENHANCEMENT_RULES` and a fourth rung would otherwise be silently
|
||
* exempt.
|
||
*/
|
||
function enhancementState(
|
||
s: GameState,
|
||
area: OfficeArea,
|
||
seat: SeatIndex,
|
||
key: string,
|
||
): { what: string; spent: boolean } {
|
||
const base = enhancementText(key) ?? prettyKey(key);
|
||
if (enhancementRule(key)?.dispatchBonus === undefined) return { what: base, spent: false };
|
||
|
||
const spent = area.dispatchUsedToday.includes(key);
|
||
if (spent) {
|
||
return {
|
||
what: `${base} SPENT for today — it comes back at the start of the next Day.`,
|
||
spent: true,
|
||
};
|
||
}
|
||
// Available, but only to whoever is dispatching. Naming the shift length is the difference
|
||
// between "not now" and knowing how long "not now" lasts.
|
||
if (seatOf(s, s.clock.superintendent) !== seat) {
|
||
return {
|
||
what:
|
||
`${base} Unspent, but IDLE: a device is only used by the district holding the Fedora, ` +
|
||
`which moves every ${STAGES_PER_SHIFT} Stages.`,
|
||
spent: false,
|
||
};
|
||
}
|
||
return { what: `${base} Available today, and this district is dispatching.`, spent: false };
|
||
}
|
||
|
||
export function projectDistrict(
|
||
s: GameState,
|
||
seat: SeatIndex,
|
||
): { cells: CellView[]; facilities: FacilityView[]; runningRow: number; limits: { west: number; east: number } } {
|
||
const area = areaAtSeat(s, seat);
|
||
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, seat);
|
||
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) => enhancementState(s, area, seat, k).what),
|
||
enhancementsSpent: card.enhancements.map((k) => enhancementState(s, area, seat, k).spent),
|
||
trains: trainsOnCard(s, seat, key),
|
||
adTracks: card.geometry.kind === 'office' ? officeProfile(area.tier).adTracks : null,
|
||
cars: carsOn(card).map((c) => carLabel(c, seat)),
|
||
standingWest: card.standingWest,
|
||
facility: fv,
|
||
});
|
||
}
|
||
|
||
return {
|
||
cells,
|
||
facilities,
|
||
runningRow: area.runningRow,
|
||
limits: { west: area.limitsWest.col, east: area.limitsEast.col },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* THE DIVISION — every district's cell, the Mainline between them, and both Division Points.
|
||
*
|
||
* Wholly public and always was: it reads no hand, no objective and no per-viewer state, so a
|
||
* spectator's Division map and a player's are the same picture. It is extracted rather than
|
||
* rewritten for exactly that reason — the public view must not be a second implementation that can
|
||
* drift from the one players look at.
|
||
*/
|
||
export function projectDivision(s: GameState): DivisionView[] {
|
||
return 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 = n.card === 'heavyGrade';
|
||
/**
|
||
* WHERE ON THE CARD — now simply what the card says.
|
||
*
|
||
* This used to recover a printed two-region model from a crossing time computed out of the
|
||
* card's mph, the train's Fast/Slow class, its consist and any modifiers, by treating the
|
||
* ENTRY point as the thing that varied: `entry = 2 - stagesTotal`. It even had to cope with a
|
||
* negative entry, for a slow train needing three Stages to cross a card with two regions.
|
||
*
|
||
* Gitea#3 turned that the right way up. Regions are the primary thing — printed on the card,
|
||
* one per Stage — and the entry point is what the rules actually move. There is nothing left
|
||
* to reconstruct.
|
||
*/
|
||
/**
|
||
* AND WHICH WAY IT CAME IN (Gitea#22). `regionOfTransit` counts from the end the train
|
||
* ENTERED — everything still to run is region 0 — and both directions share that one index
|
||
* space, which is what the collision rules want and why the engine asks it directly.
|
||
*
|
||
* The map is asking a different question: which printed box, LEFT TO RIGHT. East is right
|
||
* here and always has been, so for an eastbound train the two questions have the same answer
|
||
* by luck — it enters at the west end, so "just entered" and "leftmost box" coincide. A
|
||
* westbound train enters at the EAST end, so its region 0 is the right-hand box, and using
|
||
* the travel index directly drew the whole card mirrored.
|
||
*
|
||
* That cost a collision (seed 550943578, undo 187): a westbound TX17 that had just entered
|
||
* was drawn WEST of a westbound T5 that was nearly across, so the train physically behind
|
||
* appeared to be the one in front. Train 3 was cleared to follow T5 and ran into TX17 —
|
||
* where the rules had always had it.
|
||
*
|
||
* So the engine's index is turned into a place on the map here, once, at the boundary the
|
||
* map is drawn from. `regionOfTransit` keeps its meaning and the collision rules are
|
||
* untouched; only the picture changes.
|
||
*/
|
||
const place = (t: { stagesRemaining: number; direction: Direction }): number => {
|
||
const travelled = regionOfTransit(n.card, t.stagesRemaining);
|
||
const regions = mainlineProfile(n.card).regions;
|
||
return t.direction === 'west' ? regions - 1 - travelled : travelled;
|
||
};
|
||
return {
|
||
kind: 'ml',
|
||
label: name,
|
||
regions: mainlineProfile(n.card).regions,
|
||
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,
|
||
// Drawn at the start of the card: the yard is beside the rail, and this is the end the
|
||
// train will pull out of. It counts against nothing — see `yard` on DivisionView.
|
||
yard: (n.holding ?? []).map((id) => ({ ...trainChip(s, id), region: 0 })),
|
||
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,
|
||
// Straight off the node the engine sets (#94). Public to every seat — a flag on the table is
|
||
// seen by everyone at it — so this is not redacted by viewer and must not become so.
|
||
redFlag: n.redFlag ?? null,
|
||
running,
|
||
switching: below,
|
||
};
|
||
});
|
||
}
|
||
|
||
/**
|
||
* WHO THE GAME IS WAITING ON — the one answer, asked one way (Gitea#20 step 1).
|
||
*
|
||
* TWO THINGS HAVE TO BE TRUE AT ONCE, and each was somewhere else before #96 put them together.
|
||
*
|
||
* `clock.currentActor` alone is not it: the engine sets that null while an interruption is standing
|
||
* — a §8.1 clearance goes to the Superintendent, a Yard Office offer to the district's owner — so a
|
||
* view reading the raw field reports "nobody" during exactly the moments a player is being waited
|
||
* on. `actingPlayer` knows that rule and is the engine's own answer to it.
|
||
*
|
||
* `actingPlayer` alone is not it either, and THIS is what #96 was: it has no status guard, so when
|
||
* the game is not running it hands back whatever `clock.currentActor` was left holding — the last
|
||
* seat to move before the timetable ran out. The §3.3 vote is the state that exposed it. That vote
|
||
* is PARALLEL, open to every un-voted seat at once, and `apply.ts` says in as many words that there
|
||
* is no actor to be; the screen named the last mover anyway, beside a tally correctly showing three
|
||
* seats outstanding.
|
||
*
|
||
* So: nobody is acting unless the game is `active`, and when it is, `actingPlayer` decides who.
|
||
*
|
||
* `currentActor(game)` in `web/game.ts` is this function taking a `Game`, and delegates to it —
|
||
* ONE answer, not two that agree until they don't. That matters more than it looks: `currentActor`
|
||
* is what refuses an intent, so a screen answering differently tells the table to wait on a player
|
||
* the server would turn away.
|
||
*/
|
||
export function currentActorOfState(s: GameState): PlayerIndex | null {
|
||
if (s.status !== 'active') return null;
|
||
return actingPlayer(s);
|
||
}
|
||
|
||
/**
|
||
* THE TABLE, AS EVERY SEAT SEES IT IDENTICALLY (Gitea#20 step 1).
|
||
*
|
||
* The clock, the phase, whose turn it is, the timetable, the yards, the deck COUNTS, the score and
|
||
* the house rules. Nothing here is redacted, and nothing here may become redacted: the whole point
|
||
* is that a spectator and a player read the same table state, so a field that has to differ by seat
|
||
* belongs in the player's own frame instead.
|
||
*
|
||
* Deck contents are counts and top-of-pile names only. A Department pile is FACE UP — a discard goes
|
||
* onto one precisely so a rival can take it — so naming its top card gives nothing away; the Home
|
||
* Office deck is face down and appears here as a length and nothing else.
|
||
*/
|
||
export function projectSharedTable(s: GameState) {
|
||
return {
|
||
day: s.clock.day,
|
||
stage: s.clock.stage,
|
||
clock: clockTime(s.clock.stage),
|
||
phase: phaseLabel(s.clock.phase),
|
||
phaseKey: s.clock.phase,
|
||
actor: currentActorOfState(s),
|
||
superintendent: s.clock.superintendent,
|
||
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 }))),
|
||
houseRules: houseRules(s.config),
|
||
mode: s.config.mode,
|
||
optionalRules: s.config.optionalRules,
|
||
days: s.config.days,
|
||
minCombinedRevenue: s.config.minCombinedRevenue,
|
||
maxCollisionsPerDay: s.config.maxCollisionsPerDay,
|
||
maxCollisionsTotal: s.config.maxCollisionsTotal,
|
||
collisionsToday: s.collisionsToday,
|
||
collisionsPrevDay: s.collisionsPrevDay,
|
||
collisionsTotal: s.collisionsTotal,
|
||
status: s.status,
|
||
outcome: s.outcome,
|
||
extraDays: s.extraDays,
|
||
extensionVotes: [...s.extensionVotes],
|
||
official: s.official,
|
||
tally: s.tally,
|
||
players: s.players.map((p) => ({
|
||
index: p.index,
|
||
seat: seatOf(s, p.index),
|
||
name: p.name,
|
||
revenue: p.revenue,
|
||
// A COUNT, never the cards. Hand SIZE is public — you can see how many cards somebody holds
|
||
// across a table — and this is the only thing about another player's hand that may be here.
|
||
hand: (s.decks.hands.get(p.index) ?? []).length,
|
||
})),
|
||
openingRolls: {
|
||
division: [...s.openingRolls.division],
|
||
superintendent: [...s.openingRolls.superintendent],
|
||
},
|
||
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})`,
|
||
})),
|
||
/**
|
||
* THE CREW TRAY POOL, WHICH IS §7's SCARCITY MECHANIC (#98).
|
||
*
|
||
* `state.ts` calls it explicit, and it was explicit only in the engine: there are fewer trays
|
||
* than there are trains wanting one, and nothing said how many were left. Public without
|
||
* question — the trays are physical objects in the middle of the table, and this is a count
|
||
* beside the deck and yard counts already here.
|
||
*/
|
||
crewTrays: { free: s.freeTrays.length, total: crewTrayCount(s.players.length) },
|
||
/**
|
||
* THE TRAINS QUEUED FOR ONE — the other half, and the half that had been promised in words.
|
||
*
|
||
* Playing an Extra says "it runs once as soon as a Crew Tray frees up"; ordering a second
|
||
* section says "an identical train will run right behind it". Both were announced once in the
|
||
* log and then existed only in the engine, so neither promise was ever visibly kept. Who played
|
||
* an Extra is public: §7 gives the train to the player who played the card, in the open.
|
||
*/
|
||
queued: {
|
||
extras: s.pendingExtras.map((x) => ({ trainNumber: x.trainNumber, player: x.player })),
|
||
secondSections: [...s.pendingSecondSections],
|
||
},
|
||
};
|
||
}
|
||
|
||
/** One district as a spectator sees it: whose seat it is, who is sitting there, and its board. */
|
||
export type PublicDistrict = {
|
||
seat: SeatIndex;
|
||
player: PlayerIndex;
|
||
name: string;
|
||
cells: CellView[];
|
||
facilities: FacilityView[];
|
||
runningRow: number;
|
||
limits: { west: number; east: number };
|
||
};
|
||
|
||
/** What a seatless viewer may be shown: the table, the Division, and every district's board. */
|
||
export type PublicFrame = ReturnType<typeof projectSharedTable> & {
|
||
division: DivisionView[];
|
||
districts: PublicDistrict[];
|
||
};
|
||
|
||
/**
|
||
* THE WHOLE GAME AS A SPECTATOR MAY SEE IT — no seat, no hand, no secrets (Gitea#20 step 1).
|
||
*
|
||
* **Built from the same lower-level projections a player's frame is, and deliberately NOT by calling
|
||
* `snapshot()` once per seat.** That shortcut is the trap: `snapshot` exists to assemble one
|
||
* player's view and carries their hand, their objective, their Revenue detail and their legal moves,
|
||
* so a public view made of player views starts by constructing everything it then has to remember to
|
||
* strip. It also defaults its viewer to player zero, which means a careless spectator call today
|
||
* serves seat 0's hand. Composing upward instead means a private field cannot arrive here by
|
||
* accident: it would have to be added to a projection that has no business holding one.
|
||
*
|
||
* **Districts are keyed by SEAT and the player is resolved through `playerAtSeat`.** Employee
|
||
* Rotation moves players between districts, so seat and player index are not interchangeable, and
|
||
* a public board that assumed they were would relabel every district the first time anybody rotated.
|
||
*
|
||
* What is NOT here, and why each: `hand`/`handWhat`/`handDiscardable`/`handKeepWhy` and `handCount`
|
||
* (the cards a seat holds), `objective` (a private goal), `option`/`movesLeft`/`moves` (one player's
|
||
* legal actions, which describe what they are ABOUT to do), `blocked` (computed per viewer and
|
||
* partly about their own crews), `decision`, `viewer`/`viewerSeat`, and the narration log — which
|
||
* `session.ts` sends incrementally and which is checked separately, because two of the leaks found
|
||
* in v0.7.9.2 lived there rather than in any frame.
|
||
*/
|
||
export function publicSnapshot(s: GameState): PublicFrame {
|
||
return {
|
||
...projectSharedTable(s),
|
||
division: projectDivision(s),
|
||
districts: [...s.officeAreas.keys()].sort((a, b) => a - b).map((seat) => {
|
||
const player = playerAtSeat(s, seat);
|
||
return {
|
||
seat,
|
||
player,
|
||
// Through `seatLabel`, like every other seat a person reads: the internal index is
|
||
// zero-based and the spoken number is not (`session.test.ts` guards the conversion).
|
||
name: s.players[player]?.name ?? `Seat ${seatLabel(seat)}`,
|
||
...projectDistrict(s, seat),
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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 viewerSeat = seatOf(s, viewer);
|
||
|
||
const { cells, facilities, runningRow, limits } = projectDistrict(s, viewerSeat);
|
||
const division = projectDivision(s);
|
||
return {
|
||
/**
|
||
* THE SHARED TABLE COMES FROM THE SAME PROJECTION THE COMMON BOARD USES (Gitea#20 step 1).
|
||
*
|
||
* Spread rather than restated, so a player's frame and a spectator's cannot come to disagree
|
||
* about the clock, the phase, whose turn it is or the score. Everything after this point is
|
||
* either private to `viewer` or a viewer-specific slice; none of it shadows a shared field, and
|
||
* one that did would be exactly the bug this arrangement exists to make visible.
|
||
*/
|
||
...projectSharedTable(s),
|
||
/**
|
||
* The three interruptions §8.1 and Gitea#5/#19 can raise, said in the words the prompt itself
|
||
* uses. `decisionActor` above decides WHO; this is only what they are looking at.
|
||
*/
|
||
awaiting: (() => {
|
||
const d = s.clock.pendingDecision;
|
||
if (!d) return null;
|
||
const train = trainName(s, d.train);
|
||
if (d.kind === 'clearance') return { asks: 'a clearance ruling', train };
|
||
if (d.kind === 'yardOffice') return { asks: 'the Yard Office offer', train };
|
||
return { asks: 'a Red Flag', train };
|
||
})(),
|
||
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)),
|
||
handDiscardable: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id) === null),
|
||
handKeepWhy: [...(s.decks.hands.get(viewer) ?? [])].reverse().map((id) => keepReason(s, id)),
|
||
decision,
|
||
wasted,
|
||
option: turnOf(s, viewer).option,
|
||
viewer,
|
||
viewerSeat,
|
||
handCount: (s.decks.hands.get(viewer) ?? []).length,
|
||
overHandLimit: overHandLimit(s, viewer),
|
||
objective: objectiveOf(s, viewer),
|
||
runningRow,
|
||
limits,
|
||
movesLeft: s.clock.phase === 'localOps' && turnOf(s, viewer).option === 'switch' ? turnOf(s, viewer).movesRemaining : null,
|
||
moves: switchingMoves(s, viewer),
|
||
blocked: impediments(s, viewer),
|
||
};
|
||
}
|
||
|
||
/** 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':
|
||
/**
|
||
* THE PRINTED NAME WINS over `prettyKey`'s guess, on the same reasoning as the facility and
|
||
* modifier lookups above: the tables are for names `prettyKey` cannot derive.
|
||
*
|
||
* It guessed wrong more often than the fallback comment implies. `absSignals` came out as
|
||
* "Abs Signals" on the button while the rules, the tooltip and the card face all say **ABS**
|
||
* Signals — an acronym no key-splitter can recover — and `brokenCoupler` and `beanHouse`
|
||
* were title-cased past their printed "Broken coupler" and "Bean house".
|
||
*/
|
||
return SIMPLE_CARD_NAMES.get(k.key) ?? prettyKey(k.key);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Every authored card name, keyed as the card kinds key themselves.
|
||
*
|
||
* Built from the content tables rather than restated, so a card renamed there is renamed here and
|
||
* the two cannot drift — which is the whole argument of TODO #15a, applied to names.
|
||
*/
|
||
const SIMPLE_CARD_NAMES: ReadonlyMap<string, string> = new Map(
|
||
[
|
||
...SPACE_USE_CARDS,
|
||
...ENHANCEMENT_CARDS,
|
||
...MAINLINE_MODIFIER_CARDS,
|
||
...MANEUVER_CARDS,
|
||
...ACTION_CARDS,
|
||
].map((c) => [c.key, c.name]),
|
||
);
|
||
|
||
/**
|
||
* 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;
|
||
/**
|
||
* X17 only — whether the speeches are made, which is what decides which HALF of its printed rule
|
||
* the train is currently living under (#100). Optional because the timetable renders a train
|
||
* number with no tray behind it; absent means "not yet", which is the state a train starts in.
|
||
*/
|
||
speechMade?: 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. A caboose is not a load.');
|
||
}
|
||
/**
|
||
* WHICH HALF OF ITS RULE THE CAMPAIGN TRAIN IS IN (#100).
|
||
*
|
||
* "One turn at station (speeches) then expedite" is two states, not one sentence. This used to
|
||
* print the sentence and stop, so the chip read identically before and after the speeches — while
|
||
* the fault the second half creates was warned about only under `expedite`, i.e. to every train
|
||
* EXCEPT the one that had just become subject to it.
|
||
*/
|
||
if (p.rules.stopThenExpedite) {
|
||
parts.push(
|
||
t.speechMade
|
||
? 'SPEECHES MADE — it runs EXPEDITED from here on'
|
||
: 'STOPS ONCE FOR SPEECHES at its first Office, then runs expedited from the next one onward',
|
||
);
|
||
}
|
||
|
||
// `isExpedited` (advance.ts) is the engine's own test, borrowed rather than restated: a card that
|
||
// described a rule the engine did not apply — or the reverse — is the whole failure this is in.
|
||
if (isExpedited(t)) {
|
||
// 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 { minCombinedRevenue: target } = s.config;
|
||
/**
|
||
* PACED AGAINST THE TIMETABLE ACTUALLY BEING PLAYED, extensions included (Gitea#11).
|
||
*
|
||
* `config.days` alone would say "the last Day is over" through every extended Day, and pace an
|
||
* eight-Day game against five — both of which the status line used to do the moment play carried
|
||
* on past the end. The official result is still decided at `config.days`; that is `checkVictory`'s
|
||
* business, and nothing here feeds it.
|
||
*/
|
||
const days = s.config.days + s.extraDays;
|
||
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),
|
||
// Conditional spread, not `beingMadeUp: isBeingMadeUp(t)`: the field is optional-and-true, and
|
||
// `exactOptionalPropertyTypes` refuses an explicit `false` for it.
|
||
...(isBeingMadeUp(t) ? { beingMadeUp: true as const } : {}),
|
||
};
|
||
}
|
||
|