# Game State Model The entity model for one game of Station Master, derived from [`../rules/rules-v0.2.md`](../rules/rules-v0.2.md). This is the rules engine's world — pure data, no I/O. Written as pseudo-structure. Field names are illustrative; the shapes and constraints are the point. --- ## 1. The game ``` Game id config : GameConfig seed : integer -- all RNG derives from this; games are replayable players : Player[] -- index order IS seating order, west → east division : Division decks : Decks yards : Yards timetable : (TrainCardId | null)[12] -- index 0 = Stage 1 clock : Clock collisionsToday : integer -- §3.4, resets at the start of each Day status : setup | active | finished outcome : Outcome | null ``` ``` GameConfig mode : solitaire | competitive | coop victory : firstToTarget | highestAfterDays length : short | standard | campaign optionalRules : { reducedVisibility, sisterTrains, employeeRotation, emergencyToolbox } ``` Targets and Day counts derive from `length` (§3.2): short 15/3, standard 30/5, campaign 60/10. Co-op multiplies the target by player count. Do not store the derived numbers — compute them, so a config change cannot leave a stale target behind. ``` Clock day : integer, from 1 stage : 1..12 -- the Pocket Watch phase : localOps | newTrain | mainline | loadUnload | shiftChange currentActor : PlayerIndex | null pendingDecision : SuperintendentClearance | null -- §8.1, during mainline only superintendent : PlayerIndex ``` **`currentActor` is the whole turn machine.** After Gap 1, exactly one player may act at any moment. Every other client is spectating. Deriving this rather than storing it is tempting but wrong — the acting order starts at the Superintendent and proceeds left, and that traversal needs an explicit cursor. **The Mainline Phase is automatic but not input-free.** §8.1's fourth condition hands the Superintendent a judgment call: when a train would depart into a Subdivision already occupied by a train moving *the same direction*, he decides whether it is safe to follow. Movement otherwise proceeds with no input. ``` SuperintendentClearance train : CrewTrayId -- the train awaiting clearance followingInto: SubdivisionRef occupiedBy : CrewTrayId -- the train ahead, moving the same direction ``` This is the game's central tension and must be modelled, not optimised away. Gap 2 made the *consequences* automatic — collisions resolve with no die roll — precisely so that this decision carries full weight. The Superintendent who clears a following train into an occupied Subdivision owns the wreck (§10), and in Competitive mode three wrecks in a Day ends the game for everyone (§3.4). Note the asymmetry with §8.1's third condition: a train in the next Subdivision moving *towards* the considered train is an absolute bar — no decision, the train simply does not depart. Only the same-direction case is discretionary. Shift change fires at Stages 3, 6, 9 and 12 (§5), passing the Fedora one seat left. --- ## 2. The Division The Division is an **ordered west-to-east sequence** of nodes. For N players there are N Office Areas and N+1 Mainline cards (§4.3), with a Division Point beyond each end. ``` Division nodes : DivisionNode[] -- ordered west → east DivisionNode = DivisionPoint { side: west | east, holding: CrewTrayId[] } | MainlineCard { regions: [Region, Region] } -- 2 regions (§2.1, Gap 4b) | OfficeArea { ...see §3 } Region occupant : CrewTrayId | null ``` A concrete 3-player Division: ``` DP(W) ML Office₀ ML Office₁ ML Office₂ ML DP(E) └── 2 regions each ──┘ ``` ### Subdivisions are derived, never stored 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. ``` subdivisions(division) = split the node sequence at every Office whose type is a Control Point, and at both Division Points ``` 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 a Subdivision in two. Recompute this on demand. Caching it means every Office upgrade must remember to invalidate the cache, and forgetting is a silent, hard-to-find bug in highball legality. --- ## 3. The Office Area A **grid** of track cards, not a row (Gap 4a). The Running Track is the horizontal row through the Office; everything else is Secondary Track. ``` OfficeArea owner : PlayerIndex officeType : whistlePost | depot | station | terminal grid : Map -- sparse; cards are placed during play officeCoord : GridCoord -- where the Office card sits runningRow : integer -- the grid row that is the Running Track limitsWest : GridCoord -- moves outward as the Running Track grows limitsEast : GridCoord adOccupancy : CrewTrayId[] -- length ≤ adTrackCount(officeType) ``` ``` adTrackCount: whistlePost 1 | depot 2 | station 3 | terminal 4 -- §11.1 isControlPoint = officeType != whistlePost isPassengerFacility = officeType != whistlePost ``` **Office cards are geometrically interchangeable** (§11.3). All four tiers carry the same track footprint — a through track plus a plain junction stub above and below — and differ *only* in the three properties above. An upgrade therefore changes `officeType` and nothing else; it must never touch `grid`, `connections`, or anything attached to the Office card. The junction stubs carry no directional restriction: §A.1's turnout rule governs drawn turnout cards only. ``` TrackCard geometry : straight | turnout | runAround | officeCard | facilityCard isOperationalRail : boolean -- the wheel icon; turnouts are false (§A.1) connections : Direction[] -- which edges have rails facility : Facility | null -- facility cards carry their own track (Gap 4a) standing : RollingStock[] -- uncoupled cars left here, in track order ``` ### Constraints that are easy to lose These are the ones that will be got wrong if they are not written down explicitly: 1. **A turnout's two legs are not joined to each other.** Entering from the stem (§A.1's "A") permits either leg; entering from a leg permits only the stem. This is an **absent edge, not a one-way edge** — A→B and B→A are both legal. Modelling it as a directed graph forbids legal moves. The card's internal connections are `stem-through` and `stem-diverge`, and `through-diverge` simply does not exist. Separately, a traversal may never leave a card by the port it entered through; that is what "without changing direction" means in practice. 2. **A turnout is not Operational Rail.** A train may pass through but not stop (§A.1). 3. **A Move never changes direction.** One Move is travel from one Operational Rail card to another, any distance, no reversal (§2.4, §A). Reversing costs a second Move. 4. **A Facility track locked by loads stops being Operational Rail.** While any load sits on `MEN | AT | WORK`, no car may be picked up or dropped off there and no train may occupy or move on it (§9.3). This is *dynamic* — `isOperationalRail` for a facility card is a function of facility state, not a constant. 5. **Coupling is mandatory during Local Ops.** Moving a Crew Tray into standing cars picks them up; you may not go around them (§A.4). 6. **Coupling is fatal during the Mainline Phase.** The same encounter is a collision (§A.4, §8.3). 7. **The Office track accepts no drop-offs** despite being Operational Rail (§A.4). 8. **Two trains may not share a card** or pass through each other — except the Office track, which admits as many trains as it has free A/D tracks (§A.4). 9. **An Office upgrade must preserve every connection** (§11.3). All four tiers share identical geometry precisely so the upgrade is a property change, not a card swap. An implementation that models the upgrade as "remove old card, place new card" will silently orphan any Secondary Track hanging off the Office — mutate `officeType` in place instead. 10. **The Office card's stubs are not turnouts.** Do not route them through the §A.1 directional logic; a train may pass between the Running Track and either Secondary row freely. --- ## 4. Trains and Rolling Stock ``` CrewTray id trainCard : TrainCardId | null -- null while switching a local crew engineFront : boolean -- which end the engine occupies (§A.3) consist : RollingStock[] -- ORDERED, left-to-right, max 4 direction : east | west position : NodeRef -- division point, mainline region, or grid coord movesUsed : integer -- within the current Local Ops Phase ``` ``` RollingStock type : coach | boxcar | reefer | hopper | tank | caboose loaded : boolean -- coloured = loaded, white = empty (§2.2) ``` **Consist order is load-bearing and must never be modelled as a set.** Cars come off in the order they are seated in the tray (§A.3), and §8.1 blocks a highball unless the consist matches the order listed on the train card. An unordered collection makes both rules unimplementable. **The four-slot limit includes the caboose** (§A.4, Gap 4c). `consist.length ≤ 4`, cabooses counted. A train requiring a caboose therefore carries at most three revenue cars — the single most likely off-by-one in the whole model. ``` TrainCard number : 1..12 isExtra : boolean direction : east | west | playerChoice -- Extras are head-on; the player picks (§2.3) consistSpec : { count, allowedTypes[], requiresCaboose } class : limited | mailExpress | manifest | coalDrag | oilTrain | wayFreight | extra ``` Mainline movement order is `sort by (number, isExtra)` ascending — Timetabled before Extra on a tie (§8, Gap 5). **Train make-up is a loop, not a single pass** (§7, Gap 9). Cycle Superintendent-then-left, one car per player per pass, until either the consist reaches `consistSpec.count` or no suitable car remains in the Division Yard. A single pass would make consist length a function of player count. The loop also resolves the 5+ player case for free: it ends the moment the consist fills, mid-pass if need be, leaving no player holding a car they cannot place. --- ## 5. Facilities ``` Facility kind : passenger | freight subtype : mineTipple | produceShed | grocersWarehouse | oilRefinery | powerPlant | office allows : { outbound: boolean, inbound: boolean } -- some freight are one-way (§9) outboundBox : RollingStock[] -- green, to-be-loaded inboundBox : RollingStock[] -- red, just-unloaded capacity : { outbound, inbound, combined? } -- combined when the number touches both boxes (§9.1) menAtWork : [Load|null, Load|null, Load|null] -- freight only; one load per box industryTrack : { length: integer, cars: RollingStock[] } -- 3 or 4 per card (§12.5); ≤4 (§9.3) laborers : integer porters : integer usedThisStage : { laborers: integer, porters: integer } -- resets each Stage (§9.1) modifiers : ModifierRef[] -- adjacent cards raising capacity, track length or workers ``` All of these are populated from [`../rules/card-reference.md`](../rules/card-reference.md), which is the authoritative per-card catalogue. **`industryTrack` is where cars are spotted for loading and unloading**, and it is the field that stops being Operational Rail while `menAtWork` holds any load (constraint 4 below). Its `length` is 3 or 4 depending on the facility and may be raised by a Team Track modifier. **A Modifier serves only one Facility per Stage** even when adjacent to two (§9). Track which one claimed it this Stage. **A freight load costs four Laborer-actions for one Revenue point** — Green → MEN → AT → WORK → onto the car (§9.3). A Porter earns a point in one action. This asymmetry is deliberate; do not "fix" it. **Neither is the binding constraint, though.** Each player gets exactly one Local Operations action per Stage, and stocking a green box or clearing a red one consumes the whole of it (§6.3). That one-action-per-Stage budget is what actually limits Revenue — roughly one point per action, ceiling 12 per Day. Worker counts mostly determine how often a facility idles. See [`../rules/card-reference.md`](../rules/card-reference.md#7-economy-summary) for the full model; it is what §3's targets are calibrated against. --- ## 6. Decks and Yards ``` Decks homeOffice : CardId[] -- face down, order is secret departments : [CardId|null, CardId|null, CardId|null] -- face-up market slots (§2.6) salvageYard : CardId[] -- face up hands : Map -- private, max 3 (4 with Red Flag) redFlags : Map -- separate supply; removed when played ``` The Departments are **slots fed from the one deck**, not decks with their own contents (§2.6, Gap 4a). Reshuffle sweeps the Salvage Yard *and* all three Department slots back into the Home Office deck (§6.2). ``` Yards divisionYard : RollingStock[] -- the supply pile classificationYard: RollingStock[] -- used stock ``` When the Division Yard empties, everything in the Classification Yard moves back to it (§2.2). Used engines and cabooses return **directly** to the Division Yard, skipping the Classification Yard. --- ## 7. Players and scoring ``` Player index : integer -- seating position, fixed unless Employee Rotation is on name revenue : integer -- may go negative; collisions cost 5 (§10) officeArea : OfficeAreaRef ``` Revenue sources are exactly two: **+1** per completed load/unload operation (§9.2, §9.3) and **−5** per collision charged to that player (§10). There are no others. ``` Outcome result : win | loss winner : PlayerIndex | null -- null for co-op, solitaire, and shared losses reason : targetReached | daysElapsed | collisionFloor | revenueFloor ``` Termination checks, in the order they must be evaluated: 1. **Collision floor** (Competitive only) — `collisionsToday >= 3` ends the game immediately, all players lose (§3.4). Checked the moment a collision resolves, not at end of Stage. 2. **Target reached** (firstToTarget) — checked whenever Revenue increases. 3. **Days elapsed** (highestAfterDays) — at the end of the final Day, apply the collective Revenue floor `3 × players × Days` for Competitive (§3.5), or the mode target for Solitaire and Co-op (§3.3). Below the floor, everyone loses regardless of score. --- ## 8. What is derived, not stored Getting this list wrong produces stale-state bugs that are miserable to find: | Derived | From | | --- | --- | | Subdivisions | Office types along the Division | | Whether a train may highball | Subdivision occupancy + consist order + A/D availability (§8.1) — except the same-direction case, which requires the Superintendent's decision | | Whether a collision occurs | Board state at the moment of movement (§8.3) — pure, no RNG | | Legal Moves for a Crew Tray | Grid connectivity, turnout direction, Operational Rail, occupancy | | Facility track's Operational Rail status | Whether `menAtWork` holds any load | | Revenue targets and floors | `config.length`, `config.mode`, player count | | Whose turn it is | `clock.currentActor` — the one exception; explicitly stored (§1) | --- ## 9. Notes for whoever implements this - **Seed everything.** One seed per game, all shuffles and D12 rolls drawn from it. This makes a reported bug reproducible, which for a rules engine this size is worth more than it costs. - **Model the grid sparsely.** Office Areas start at three cards and grow unevenly; a fixed 2-D array will either waste space or need resizing at exactly the wrong moment. - **Validate consist order on every mutation**, not just at highball time. A consist that silently reorders during a switching move produces a train that can never legally depart, with no clue as to when it broke. - **Treat `isOperationalRail` as a function, not a field**, for facility cards — see constraint 4.