Files
station-master/docs/architecture/game-state.md
T
2026-07-31 07:19:57 -04:00

16 KiB
Raw Blame History

Game State Model

The entity model for one game of Station Master, derived from ../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<GridCoord, TrackCard>      -- 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 dynamicisOperationalRail 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, 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 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<PlayerIndex, CardId[]>                 -- private, max 3 (4 with Red Flag)
  redFlags     : Map<PlayerIndex, boolean>                  -- 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.