Initial commit

This commit is contained in:
Jesse
2026-07-31 07:19:57 -04:00
commit e7bc07df85
39 changed files with 12438 additions and 0 deletions
+365
View File
@@ -0,0 +1,365 @@
# 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.