/** * 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, MainlineKind, FreightKind, GameLength, ModifierKind, OfficeTier, TrackGeometry, } from './content.ts'; import { officeProfile } from './content.ts'; // --------------------------------------------------------------------------- // Identifiers // --------------------------------------------------------------------------- export type PlayerIndex = 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. * * OPEN (Gap 11) — the rules specify "turnout ×6" without saying how many face which way. Until * that is settled, orientation is carried per card and defaults to stem-east / diverge-north. */ export type TurnoutOrientation = { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' }; /** * A straight card's axis. Also part of Gap 11 — the catalogue says "straight ×3" without saying * how many run each way, yet a layout with no north-south straight can never use the Office's * junction stubs at all. */ export type TrackAxis = 'ew' | 'ns'; export type CardGeometry = | { kind: 'track'; geometry: TrackGeometry; turnout?: TurnoutOrientation; axis?: TrackAxis; /** Curves are printed left- or right-handed (design supply); not chosen on placement. */ hand?: 'left' | 'right'; } | { kind: 'office' } | { kind: 'limits' } | { kind: 'facility'; facility: FreightKind; axis?: TrackAxis } /** 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 here, in track order (§A.3). */ standing: RollingStock[]; facility: Facility | null; modifiers: ModifierKind[]; /** Enhancement cards laid on this card (§7 of implications.md). */ enhancements: string[]; }; export type OfficeArea = { owner: PlayerIndex; 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[]; /** * The player's personal track supply (26 pieces), keyed `geometry:hand`. Track is NOT in the * Home Office deck — it is laid from here. */ trackSupply: Map; }; // --------------------------------------------------------------------------- // 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. One load per box; three boxes, so it is a pipeline (§9.1). */ menAtWork: [Load | null, Load | null, Load | null]; /** Where cars are spotted for loading and unloading. */ industryTrack: { length: number; 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. * * 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. */ export function carsOn(card: TrackCard): RollingStock[] { return card.facility && card.facility.industryTrack.length > 0 ? card.facility.industryTrack.cars : card.standing; } /** How many more cars this card can hold. Ordinary track is unbounded; an industry track is not. */ export function spaceOn(card: TrackCard): number { if (card.facility && card.facility.industryTrack.length > 0) { return card.facility.industryTrack.length - card.facility.industryTrack.cars.length; } return Number.MAX_SAFE_INTEGER; } export function isOperationalRail(card: TrackCard): boolean { if (!card.baseOperationalRail) return false; if (card.facility && card.facility.menAtWork.some((slot) => slot !== null)) return false; return true; } // --------------------------------------------------------------------------- // Trains // --------------------------------------------------------------------------- export type NodeRef = | { at: 'divisionPoint'; side: Direction } | { at: 'mainline'; index: number } | { at: 'grid'; owner: PlayerIndex; coord: GridCoord }; export type CrewTray = { id: TrayId; /** null while a local crew is switching without a train card. */ trainNumber: number | null; trainIsExtra: boolean; /** Which end the engine occupies (§A.3). */ engineFront: boolean; /** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */ consist: RollingStock[]; direction: Direction; position: NodeRef; movesUsed: number; }; // --------------------------------------------------------------------------- // The Division // --------------------------------------------------------------------------- /** * A train part-way across a Mainline card. Crossing time is measured in STAGES (Q1/Q2), so the * regions the placeholder used are gone — the cells printed on the cards are decoration. */ export type Transit = { tray: TrayId; stagesRemaining: number; direction: Direction }; export type DivisionNode = | { kind: 'divisionPoint'; side: Direction; holding: TrayId[] } | { kind: 'mainline'; card: MainlineKind; transits: Transit[]; absSignals?: boolean; /** Brakeman / Airbrakes / Helpers / Realignment laid on this card. */ modifiers?: string[]; /** Red Flags protecting a stopped train here, by tray. */ redFlagged?: TrayId[]; } | { kind: 'office'; owner: PlayerIndex }; /** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */ export type Division = { nodes: DivisionNode[] }; // --------------------------------------------------------------------------- // Decks and yards // --------------------------------------------------------------------------- export type CardKind = | { kind: 'timetabledTrain'; number: number } | { kind: 'extraTrain'; number: number } | { kind: 'office'; tier: OfficeTier } | { kind: 'freightFacility'; facility: FreightKind } | { kind: 'modifier'; modifier: ModifierKind } | { kind: 'track'; geometry: TrackGeometry } /** * The four categories recovered from the design. They are in the deck so the composition is * right; their BEHAVIOUR is not implemented yet (implications.md §10 is still open), so * `check` rejects playing them and they can only be discarded. */ | { kind: 'spaceUse'; key: string } | { kind: 'enhancement'; key: string } | { kind: 'mainlineModifier'; key: string } | { kind: 'maneuver'; key: string } | { kind: 'action'; key: string }; export type Card = { id: CardId; kind: CardKind }; export type Decks = { /** Face down. Order is SECRET — never projected to any client. */ homeOffice: CardId[]; /** Three face-up market slots fed from the deck (§2.6). */ departments: (CardId | null)[]; /** Face up, so players can audit discards (§2.6). */ salvageYard: CardId[]; /** Private to the owner. Max 3, or 4 with a Red Flag. */ hands: Map; 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; }; }; 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; /** * Track was a CARD before it became a per-player supply, so it was naturally limited to one play * a turn. The supply has 26 pieces and nothing else bounds it, so keep that limit explicitly. */ laidThisTurn: boolean; freightAgentUsed: boolean; /** Set when the actor finishes; the phase driver then moves to the next player. */ done: boolean; }; export function freshTurn(moves: number): TurnState { return { option: null, movesRemaining: moves, drawnThisTurn: false, laidThisTurn: false, freightAgentUsed: false, done: false, }; } export type GameState = { id: string; config: GameConfig; /** All RNG derives from this. Games are exactly replayable. */ seed: number; rngState: number; players: Player[]; 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; turn: TurnState; /** 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.owner)); if (isBoundary) { if (current.length > 0) out.push(current); current = []; } else { current.push(i); } }); if (current.length > 0) out.push(current); return out; } export function isControlPoint(state: GameState, owner: PlayerIndex): boolean { const area = state.officeAreas.get(owner); if (!area) throw new Error(`no Office Area for player ${owner}`); return officeProfile(area.tier).isControlPoint; } export function adTrackCount(state: GameState, owner: PlayerIndex): number { const area = state.officeAreas.get(owner); if (!area) throw new Error(`no Office Area for player ${owner}`); return officeProfile(area.tier).adTracks; } export function totalRevenue(state: GameState): number { return state.players.reduce((n, p) => n + p.revenue, 0); }