/** * Component 2 — State model and types. * * The entity model from docs/architecture/game-state.md. Pure data; no behaviour beyond a few * derivations that must never be cached (see DERIVED note below). * See architecture/components.md §2 A.2. */ import type { CarType, Direction, Hand, MainlineKind, FreightKind, HouseRuleOverrides, ModifierKind, OfficeTier, TrackGeometry, } from './content.ts'; import { HAND_LIMIT, MAX_CONSIST, officeProfile } from './content.ts'; // --------------------------------------------------------------------------- // Identifiers // --------------------------------------------------------------------------- export type PlayerIndex = number; /** * A SEAT at the table — a fixed position in the west-to-east chain of Offices (§4.3). * * NOT the same thing as a `PlayerIndex`, even though the two are equal in every game today. An * Office is a place: it sits between two Mainline cards and never moves. A player OCCUPIES a seat, * and Employee Rotation (Appendix B) moves every player one seat left at the end of each Day while * their Revenue and the Fedora travel with them. * * So: offices, districts and grid positions are keyed by SEAT; hands, Revenue, the Superintendent * and whose turn it is are keyed by PLAYER. `s.seating` maps one to the other, and both are plain * numbers, so the distinction is carried by naming and by the accessors rather than by the type * system — `areaOf(s, player)` and `areaAtSeat(s, seat)` are the two doors, and code should use them * rather than reaching into `officeAreas` directly. */ export type SeatIndex = number; export type CardId = string; export type TrayId = string; /** A cell in a player's Office Area grid. Sparse — cards are placed during play. */ export type GridCoord = { row: number; col: number }; export function coordKey(c: GridCoord): string { return `${c.row},${c.col}`; } // --------------------------------------------------------------------------- // Rolling stock // --------------------------------------------------------------------------- /** §2.2 — a coloured car is loaded, a white car is empty. */ export type RollingStock = { type: CarType; loaded: boolean; /** * WHICH OFFICE AREA MADE THIS LOAD — the physical game's chip turned upside down in the tray. * * Reported from playtesting v0.4.9d as two bugs with one cause: a boxcar loaded at a Freight * House could be unloaded at that same Freight House on the next Laborer action, and passengers * who had just boarded could be detrained again before the train turned a wheel. Both paid full * Revenue at each end for a load that never went anywhere. * * Jesse's rule (v0.4.9e): freight or passengers loaded anywhere in an Office Area may not be * unloaded ANYWHERE in that same Office Area — not at another facility, not in a later Stage. * They have to be carried by a train to a different Office Area. So the stamp is the SEAT, which * is what an Office Area belongs to (Employee Rotation moves players between chairs; the district * stays with the chair), and it never expires. * * A SEAT, NOT A PLAYER, and undefined rather than -1 for "no origin": the Division Yard opens with * loaded cars and loaded coaches that were made up off-Division (`ROLLING_STOCK_SUPPLY`), and * those are exactly the inbound traffic a solitaire district lives on. A sentinel inside * `SeatIndex`'s own value range is not a sentinel — see `card.play`'s `node` in intents.ts. * * Stripped by `pooled` whenever a car goes back to a yard: the stamp belongs to the LOAD, and a * car returning to the common supply is carrying nothing. */ origin?: SeatIndex; }; /** * A car returning to the common pool — the Division or Classification Yard — with its load's origin * stamp taken off. * * Every yard push goes through this. A loaded car CAN reach a yard still loaded (a train retires at * a Division Point with freight aboard, `advance.ts`), and without this it would carry a stamp from * a district it left several Days ago into whatever train is made up from it next. */ export function pooled(car: RollingStock): RollingStock { if (car.origin === undefined) return car; const { origin: _origin, ...rest } = car; return rest; } // --------------------------------------------------------------------------- // Track and Office Area // --------------------------------------------------------------------------- /** * A turnout's handedness: the stem is §A.1's "A", the two legs are "B" and "C". The rule that * matters is that `through` and `diverge` are NOT joined to each other. * * The through track is ALWAYS east-west (`{stem, through}` is `{e,w}`); the diverging leg is the * 45° one and always reaches north or south. Which of the two the card can be turned to is decided * by its printed handedness — see `Slope`. */ export type TurnoutOrientation = { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' }; /** * A curve joins two ADJACENT edges: it runs along the card's centre line from the east or west edge * to a frog, then leaves at 45° through the MIDDLE of the north or south edge. Four such arcs * exist, but a printed card reaches only two of them (`Slope`). */ export type TrackArc = 'ne' | 'nw' | 'se' | 'sw'; /** * WHICH DIAGONAL A 45° LEG LIES ON. * * The printed cards (docs/tracks.png) put the through rail dead centre and send every diverging leg * out at 45° through the middle of the north or south edge. A card can be turned 180° but not * flipped over, so its leg never changes diagonal: the slope is printed, not chosen. * * Each name is the pair of arcs that MATE across a horizontal card edge — an `sw` card sitting * above an `ne` card is one unbroken rail, whereas `sw` above `nw` is a V and joins nothing. That * is the whole matching rule, and it is why the name is spelt this way. * * On screen `ne_sw` descends to the right and `nw_se` descends to the left. */ export type Slope = 'ne_sw' | 'nw_se'; export type CardGeometry = | { kind: 'track'; geometry: TrackGeometry; turnout?: TurnoutOrientation; /** Which two edges a CURVE joins — chosen on placement, since the card can be turned. */ arc?: TrackArc; /** Curves and turnouts are printed left- or right-handed, which fixes their `Slope`. */ hand?: 'left' | 'right'; } | { kind: 'office' } | { kind: 'limits' } | { kind: 'facility'; facility: FreightKind } /** Not track — a Modifier sits beside a Facility and raises its capacity (§9). */ | { kind: 'modifier'; modifier: ModifierKind } /** Not track — a Space-use card played at a district to consume a cell (Q6). */ | { kind: 'spaceUse'; key: string }; export type TrackCard = { geometry: CardGeometry; /** * DERIVED for facility cards — a Facility track locked by loads on MEN|AT|WORK stops being * Operational Rail (§9.3). Use isOperationalRail() rather than reading a stored flag. */ baseOperationalRail: boolean; /** * Uncoupled cars left standing here, **ordered WEST to EAST** (§A.3). * * §A.3 states the requirement outright: "cars are loaded into and unloaded from the Crew Tray, and * occupy the track, in the same order they originally held, **left-to-right**". Left-to-right is * west-to-east, and this array had no defined orientation at all — the doc comment said "in track * order" and named no direction, so nothing performed the conversion. * * That is one bug wearing three faces, all reported from play. Setting out four cars at once * parked them in a different order than setting out one car four times, because `carsDropped` * always pushed onto the end. A train that ran onto a parked cut got the SAME consist whichever * way it approached, when the two must mirror. And the board drew the tray strip flipped for an * east-facing train and the card's cars unflipped, so the same cut read one way in the tray and * the other on the card. * * WEST-TO-EAST IS THE BOARD'S ORIENTATION, not the train's, which is exactly why it is the right * one: it is a property of the card, so it does not change when a different train touches it. The * two conversions are stated once, in `apply.ts`'s `carsDropped` and `carsCoupled` reducers, and * `standingWest` below records where a train standing here sits among them. */ standing: RollingStock[]; /** * HOW MANY OF `standing` LIE WEST OF THE TRAIN(S) STANDING HERE. * * `standing` runs west to east and a train sits somewhere IN that row: cars `[0, standingWest)` * are west of it, cars `[standingWest, end)` are east. A train can set out off both ends on the * same square — §A.3 lets a cut come off either outer end — so "which side is this cut on" is not * recoverable from the array alone, and it is the question every remaining rule asks: which cars * are AHEAD of an engine and which BEHIND (combine with `CrewTray.railFacing`), which cut a * departing train must couple back up (the one at the end it leaves by), and which side of the * chip the renderer draws the cut on. * * WITH NO TRAIN STANDING HERE, THIS NUMBER MEANS NOTHING: a card's cars are simply a cut with no * near or far side, and the next train to arrive couples all of them regardless — nothing reads * `standingWest` in that state, so a stale value left over from a train that has since moved on is * inert, not wrong. That reasoning is unchanged from when this lived on `CrewTray`. * * WHAT MOVED IT TO THE CARD: the Office square is the one place more than one train may stand at * once, and only one route lets two trays disagree about the same row of cars — a train already * at the Office sets out a cut in place, without moving, while a second train on another A/D track * still holds whatever split it arrived with. A cut lies west or east of the WHOLE block of A/D * tracks, never of one particular track and never between two trains, so there is exactly one * number for the card to carry and every train standing there reads the same one. Everywhere else * on the board at most one train ever stands on a card, so the two homes for this field are * identical in every other case. */ standingWest: number; facility: Facility | null; modifiers: ModifierKind[]; /** Enhancement cards laid on this card (§7 of implications.md). */ enhancements: string[]; }; export type OfficeArea = { /** Where this Office sits in the chain, not who is sitting at it. See `SeatIndex`. */ seat: SeatIndex; tier: OfficeTier; grid: Map; officeCoord: GridCoord; /** The grid row that is the Running Track (§2.1). */ runningRow: number; limitsWest: GridCoord; limitsEast: GridCoord; /** Trays holding at the Office. Length must never exceed the tier's A/D track count. */ adOccupancy: TrayId[]; /** * Trains held at the Limits by an Interlocking rather than admitted to the Office. They are * inside the player's Limits but not occupying an A/D track. */ heldAtLimits: TrayId[]; /** Dispatch bonuses spent this Day, by enhancement key — each is once a Day. */ dispatchUsedToday: string[]; }; // --------------------------------------------------------------------------- // Facilities // --------------------------------------------------------------------------- /** * A load in transit along the MEN | AT | WORK track (§9.3). * * Direction matters and is easy to miss. **Outbound** loading runs Green -> MEN -> AT -> WORK -> * onto a spotted empty car. **Inbound** unloading runs the other way: car -> WORK -> AT -> MEN -> * red Inbound box. Same three boxes, opposite traversal. */ export type Load = { type: CarType; dir: 'out' | 'in' }; export type Facility = { kind: 'freight' | 'passenger'; subtype: FreightKind | 'office'; allows: { outbound: boolean; inbound: boolean }; outboundBox: RollingStock[]; inboundBox: RollingStock[]; capacity: { outbound: number; inbound: number }; /** * FREIGHT ONLY — `null` on a Passenger Facility, which is what makes that unrepresentable rather * than merely unused. * * One load per box; three boxes, so it is a pipeline (§9.1). §9.2's passenger work is porters * boarding and detraining with no pipeline at all, and the sign is printed "For Freight * Facilities" (rules-v0.2.md:452). Every Office got a three-slot array anyway, and because it was * an array the renderers happily drew three boxes on a Depot that has no Laborer to work them — * reported from playtesting. Typing it away means a fourth renderer cannot reintroduce the bug. */ menAtWork: [Load | null, Load | null, Load | null] | null; /** * Where cars are spotted for loading and unloading. * * NO LENGTH. This used to carry `length: baseOut + baseIn`, which quietly made an industry's * SIDING as long as its box count — so a Mine Tipple (one green box) had room for exactly one * car, and a crew standing on it with two hoppers to set out could only put down one. Reported * from play at undo 188. The box count is how much WORK an industry can hold, not how much RAIL * it has; conflating the two invented a printed siding that no industry card actually has. * * An industry track is ordinary Operating Rail and holds what any card holds — see `spaceOn`. * Modifiers raise `capacity` (another red or green box) and never the room for cars. */ industryTrack: { cars: RollingStock[] }; laborers: number; porters: number; /** Resets at the start of each Stage (§9.1). */ usedThisStage: { laborers: number; porters: number }; }; /** * §9.3 — while ANY load sits on MEN|AT|WORK the industry's track is locked down and loses its * Operational Rail status. This is why isOperationalRail is a function, not a stored field. */ /** * The cars physically standing on a card, **ordered WEST to EAST**. * * On a Facility card the industry track IS where cars stand — spotting a car there and leaving a * car there are the same act (§9.3). Modelling them as two separate places was a bug: cars dropped * at a facility went into `standing`, while loading looked for them on `industryTrack`, so no * freight load could ever complete and freight revenue was structurally zero. * * THE ORIENTATION IS THE BOARD'S, NOT THE TRAIN'S, and it is `standing`'s alone to keep — see the * comment on that field. `industryTrack.cars` inherits it through this function, so every reader of * either place gets the same convention and the two can never drift apart. */ export function carsOn(card: TrackCard): RollingStock[] { // Gated on the facility being FREIGHT, not on a track length that no longer exists. A Passenger // Facility has no industry track at all — passengers board off the platform — so cars left on a // Depot stand on the card like they would anywhere else. return card.facility && card.facility.kind === 'freight' ? card.facility.industryTrack.cars : card.standing; } /** * How many more cars this card can hold. * * ONE RULE FOR EVERY OPERATING TRACK CARD, industry or not: a card holds up to `MAX_CONSIST` cars, * because a consist may not exceed four and four is therefore all that can ever be shoved onto one. * An industry used to be the exception — its room came from its box count, so a one-box industry * refused a second car — and that exception was the bug. Ordinary track used to be the other * exception, unbounded, which let a pile build up that no train could then legally couple. */ export function spaceOn(card: TrackCard): number { return MAX_CONSIST - carsOn(card).length; } export function isOperationalRail(card: TrackCard): boolean { if (!card.baseOperationalRail) return false; if (isLockedByWork(card)) return false; return true; } /** * §9.3 — "While ANY loads are in the MEN | AT | WORK track, the industry's track is locked down for * safety reasons. It loses its status as Operational Rail. No cars can be picked up or dropped off, * and **no trains may occupy or move on it**." * * That last clause is stronger than losing Operational Rail status. A turnout is not Operational * Rail either, and a train runs straight through one — so "cannot stop here" and "cannot pass * through here" are different properties, and only a locked industry has both. Kept separate from * `isOperationalRail` for exactly that reason. */ export function isLockedByWork(card: TrackCard): boolean { // A Passenger Facility has no pipeline, so it is never locked by freight work. return !!card.facility?.menAtWork?.some((slot) => slot !== null); } // --------------------------------------------------------------------------- // Trains // --------------------------------------------------------------------------- export type NodeRef = | { at: 'divisionPoint'; side: Direction } | { at: 'mainline'; index: number } /** A tray standing in someone's district. `seat` is WHICH district, not whose turn it is. */ | { at: 'grid'; seat: SeatIndex; coord: GridCoord }; export type CrewTray = { id: TrayId; /** null while a local crew is switching without a train card. */ trainNumber: number | null; trainIsExtra: boolean; /** * STILL BEING ASSEMBLED, somewhere that is not a Division Point. * * `isBeingMadeUp` used to read the position alone — "standing at a Division Point" — which was * true of every train being built when the only place to build one WAS a Division Point. An Extra * may now be started at a Control Point or in an Interchange's yard (§7), and those trains could * not be given a consist at all: they ran empty, and so did every Control Point Extra since that * option was added. Jesse's report says it plainly — an Extra started at the Interchange "would be * loaded with cars". * * Set when such an Extra is placed and cleared the moment it starts running (`enterMainline`), so * it names a train that is being made up rather than one that merely happens to be standing * somewhere. That distinction is load-bearing: a train that ARRIVED at an Office must never be * fillable from the Division Yard, which is the "cars appearing on a train nobody was making up" * bug `isBeingMadeUp` was tightened to kill, and an arriving train never carries this. */ beingMadeUp?: boolean; /** * WHOSE TRAIN THIS IS TO BUILD, for an Extra only. * * §7's Extra is loaded by the player who played the card, not by the Superintendent-first round * that fills a Timetabled train — so the tray has to remember them: `pendingExtras` is emptied the * moment the Extra starts, which is before a single car goes on. Undefined on every Timetabled * train, where the round decides instead. */ builtBy?: PlayerIndex; /** * WHERE THE ENGINE SITS IN THE TRAY, as an index into `consist`. * * A Crew Tray is an engine plus its Rolling Stock, and the engine may be PULLING (index 0, ahead * of everything), PUSHING (index `consist.length`, behind everything) or somewhere in the middle * doing both at once. That last case is why this is an index and not the boolean it replaced — * `engineFront` was written in three places and read in none, so the engine had a position the * game recorded and never used. * * The engine is NOT one of the `consist` entries: §8.2 counts the consist as Rolling Stock, and * the four-car limit (§A.4) is a limit on cars, not on the locomotive hauling them. * * This only records POSITION, not a supply — and that is not a gap. `rules-v0.2.md`:339 (Gap 4b) * ties Crew Trays and engine pieces together as one combined resource, `player count + 3` * (`crewTrayCount` in `content.ts`), so engine scarcity IS tray scarcity: `freeTrays` running out * already blocks a new train exactly when the engine supply would. There is no state for "a tray * with no engine" because the rules never separate the two. */ engineAt: number; /** * ORDERED NOSE FIRST: index 0 is the end nearest the front of the train, the last index is the * tail. Max 4 including any caboose (§A.4); the engine is not one of them. * * Both diagrams in Appendix A read this way. A train drawn `[; redFlags: Map; }; export type Yards = { divisionYard: RollingStock[]; classificationYard: RollingStock[]; }; // --------------------------------------------------------------------------- // Clock and phases // --------------------------------------------------------------------------- export type Phase = 'localOps' | 'newTrain' | 'mainline' | 'loadUnload' | 'shiftChange'; /** §8.1 fourth condition — the Superintendent rules on a following train. */ /** * AN INTERRUPTION TO THE AUTOMATIC MAINLINE PHASE — a question the driver cannot answer itself. * * There was one of these and it was hardcoded to one question asked of one player: the §8.1 * clearance ruling, always to the Superintendent. Gitea#5 and Gitea#19 each need to stop the same * phase and ask a DIFFERENT player something different, so the shape is a union and `decisionActor` * below decides who answers. * * Every member names the `train` the question is about, because the answer has to be matched back * to it — see `DecisionAnswer`. */ export type PendingDecision = /** §8.1 — a following train in the same Subdivision. The Superintendent rules. */ | { kind: 'clearance'; train: TrayId; occupiedBy: TrayId } /** * §11 (Gitea#5) — an inbound freight may take the Yard Office instead of the Train Order Office. * Asked of whoever sits in `seat`, on the Mainline Phase the train arrives. */ | { kind: 'yardOffice'; train: TrayId; seat: SeatIndex } /** * §Q (Gitea#19) — a train is about to enter this district into a collision, and its owner holds a * Red Flags card. "You can play the card normally or out of phase, but only if you need it." */ | { kind: 'redFlag'; train: TrayId; seat: SeatIndex; from: Direction }; /** * The answer, waiting to be consumed by the train that asked. * * Without this the driver would re-evaluate the same train, ask the same question, and never * advance. Keyed by `kind` as well as `train` so an answer can never be mistaken for the reply to a * different question about the same train. */ export type DecisionAnswer = | { kind: 'clearance'; train: TrayId; allow: boolean } | { kind: 'yardOffice'; train: TrayId; take: boolean } | { kind: 'redFlag'; train: TrayId; flag: boolean }; export type Clock = { day: number; /** 1..12 — the Pocket Watch. */ stage: number; phase: Phase; /** Exactly one player may act at a time. Null during automatic Mainline movement. */ currentActor: PlayerIndex | null; /** Interrupts the Mainline Phase to ask a player something (§8.1, §11). */ pendingDecision: PendingDecision | null; /** The answer to `pendingDecision`, waiting to be consumed by the train that asked. */ decisionAnswer: DecisionAnswer | null; superintendent: PlayerIndex; /** * How far round the table the current phase has got. Acting order starts at the Superintendent * and proceeds left (Gap 1), so `currentActor = (superintendent + actorOffset) % players`. * When it reaches the player count, the phase is complete. */ actorOffset: number; }; // --------------------------------------------------------------------------- // Players and game // --------------------------------------------------------------------------- export type Player = { index: PlayerIndex; name: string; /** May go negative — a collision costs 5 (§10). */ revenue: number; }; export type GameMode = 'solitaire' | 'competitive' | 'coop'; /** * VICTORY CONDITIONS — designed 2026-08-20, unified across all three modes. * * Replaces the old `length`-preset lookup (`LENGTH_PROFILES.target`) and the dead * `VictoryCondition: 'firstToTarget'` (grepped: never selected anywhere in the codebase). Winner is * whoever has the most Revenue when `days` run out — solitaire's own player counts as "everyone" — * unless `minCombinedRevenue` was missed, in which case everyone loses. Coop keeps summing every * player's Revenue into one table score, now against a configurable floor instead of * `profile.target * players.length`. * * `0` means "off" for every numeric field below. `minCombinedRevenue`'s natural default is * `collectiveRevenueFloor(players, days)` (`content.ts`), computed by whoever authors the config — * the engine only ever reads a concrete number here, never resolves one lazily, because player count * is not always known at config-authoring time (a lobby, a CLI flag, a dialog). * * `maxCollisionsPerDay`/`maxCollisionsTotal` are deliberately FLAT, not scaled by player count: more * players means more independent chances to collide, not a bigger shared budget, so a multiplayer * table is genuinely riskier than solitaire at the same default (Jesse's call). */ export type GameConfig = { mode: GameMode; /** How many Days the game runs. */ days: number; /** Everyone loses if the table's total Revenue is below this when `days` run out. 0 = off. */ minCombinedRevenue: number; /** Everyone loses immediately, mid-game, once collisions in one Day reach this. 0 = off. */ maxCollisionsPerDay: number; /** Same, summed across the whole game, never reset. 0 = off. */ maxCollisionsTotal: number; /** * Whether the 22 opponent-directed cards are in the deck (`setup.ts`'s `buildDeck`). Forced off in * `solitaire` and `coop` — neither has a valid target for them — on by default in `competitive`. * Has no effect until those cards are built (Phase 5); see `TODO.md`. */ pvpCardsAllowed: boolean; optionalRules: { reducedVisibility: boolean; employeeRotation: boolean; emergencyToolbox: boolean; }; /** * The opening deal and the three revenue rates, chosen when the game is dealt (`content.ts`). * * Optional and PARTIAL on purpose. Every caller that does not care about them — and most of the * engine tests do not — gets `DEFAULT_HOUSE_RULES` through `houseRules()`, which is the one place * a default is written down. A caller that cares names only the dials it is setting. */ houseRules?: HouseRuleOverrides; }; export type OutcomeReason = 'daysElapsed' | 'collisionFloor' | 'revenueFloor'; export type Outcome = { result: 'win' | 'loss'; winner: PlayerIndex | null; reason: OutcomeReason; }; /** * §3.3, EXTENDED PLAY (Gitea#11) — which endings may be played past. * * Both days-based endings offer another Day: running out of timetable, and closing short of the * combined Revenue floor, are the same event seen twice — the last Day ended and this is what the * books say. A `collisionFloor` ending is NOT extendable, and neither is a collision breach that * happens during an extended Day: §3.4 stopped the game because the railroad was declared unsafe, * and carrying on regardless would contradict the rule that stopped it (Jesse's call, 2026-08-28). */ export function isExtendable(reason: OutcomeReason): boolean { return reason === 'daysElapsed' || reason === 'revenueFloor'; } /** * Running counts of everything interesting that has happened, tallied from the event stream * (Gitea#16). * * WHY IT LIVES ON `GameState` rather than being computed by whoever happens to want it. Three * reasons, in ascending order of how much they cost to work around: * * 1. `snapshot()` already takes a `GameState`, so every number here reaches a MULTIPLAYER client * through the `Frame` it is already being sent — no new server route, no new `Push` field, no * new `Session` method, and no second implementation that can disagree with the first. * 2. It is REPLAY-EXACT. A save is `{ seed, config, history }` replayed through the engine * (`web/game.ts`'s `fromSave`), so a tally folded from the events that replay emits is rebuilt * identically every time — which is what makes Undo and a server restart correct here for free. * 3. The official result freezes a COPY of this at the moment the timetable ran out (`official` * below), and a frozen copy has to be taken from something that already exists. * * Aggregate counts only. Nothing here is seat-secret — no card ids, no hands — which is why * `test/redaction.test.ts` stays green with the whole thing on the Frame. * * NOT SCORING. Nothing in here feeds a rule; it is read by the results screen and by the badge work * that Gitea#16 leaves to a second pass. Adding a counter is always safe. */ export type Tally = { /** §8.3 — a train that ran the length of the Division and left it. */ trainsCompleted: number; /** * Of those, how many did some switching between being made up and leaving. * * The join Gitea#16 asks for by name ("a player who completes an entire game where every train * that passed through did some switching on"). Counted as the train completes, against whether * that tray has coupled or dropped anything since it was made up — which is why `switchedSince` * below exists rather than this being derivable afterwards. */ trainsCompletedWithWork: number; /** §10 — trains lost to a collision, and the cars that went with them. */ trainsDestroyed: number; carsDestroyed: number; /** §6 — switching volume, both directions. */ carsCoupled: number; carsDropped: number; /** §9.1 — the MEN | AT | WORK pipeline: begun, and carried all the way through. */ loadsStarted: number; loadsCompleted: number; unloadsBegun: number; unloadsCompleted: number; /** §9.2 — passenger work. */ passengersBoarded: number; passengersDetrained: number; /** Colour, and the vocabulary the badge pass will draw on. */ flyingSwitches: number; officeUpgrades: number; dispatchBonusesUsed: number; facilitiesUnjammed: number; expediteFaults: number; trainsHeld: number; trainsDiverted: number; secondSections: number; extrasStarted: number; cardsDrawn: number; cardsPlayed: number; cardsDiscarded: number; clearancesRequested: number; /** §8.1 — rulings that let the other train through. A refusal is a ruling too, but not this one. */ clearancesAllowed: number; /** * X18 CIRCUS SET-UPS — a train that spent a Stage standing still and was paid for it (§X18). * * NOT "the longest an engine sat on a siding", which is what Gitea#16 asks for and what the * comment on that issue assumed this was. `trainStoodStill` is emitted ONCE IN A GAME PER SUCH * TRAIN — only for a train whose profile has `stopEarnsPoint`, and `advance.ts` sets * `stopPointClaimed` so it can never fire twice. There is no per-Stage "this train did not move" * signal in the engine at all, so a longest-stand streak cannot be folded from the event stream: * it needs an engine-side signal that does not exist yet. Recorded in `TODO.md` for the badge * pass rather than shipped as a statistic that would read "1 Stage" for ever. */ circusStops: { trainNumber: number; where: string }[]; /** * Train numbers that have coupled or dropped something since they were made up, for * `trainsCompletedWithWork`. Cleared when the train is made up and when it leaves the Division. */ switchedSince: number[]; /** Indexed by PLAYER. Only events that name a player reach these. */ byPlayer: PlayerTally[]; }; export type PlayerTally = { loads: number; unloads: number; passengersBoarded: number; passengersDetrained: number; cardsPlayed: number; /** §10 — collisions this player was faulted for, not collisions they were caught in. */ collisions: number; /** Revenue gained and Revenue lost, kept apart: the net is already on `players[i].revenue`. */ revenueGained: number; revenueLost: number; }; /** * THE OFFICIAL RESULT, frozen at the moment the timetable ran out (Gitea#11). * * "The winner is based upon the original game length. In a five-day game, even if it's extended to * eight or nine days, the winner and the official answer is the winner at the end of five days" * (Jesse, 2026-08-28). So this is written ONCE, at the first ending, and never overwritten — * including by a §3.4 collision breach during an extended Day, which ends play without touching it. * * `state.outcome` keeps moving: it is always the CURRENT evaluation, which is what the live game * wants. Once `official` exists, everything after it is informational. */ export type FinalReport = { /** The Day the game was scheduled to end on — always `config.days`. */ day: number; outcome: Outcome; /** Every player's Revenue at that moment, in player order. */ revenues: number[]; collisionsTotal: number; /** The Tally as it stood when the timetable ran out. */ tally: Tally; }; /** * Per-Stage transient bookkeeping for the acting player. Reset when the actor changes. * * §6 — the three Local Operations options are mutually exclusive: choosing one forecloses the * others for that Stage. That exclusivity lives here. */ export type TurnState = { option: 'switch' | 'draw' | 'freightAgent' | null; movesRemaining: number; /** * What `movesRemaining` started at this Stage — six, or five under Reduced Visibility at night. * * CARRIED RATHER THAN ASSUMED. Every reader of `movesRemaining` that wanted to say "3 of 6" had * hardcoded the 6, which is simply wrong on a night Stage, and the only other way to recover it is * to re-derive `movesForStage` outside the phase driver that owns it. It also makes "is this the * FIRST move of the turn?" a comparison rather than a guess, which is what the history panel needs * to keep the opening move of a switching turn and drop the ones in the middle. */ movesAllowed: number; /** * The last square this player's crew moved to this Stage, and which crew it was. * * Switching ends with a summary line, and "where did the train end up" is the half of it a player * actually wants. It cannot be recovered from the log: the line naming the last move is written * before anyone knows it was the last, and the log streams to clients as it is written, so a line * already sent cannot be revised afterwards. */ lastMove?: { trayId: TrayId; to: GridCoord }; drawnThisTurn: boolean; freightAgentUsed: boolean; /** * Trains 3/4 Express — "may drop or pick up ONE freight car at every location". * * Keyed `trayId@row,col`, counting freight cars that train has exchanged on that square this turn. * Per LOCATION rather than per turn, so the Express can work its way along a district a car at a * time — which is what makes it an Express rather than a train that may move one car a Stage. * Cleared with the rest of the turn. */ freightWorked: Record; /** Set when the actor finishes; the phase driver then moves to the next player. */ done: boolean; }; /** * One turn per player, created together at phase entry. * * This was a single `TurnState` on the game, replaced one player at a time as the cursor walked the * table. That is indistinguishable from this while only the player at the cursor may act — which is * exactly the case today, and every existing test still describes the same game. * * It is per-player now because making it so later would mean the same change PLUS reworking a client * built around "wait your turn". What it enables is Local Operations work that no other player can * observe — switching inside your own district — happening off-cursor, which is a change to * `isActor` and nothing else. See `docs/architecture/multiplayer.md` D19. */ export function freshTurns(players: number, moves: number): Map { const turns = new Map(); for (let p = 0; p < players; p++) turns.set(p, freshTurn(moves)); return turns; } /** A Tally with everything at zero — the state every game starts in (Gitea#16). */ export function emptyTally(players: number): Tally { return { trainsCompleted: 0, trainsCompletedWithWork: 0, trainsDestroyed: 0, carsDestroyed: 0, carsCoupled: 0, carsDropped: 0, loadsStarted: 0, loadsCompleted: 0, unloadsBegun: 0, unloadsCompleted: 0, passengersBoarded: 0, passengersDetrained: 0, flyingSwitches: 0, officeUpgrades: 0, dispatchBonusesUsed: 0, facilitiesUnjammed: 0, expediteFaults: 0, trainsHeld: 0, trainsDiverted: 0, secondSections: 0, extrasStarted: 0, cardsDrawn: 0, cardsPlayed: 0, cardsDiscarded: 0, clearancesRequested: 0, clearancesAllowed: 0, circusStops: [], switchedSince: [], byPlayer: Array.from({ length: players }, () => ({ loads: 0, unloads: 0, passengersBoarded: 0, passengersDetrained: 0, cardsPlayed: 0, collisions: 0, revenueGained: 0, revenueLost: 0, })), }; } /** * A deep copy, for freezing the official result (`FinalReport`). * * Written out rather than reached for via `structuredClone` because a Tally is a flat bag of numbers * with two containers in it, and spelling the copy out means a field added later that needs deep * copying is a compile error here rather than a shared reference discovered in a results screen. */ export function cloneTally(t: Tally): Tally { return { ...t, circusStops: t.circusStops.map((c) => ({ ...c })), switchedSince: [...t.switchedSince], byPlayer: t.byPlayer.map((p) => ({ ...p })), }; } /** * WHICH WAY TO DRAW THE ENGINE — east or west, for every train, everywhere. * * The single place the display facing is decided, so the Division map, the Office cards and the * tooltips can never disagree about which end of a train the engine is on. Three sources, in the * order they can be trusted: the carried east-west sense; the current port, when it happens to be * an east-west one (a tray placed straight onto the board has no history yet); and failing both, * the direction of the run. */ export function railFacingOf(tray: Pick): 'e' | 'w' { if (tray.railFacing) return tray.railFacing; if (tray.facing === 'e' || tray.facing === 'w') return tray.facing; return tray.direction === 'west' ? 'w' : 'e'; } /** * THE ONE CONVERSION BETWEEN A TRAY AND A TRACK, stated once and used by both directions. * * `consist` is ordered NOSE FIRST and the nose points `facing`; `standing` is ordered WEST TO EAST. * So for an east-facing train the tray reads east-to-west and has to be reversed to go onto the * card; for a west-facing train it already reads west-to-east and goes on as it stands. * * It is its own inverse, which is why lifting cars off a card uses the same function. */ export function trackOrder(cut: readonly RollingStock[], facing: 'e' | 'w'): RollingStock[] { return facing === 'e' ? [...cut].reverse() : [...cut]; } /** * The cars standing west and east of a train, split at `standingWest`. * * Clamped rather than trusted: a tray that has never dropped anything carries no `standingWest` at * all, and a saved game from before this field existed carries none either — both read as 0, which * puts every car on the card east of the engine. That is the same answer the engine gave before the * split existed, so an old save degrades to the old behaviour instead of throwing. */ export function standingSides( tray: { standingWest?: number | undefined }, cars: readonly RollingStock[], ): { west: RollingStock[]; east: RollingStock[] } { const k = Math.max(0, Math.min(cars.length, tray.standingWest ?? 0)); return { west: cars.slice(0, k), east: cars.slice(k) }; } /** * The cut a train would run into if it left this card by the `exit` END OF THE ROW — the cars * between it and that end. Returned in the order the train MEETS them, nearest first, which is what * `carsCoupled` wants. * * `exit` IS AN END OF THE ROW, NOT A PORT. It used to be a raw `Port`, and answered "you meet * nothing" for north and south on the reasoning that a leg leaving through an edge is not running * along the west-to-east row. It is: a `sw` curve's south leg IS the east end of that row, so a * crew standing on the curve pulled out through the leg and drove away leaving the cars beside it * standing, against §A.4's mandatory coupling (Gitea#17). Callers resolve the leg with `rowEndAt` * (`track.ts`), which lives there because only the card's arc can say which end a leg is — and the * narrowed type is what makes every caller do it. */ export function cutTowards( tray: { standingWest?: number | undefined }, cars: readonly RollingStock[], exit: 'e' | 'w', ): RollingStock[] { const { west, east } = standingSides(tray, cars); return exit === 'e' ? east : [...west].reverse(); } export function turnOf(s: GameState, player: PlayerIndex): TurnState { const t = s.turns.get(player); if (!t) throw new Error(`no turn state for player ${player}`); return t; } /** * §6.2 — IS THIS PLAYER HOLDING MORE THAN THEY MAY? Three cards, or four while a Red Flag is held. * * ONE answer, because there were three of them: `check('draw.end')` refused on it, `snapshot()` * recomputed it inline for the Frame, and `web/game.ts` kept a third for the page. All three agreed * — which is the state a disagreement starts from, and #96 is what that costs when the two halves * are a screen and the server that refuses what the screen offered. */ export function overHandLimit(s: GameState, player: PlayerIndex): boolean { const hand = s.decks.hands.get(player) ?? []; return hand.length > (s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT); } export function freshTurn(moves: number): TurnState { return { option: null, movesRemaining: moves, movesAllowed: moves, drawnThisTurn: false, freightAgentUsed: false, freightWorked: {}, done: false, }; } export type GameState = { id: string; config: GameConfig; /** All RNG derives from this. Games are exactly replayable. */ seed: number; rngState: number; players: Player[]; /** * Who is sitting where: `seating[seat] = player`, seat 0 at the WESTERN end of the chain. * * Decided at setup by §4.4's D12 (`openingRolls.division`) — a real permutation, not the identity, * except in solitaire where one player means one seat. Employee Rotation would rotate this array * and nothing else. */ seating: PlayerIndex[]; /** * The two opening D12s, per player, kept so a client can show the rolls rather than only their * outcome — it is the game's first moment of drama (`lobby-and-sessions.md` §4). Indexed by * PLAYER, since that is who rolls. */ openingRolls: { division: number[]; superintendent: number[] }; division: Division; officeAreas: Map; trays: Map; /** Trays not yet in play; §7 scarcity is an explicit mechanic. */ freeTrays: TrayId[]; cards: Map; decks: Decks; yards: Yards; /** Index 0 = Stage 1. A train number, or null for an empty slot. */ timetable: (number | null)[]; /** * §7 — Extra Trains played from hand, waiting for a free Crew Tray. An Extra is not scheduled: * it runs once, immediately, then its card goes to the Salvage Yard (§2.3). * * CARRIES WHO PLAYED IT (2026-08-23, Jesse's call). §7 gives an Extra to the player who played the * card — "may place the Crew Tray at either Division Point ... and may load the consist as he * chooses" — which is a different rule from the Timetabled make-up round, where the table goes * round starting at the Superintendent. This was a bare `number[]`, so the engine could not tell * whose Extra it was and asked whoever the acting order happened to be on: correct in solitaire, * where there is only one player, and wrong at every table. */ pendingExtras: { trainNumber: number; player: PlayerIndex }[]; /** * Q9 — train numbers ordered to run a second section. The next New Train Phase makes up an * identical train behind the first, if a Crew Tray is free. */ pendingSecondSections: number[]; clock: Clock; /** One per player, keyed by PLAYER (a turn belongs to a person, not to a chair). */ turns: Map; /** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */ movedThisPhase: Set; /** §3.4 — resets at the start of each Day; checked against `config.maxCollisionsPerDay`. */ collisionsToday: number; /** * What `collisionsToday` held for the Day that just ENDED — captured at the rollover, immediately * before the reset. * * The Day-end dialog exists to report the Day that finished, and it is drawn from the frame AFTER * the rollover, because that is the frame whose `day` went up. So it read `collisionsToday` as 0 no * matter what had happened: Jesse, 2026-09-09, at the end of a Day 1 with two collisions in it — * "it shows a total of two collisions, but zero today ... that does seem to be a contradiction". * * NOT DERIVABLE ON THE CLIENT. A Day turns over inside the phases that run themselves, so in * multiplayer the push that reports the new Day is the same push that reports the reset — a client * may never see the ended Day's final count to remember it. */ collisionsPrevDay: number; /** §3.4 — never reset; checked against `config.maxCollisionsTotal`. */ collisionsTotal: number; /** * `awaitingExtension` is Gitea#11: the timetable has run out, the result is recorded, and the * table is being asked whether to play one more Day. It is a PAUSE, not an ending — `advance` * reports `needsInput` there, the server resumes it like any live game, and the only intent the * rules will accept is `game.extend`. */ status: 'setup' | 'active' | 'awaitingExtension' | 'finished'; /** The CURRENT evaluation, re-decided at the end of every Day including extended ones. */ outcome: Outcome | null; /** * §3.3 (Gitea#11) — Days granted beyond `config.days`, one vote at a time. * * `config.days` is deliberately never touched: it is what the official result was decided at, so * leaving it alone is what makes "the winner is decided at the original game length" a fact about * the code rather than a comment on it. */ extraDays: number; /** * Per PLAYER, while `awaitingExtension`. `null` means they have not voted yet. * * Unanimous, and one refusal is decisive: nobody is made to wait on a player who has already said * no (Jesse's call, 2026-08-28). Solitaire is the same code with one voter. */ extensionVotes: (boolean | null)[]; /** Frozen at the FIRST ending and never overwritten. See `FinalReport`. */ official: FinalReport | null; /** Gitea#16. Folded from the event stream; see `Tally`. */ tally: Tally; }; // --------------------------------------------------------------------------- // Derivations — never stored, never cached // --------------------------------------------------------------------------- /** * A Subdivision is the Mainline track between the Limits of opposing Control Points (§2.1). * Whistle Posts are NOT Control Points and sit inside a Subdivision like ordinary mainline. * * At game start every Office is a Whistle Post, so the entire railroad is ONE Subdivision (§8) — * which is why early traffic is so constrained. Each Office upgrade splits one in two. * * Recomputed on demand. Caching this means every Office upgrade must remember to invalidate, * and forgetting is a silent bug in highball legality. */ export function subdivisions(state: GameState): number[][] { const out: number[][] = []; let current: number[] = []; state.division.nodes.forEach((node, i) => { const isBoundary = node.kind === 'divisionPoint' || (node.kind === 'office' && isControlPoint(state, node.seat)); if (isBoundary) { if (current.length > 0) out.push(current); current = []; } else { current.push(i); } }); if (current.length > 0) out.push(current); return out; } /** Who is sitting at this seat. */ export function playerAtSeat(state: GameState, seat: SeatIndex): PlayerIndex { const p = state.seating[seat]; if (p === undefined) throw new Error(`no player at seat ${seat}`); return p; } /** This seat's node on the Division — where its Limits, and any Red Flag on them, live. */ export function officeNodeFor( state: GameState, seat: SeatIndex, ): Extract | null { for (const n of state.division.nodes) if (n.kind === 'office' && n.seat === seat) return n; return null; } /** * WHO MUST ANSWER the interruption, or null when nothing is pending. * * The one place that knows which player each kind of question goes to. §8.1's clearance is the * Superintendent's ruling wherever it happens; the Yard Office is offered to whoever sits in the * district the train is arriving at, because it is their card and their yard. */ export function decisionActor(state: GameState): PlayerIndex | null { const d = state.clock.pendingDecision; if (!d) return null; return d.kind === 'clearance' ? state.clock.superintendent : playerAtSeat(state, d.seat); } /** * WHOSE MOVE IT IS RIGHT NOW — a pending interruption's owner if there is one, else the phase's * own actor. * * Written out six times across the engine, the sim, the web client and the tests as * `pendingDecision !== null ? superintendent : currentActor`, which stopped being right the moment * a second kind of question existed. One copy now, so a new decision kind cannot be half-adopted. */ export function actingPlayer(state: GameState): PlayerIndex | null { return decisionActor(state) ?? state.clock.currentActor; } /** Where this player is sitting, and therefore which Office Area is theirs. */ /** * The player `n` seats to the LEFT of this one, wrapping round the table. * * Acting order, the deal and the Fedora are all "starting here and proceeding left" (Gap 1, §4.7, * §5), which is a statement about the physical chain of Offices — so it is seat arithmetic, not * player arithmetic. All three used to do `(player + n) % players`, which was the same thing only * while seating was the identity mapping. It stopped being that when §4.4's D12 started deciding * who sits where. * * "Left" is increasing seat index, i.e. eastward along the chain, matching what the shift-change * tests have always asserted. */ export function playerLeftOf(state: GameState, player: PlayerIndex, n = 1): PlayerIndex { return playerAtSeat(state, (seatOf(state, player) + n) % state.seating.length); } export function seatOf(state: GameState, player: PlayerIndex): SeatIndex { const seat = state.seating.indexOf(player); if (seat < 0) throw new Error(`player ${player} is not seated`); return seat; } export function isControlPoint(state: GameState, seat: SeatIndex): boolean { const area = state.officeAreas.get(seat); if (!area) throw new Error(`no Office Area at seat ${seat}`); return officeProfile(area.tier).isControlPoint; } export function adTrackCount(state: GameState, seat: SeatIndex): number { const area = state.officeAreas.get(seat); if (!area) throw new Error(`no Office Area at seat ${seat}`); return officeProfile(area.tier).adTracks; } export function totalRevenue(state: GameState): number { return state.players.reduce((n, p) => n + p.revenue, 0); }