Initial commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user