# 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<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 *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<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.
