/** * 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, GameLength, HouseRuleOverrides, ModifierKind, OfficeTier, TrackGeometry, } from './content.ts'; import { 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 }; // --------------------------------------------------------------------------- // 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 * `CrewTray.standingWest` records where a train standing here sits among them. */ standing: RollingStock[]; 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; /** * 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. */ 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. */ export type SuperintendentClearance = { train: TrayId; occupiedBy: TrayId; }; 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 the Superintendent (§8.1). */ pendingDecision: SuperintendentClearance | null; /** * The Superintendent's answer, waiting to be consumed by the train that asked. Without this the * driver would re-evaluate the same train and ask the same question forever. */ clearanceRuling: { train: TrayId; allow: boolean } | 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'; export type VictoryCondition = 'firstToTarget' | 'highestAfterDays'; export type GameConfig = { mode: GameMode; victory: VictoryCondition; length: GameLength; optionalRules: { reducedVisibility: boolean; sisterTrains: 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 = | 'targetReached' | 'daysElapsed' | 'collisionFloor' | 'revenueFloor'; export type Outcome = { result: 'win' | 'loss'; winner: PlayerIndex | null; reason: OutcomeReason; }; /** * 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; 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; } /** * 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 through `exit` — the cars between it and that * end of the card. * * Only 'e' and 'w' can hold a cut: the array is a west-to-east row, so a train leaving north or * south off a curve or a spur is not running along it and meets nothing. Returned in the order the * train MEETS them, nearest first, which is what `carsCoupled` wants. */ export function cutTowards( tray: { standingWest?: number | undefined }, cars: readonly RollingStock[], exit: 'n' | 's' | 'e' | 'w', ): RollingStock[] { const { west, east } = standingSides(tray, cars); if (exit === 'e') return east; if (exit === 'w') return [...west].reverse(); return []; } 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; } export function freshTurn(moves: number): TurnState { return { option: null, movesRemaining: 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). */ pendingExtras: number[]; /** * 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. */ collisionsToday: number; status: 'setup' | 'active' | 'finished'; outcome: Outcome | null; }; // --------------------------------------------------------------------------- // 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; } /** 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); }