Seven rules faults and one dealing fault, from a four-way code audit (engine, server, client, tests) read against the code before anything was acted on. Each is pinned by a test that failed first. CHANGELOG has the reasoning; this is the list. THE DEAL. 0.8.2 put the Second Section card into the deck after its save check had run and without a line in its notes. A deck one card larger shuffles differently from the same seed, so every save on the test server refused at move 3 — the boot log shows thirteen of thirteen — while the release notes said three would resume. `withSavedDeal` (was `withSavedOpening`) now sets `secondSectionCard: false` for a config that predates the setting, and the thirteen replay exactly as 0.8.2 described: three resume, ten refuse, the same ten at the same moves. THE RULES. `check` never tested that a switching tray was in the actor's own district, so a rival's train could be shunted and the rival charged the Moves. Occupancy matched on coordinates alone, so a rival's crew blocked your track. A Department draw that emptied the deck duplicated the drawn card and destroyed the refill card. The unjam cleared the first load rather than the one named. The collision floor could not fire in Stage 12. The Expedite fault was charged once per clearance question rather than once per phase. A train held at the Limits was only ever released by another arrival, never by a departure. Docs: rules.md describes each as built (and no longer says an Expedited train departs at Shift Change — that was v0.4.8's reading, corrected in v0.4.9's code and never in the document); game-state.md's collision-floor note now matches the code. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
368 lines
17 KiB
Markdown
368 lines
17 KiB
Markdown
# 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** (every mode) — `collisionsToday >= maxCollisionsPerDay` or
|
||
`collisionsTotal >= maxCollisionsTotal` ends the game, all players lose (§3.4). Judged at the
|
||
Shift Change that closes the Stage, on the Stage just played and before the Day rolls over — so
|
||
Load/Unload still scores in the Stage of the breach, and a breach in Stage 12 counts.
|
||
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.
|