/** * 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, 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. * * 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 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[]; }; // --------------------------------------------------------------------------- // 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. */ 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 (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 } | { 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; /** * 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; }; }; 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; }; 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[]; 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); }