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
+437
View File
@@ -0,0 +1,437 @@
# Component Inventory & Project Sizing
The buildable pieces: what code must be written, where each runs, when each runs, and how big each
is. The five sibling documents describe the system's *concerns*; this one describes its *parts*.
Sizes are **indicative, not estimates of effort**. T-shirt size for MVP and for finished, a one-line
driver of that size, and a LOC band for comparison between components.
---
## 1. What the MVP is
**Solitaire. One Office. A fixed number of Days. A server talking to a single browser. The display
functional, not pretty.**
```
DP(W) [ML] [ Office ] [ML] DP(E)
```
Two Mainline cards, one Office Area, and the player is permanently the Superintendent. Trains enter
at a Division Point, cross a Mainline card, reach the Office, get switched and worked, highball, and
exit at the far end.
### What solitaire simplifies — and what it does not
**It removes players, not rules.** Every rule still runs with one player: the six-Move switching
game, mandatory coupling, consist ordering, the four-Laborer freight pipeline, highball conditions,
automatic collisions, the twelve-Stage clock, and the Superintendent's following-train clearance
decision (you are the Superintendent, ruling on your own trains).
| Removed at MVP | Still required at MVP |
| --- | --- |
| Lobby, matchmaking, game codes | Full rules engine |
| Hiding hands from opponents | Phase driver |
| Turn arbitration between players | Card catalogue |
| Presence, reconnection of others | Track graph and Move legality |
| Fedora rotation between players | Board and action UI |
| Competitive/Co-op victory modes | Event log and persistence |
| Collision floor (Competitive only) | Server and protocol |
**Consequence: the engine is the dominant cost and it is front-loaded.** Components 46 barely differ
between MVP and finished. Most of the MVP-to-finished delta lives in the server and client layers,
not in the rules.
---
## 2. Component inventory
Execution locations: **Engine** (pure, runs wherever it is hosted — server-side at MVP), **Server**,
**Browser**, **Dev** (developer machine or CI; ships nowhere).
### A. Engine core
Pure, no I/O, no clock, deterministic given a seed. See [`overview.md`](overview.md) — this boundary
is what makes the whole system testable without a server.
---
**1. Card catalogue / static content**
- **Where:** Engine · **When:** loaded once at startup, immutable thereafter
- **Does:** the 52-card deck composition, freight and passenger facility profiles, Modifier effects,
train consist specs, track geometries. Source: [`../rules/card-reference.md`](../rules/card-reference.md)
- **MVP: S** · **Final: S** · ~200 → ~350 LOC
- **Size driver:** it is data, not logic. Grows only if card variants are added.
- **Note:** every number here is provisional and will be retuned repeatedly. Keep it as data files,
not code, so tuning never requires touching the engine.
**2. State model and types**
- **Where:** Engine · **When:** always resident
- **Does:** the entity model from [`game-state.md`](game-state.md) — Division, Office Areas, Crew
Trays, Facilities, decks, yards, clock, Revenue.
- **MVP: M** · **Final: M** · ~400 → ~600 LOC
- **Size driver:** breadth of the model. Nearly all of it is needed even for one Office; the delta is
multi-player and multi-Office bookkeeping.
**3. Track graph and movement**
- **Where:** Engine · **When:** per Move, and per train step in the Mainline Phase
- **Does:** the Office grid as a port graph — turnouts whose two legs are not joined to each other
(§A.1), Operational Rail, the no-reversal rule within a Move, mandatory coupling, the Office card's
plain junction stubs, and dynamic loss of Operational Rail while `MEN | AT | WORK` is occupied.
- **MVP: L** · **Final: L** · ~500 → ~700 LOC · *built: 285 LOC*
- **Size driver:** which port pairs each card joins, and the rule that a traversal never leaves by
the port it entered. The constraints in `game-state.md` §3 all land here. Barely changes from MVP
to finished.
**4. Intent validation and application**
- **Where:** Engine · **When:** per submitted player intent
- **Does:** `apply(state, intent) → events | rejection`. Every legal player action from
[`protocol.md`](protocol.md) §1 — the three-way Local Ops choice, Moves, drops, draws, card plays,
Freight Agent operations, car placement, Porter and Laborer actions, clearance rulings.
- **MVP: L** · **Final: L** · ~800 → ~1000 LOC
- **Size driver:** the number of distinct legal actions, which solitaire does not reduce.
**5. Phase driver**
- **Where:** Engine · **When:** continuously between player inputs
- **Does:** `advance(state) → events | needsInput`. Runs the whole automatic side: Mainline movement
in `(number, isExtra)` order, highball evaluation, collision resolution, train make-up, Stage and
Day advance, Fedora rotation, per-Stage resource reset, victory checks. Pauses for the
Superintendent's clearance decision.
- **MVP: M** · **Final: L** · ~400 → ~800 LOC · *built: 430 LOC*
- **Size driver:** the Mainline Phase. Ordering, highball conditions and collision triggers are all
here. Grows with multiplayer turn arbitration and the Competitive collision floor.
- **Note:** carries a deliberate safety net — if an actor somehow has no legal action, the turn ends
rather than the game hanging. It should never fire, but a hung game is far harder to diagnose than
a forfeited turn.
- **Note:** this is the component most likely to be missed entirely when planning, because it is not
a feature anyone asks for — it is simply how the game moves.
**6. Legal-action enumeration**
- **Where:** Engine · **When:** whenever the client needs affordances — every state change
- **Does:** `legalActions(state, actor) → Intent[]`. Which grid squares a Crew Tray can reach, which
cars may be dropped, whether a Laborer has work available, which cards are playable where.
- **MVP: M** · **Final: M** · ~300 → ~450 LOC
- **Size driver:** it must mirror component 4's rules exactly. The temptation is to reimplement them;
the discipline is to share predicates with 4 so the two can never disagree.
**7. Seeded RNG**
- **Where:** Engine · **When:** deck shuffles, the 1D12 timetable roll, setup rolls
- **Does:** deterministic pseudo-random from one stored seed, so any game is exactly replayable.
- **MVP: S** · **Final: S** · ~50 → ~80 LOC
- **Size driver:** trivial, but must be threaded through the engine rather than called ambiently —
a single stray `Math.random()` destroys replay.
### B. Server
---
**8. Game session host**
- **Where:** Server · **When:** per active game, for the game's lifetime
- **Does:** owns one game's state, pumps `advance` until it needs input, routes intents to `apply`,
emits events, serializes access so two intents never interleave.
- **MVP: S** · **Final: M** · ~150 → ~400 LOC
- **Size driver:** at MVP one game and one player, so the pump loop is nearly all of it. Grows with
concurrent games and per-player routing.
**9. Event log and persistence**
- **Where:** Server · **When:** append per event; full read on startup
- **Does:** append-only log to disk; state is `fold(events)`. Powers restart recovery at MVP, and
later reconnection and the replay viewer.
- **MVP: S** · **Final: M** · ~100 → ~350 LOC
- **Size driver:** an append and a read-back. Grows with snapshotting, retention and indexing.
**10. View projection / redaction**
- **Where:** Server · **When:** per state change, per connected client
- **Does:** builds each player's permitted view. Public board, private hand, deck **count** visible
but deck **order** never sent.
- **MVP: S** · **Final: M** · ~80 → ~300 LOC
- **Size driver:** nearly free at MVP — one player, so the only secret is deck order. Grows sharply
once opponents' hands must be hidden, and it is the trust boundary, so it must be exactly right.
**11. Transport**
- **Where:** Server · **When:** per connection; pushes on every event
- **Does:** HTTP for lobby operations, WebSocket for the ordered event stream, static asset serving.
Bidirectional — most traffic is server → client.
- **MVP: S** · **Final: M** · ~150 → ~400 LOC
- **Size driver:** small at MVP because one client needs no fan-out. Grows with broadcast, backpressure
and reconnect-with-replay.
**12. Lobby and session lifecycle**
- **Where:** Server · **When:** before a game starts; on reconnect
- **Does:** create/join by game code, seating order (which sets adjacency *and* Superintendent
rotation), config lock at start, session tokens, reconnection.
- **MVP: XS** · **Final: M** · ~50 → ~450 LOC
- **Size driver:** essentially absent at MVP — one player, one game, start immediately. This is the
single largest MVP-to-finished delta in the system.
**20. Process bootstrap and configuration**
- **Where:** Server · **When:** once, at process start
- **Does:** reads environment configuration (bind address, port, data directory), opens or creates the
event log, constructs the session host and transport, wires them together, handles shutdown.
- **MVP: XS** · **Final: S** · ~80 → ~200 LOC
- **Size driver:** trivial in itself, but it is where
[`deployment.md`](deployment.md)'s five portability rules are actually enforced — single process
and single port, relative URLs only, all state under one configurable directory, configurable bind
address, and no assumption of public reachability. Those rules cost nothing here and are what keep
both the plain-web-host and StartOS-service paths open.
### C. Browser
---
**13. Client state store**
- **Where:** Browser · **When:** on every received event
- **Does:** applies events in order, detects gaps and requests replay, holds the current view and
affordance set. Never mutates state locally in a way that could drift.
- **MVP: S** · **Final: M** · ~150 → ~350 LOC
- **Size driver:** ordered application is simple; gap recovery and optimistic prediction are what grow.
**14. Board renderer**
- **Where:** Browser · **When:** on every view change
- **Does:** draws the Division, the Office grid, track geometry, trains and consists, facilities with
their boxes and `MEN | AT | WORK` track, the turn chart and timetable.
- **MVP: M** · **Final: L** · ~500 → ~1500 LOC
- **Size driver:** at MVP, one Office and "functional not pretty". Grows with several Offices, the
full Division chain, animation, and responsive layout.
**15. Action and affordance UI**
- **Where:** Browser · **When:** whenever the player may act
- **Does:** presents the three-way Local Ops choice; the switching interface — six Moves, reachable
squares, mandatory coupling, drop-in-seated-order, the four-slot limit; card play and placement;
Laborer and Porter assignment; the clearance prompt.
- **MVP: L** · **Final: L** · ~600 → ~1200 LOC
- **Size driver:** **the sleeper of the project.** The switching interface is the hardest UI in the
game and it is needed in full at MVP — a player cannot do anything useful without it. It barely
simplifies for solitaire.
**16. Replay viewer**
- **Where:** Browser · **When:** after a game ends — **post-MVP**
- **Does:** read-only projection over the stored event log, with playback controls.
- **MVP: —** · **Final: S** · 0 → ~250 LOC
- **Size driver:** small *because* it reuses component 14. Cheap only if events render standalone —
see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability).
### D. Dev-side
Real code that must be written and maintained, but ships nowhere. This is the answer to "what might
need to run somewhere else."
---
**17. Heuristic bot players**
- **Where:** Dev · **When:** simulation runs and tests
- **Does:** plays legal games without a human — picks a Local Ops option, spots cars, works freight,
rules on clearances.
- **MVP: S** · **Final: M** · ~200 → ~500 LOC
- **Size driver:** a bot that plays *legally* is small; one that plays *well* enough to validate
balance numbers is much larger. Only the former is needed first.
**18. Balance simulation harness**
- **Where:** Dev · **When:** on demand, in batches
- **Does:** runs many seeded games, reports Revenue per Day, game length, collision frequency, Crew
Tray contention — to retune the provisional numbers.
- **MVP: S** · **Final: M** · ~150 → ~400 LOC
- **Size driver:** trivial to run games; the work is in reporting and in deciding what to measure.
- **Note:** this exists *only* because the phase driver lives inside the engine. It is a loop over
`advance` and `apply` with no server involved.
**19. Test fixtures and golden replays**
- **Where:** Dev · **When:** CI, every commit
- **Does:** hand-built states for tricky rules (facing-point switching, collision triggers, consist
ordering) plus recorded full games replayed to detect regressions.
- **MVP: M** · **Final: L** · ~400 → ~1000 LOC
- **Size driver:** proportional to rules surface, which is large. Under-investing here is the most
expensive mistake available, because rules bugs are silent.
---
### Estimate accuracy so far
Components 14, 6 and 7 are built. Actual against the MVP estimate:
| Component | Estimated | Actual | |
| --- | ---: | ---: | --- |
| 1 · card catalogue | ~200 | 465 | data-with-types is more verbose than allowed for |
| 2 · state model | ~400 | 349 | close |
| 3 · track graph | ~500 | 286 | the port-pair model turned out compact |
| 4 · apply | ~800 | 830 | close |
| 6 · legalActions | ~300 | 187 | small *because* it delegates every rule to `check` |
| 7 · seeded RNG | ~50 | 75 | close |
| **Engine subtotal** | **~2,250** | **2,192** | **within 3%** |
The individual figures move around by up to 2× in both directions, but the subtotal is close enough
that the group-level numbers below are worth keeping. The one systematic lesson: **static data costs
more than logic** — component 1 was the worst miss, and components 2 and 3 (types and graph) came in
under.
## 3. Dependency graph
```
[1] card catalogue
[2] state model ◄──── [7] seeded RNG
[3] track graph
┌───────┼───────┐
▼ ▼ ▼
[4]apply [5]advance [6]legalActions
└───────┼───────┘
[8] session host ──► [9] event log
[10] view projection
[11] transport ◄──── [12] lobby
│ wired together at startup by
[20] bootstrap / config ──► [9]
[13] client store
┌───────┴───────┐
▼ ▼
[14] renderer [15] action UI
[16] replay viewer
dev-side: [17] bots ──► [18] harness both depend on [4][5][6]
[19] fixtures depends on [4][5][6]
```
Acyclic. Everything flows from the engine outward.
---
## 4. Build order
Each step ends at something demonstrable.
| Step | Build | Testable when done |
| --- | --- | --- |
| 1 | 1, 2, 7 | Card data loads; a game state can be constructed and seeded |
| 2 | 3 | Move legality provable against Appendix A's worked switching examples |
| 3 | 4, 6 | Individual actions apply correctly; illegal ones rejected |
| 4 | 5 | **A full solitaire game runs to completion, headless** |
| 5 | 19, 17 | Rules regressions caught automatically; bots play legal games |
| 6 | 18 | **Provisional numbers validated or retuned against real data** |
| 7 | 8, 9, 20 | A game runs in a server process and survives restart |
| 8 | 10, 11 | A browser can connect and receive an ordered event stream |
| 9 | 13, 14 | The board renders and updates live |
| 10 | 15 | **MVP complete — a human plays a solitaire game end to end** |
| 11 | 12 | Multiple games, multiple players, lobby and reconnection |
| 12 | 16 | Post-game replay |
**Step 4 is the important milestone.** A full game running headless proves the rules before a single
pixel is drawn. **Step 6** answers whether the numbers are right — while changing them is still free.
---
## 5. Overall sizing
| Group | Components | MVP | Finished |
| --- | --- | ---: | ---: |
| A. Engine core | 17 | ~2,650 | ~3,980 |
| B. Server | 812, 20 | ~610 | ~2,100 |
| C. Browser | 1316 | ~1,250 | ~3,300 |
| D. Dev-side | 1719 | ~750 | ~1,900 |
| **Total** | **20** | **~5,260** | **~11,280** |
Read these as ratios, not promises. What they say:
- **The engine is half the MVP and it comes first.** No way around it — solitaire removes players,
not rules.
- **The MVP is roughly half the finished system.** Unusually high, because the rules do not shrink.
- **The server is the cheapest group at MVP and grows the most proportionally** (~3.5×), almost
entirely in lobby, redaction and fan-out — all multiplayer concerns.
- **Dev-side is ~15% of the work.** That is proportionate for a game whose rules bugs are silent.
---
## 6. Worked trace — one Move, end to end
Following a single action through every component, to show where the seams are. The player moves a
Crew Tray onto a card holding two standing cars, which couples them automatically (§A.4).
| # | Component | What happens |
| --- | --- | --- |
| 6 | Legal-action enumeration | Computes reachable Operational Rail squares from the tray's position, respecting turnout direction and the no-reversal rule. The occupied card is among them. |
| 15 | Action UI | Highlights those squares. Player clicks the occupied one. |
| 15 | Action UI | Emits `Switch.Move { trayId, to }`. Note it does **not** emit a couple intent — coupling is mandatory, not a choice. |
| 11 | Transport | Carries the intent to the server. |
| 8 | Session host | Checks it is this player's turn and the right phase; hands it to the engine. |
| 4 | Apply | Validates the Move via component 3; rejects with `WOULD_REVERSE` / `NOT_OPERATIONAL_RAIL` / `TRACK_OCCUPIED` if illegal. |
| 3 | Track graph | Confirms the path exists without a direction change. |
| 4 | Apply | Moves the tray, couples the standing cars **in track order**, checks the four-slot limit, decrements Moves. Emits `TrayMoved` and `CarsCoupled`. |
| 9 | Event log | Appends both events to disk. |
| 8 | Session host | Calls `advance` — no automatic work is due mid-Local-Ops, so it returns `needsInput`. |
| 10 | View projection | Rebuilds the view. Nothing is redacted here; the board is public. |
| 6 | Legal-action enumeration | Recomputes affordances from the new state — fewer Moves remain, and a fuller consist may now be at its four-slot limit. |
| 11 | Transport | Pushes events and the new view. |
| 13 | Client store | Applies both events in order. |
| 14 | Board renderer | Redraws the tray at its new position with the coupled cars in the consist. |
| 15 | Action UI | Updates the Moves counter and the highlighted squares. |
Two things this makes concrete. **Coupling never appears as an intent** — it is a consequence the
engine produces, so a client that offered it as a choice would be wrong. And **component 6 runs
twice**, before and after: affordances are a projection of state, not a response to an action.
## 7. Design decisions this surfaces
The point of doing this before coding — questions now answerable, or now visibly needing an answer.
**Settled by the shape above:**
- **The phase driver belongs in the engine.** Without it the balance harness (18) would have to
reimplement the phase loop, and the two would drift.
- **`legalActions` must share predicates with `apply`**, not reimplement them. Two independent copies
of turnout directionality will disagree eventually, and the failure is a legal move the UI refuses
or an illegal one it offers.
- **Persist events, not snapshots.** Components 9, 13 and 16 all assume it, and retrofitting it later
means re-instrumenting everything.
- **The RNG must be threaded, not ambient.** One stray global random call and replay silently breaks.
**Settled since:**
- **Stack: TypeScript**, chosen precisely so the engine can run in both places. Node 22 executes
TypeScript natively by type stripping, so the engine, server and tests need no build step during
development; `tsc --noEmit` provides typechecking separately. Note the constraint this implies:
**erasable syntax only** — no `enum`, no parameter properties, no namespaces.
- **The engine ships to the browser.** It is pure and has no I/O, so component 6 runs client-side for
instant affordances without a round-trip, and there is exactly one implementation of turnout
directionality rather than two that can disagree.
- **The client computes affordances locally**, following from the above. Component 10 sends state,
not affordance lists.
**Now visible, still open:**
- **How is the Office grid rendered — DOM, SVG or canvas?** Component 14's finished size varies by
roughly 2× across those choices, and component 15 depends on the hit-testing model the choice
implies.
- **What does the replay viewer show?** Hands were private during play. Revealing them afterwards is a
design question, not a technical one — noted in `overview.md` and still unanswered.
+94
View File
@@ -0,0 +1,94 @@
# Deployment
A decision record comparing the two hosting paths under consideration: a plain web host, or packaging
the server as a StartOS service. **No packaging work is done here** — this exists so the choice can be
made deliberately, and so the architecture does not accidentally foreclose either option.
Target remains **small self-hosted** scale.
---
## 1. What the server needs, either way
The requirements are modest, which is what makes both paths viable:
| Need | Why |
| --- | --- |
| One long-running process | Active games are held in memory ([`overview.md`](overview.md)) |
| HTTP + WebSocket on one port | Lobby over HTTP, game stream over WebSocket ([`protocol.md`](protocol.md)) |
| A writable data directory | The append-only event log ([`lobby-and-sessions.md`](lobby-and-sessions.md)) |
| Static asset serving | The browser client |
| No outbound network access | The game talks to nobody |
| No scheduled work | Nothing in the rules is real-time ([`overview.md`](overview.md)) |
No database server, no message broker, no cache, no cron. SQLite or flat files cover persistence.
That single-process, single-port, one-volume shape is the least demanding thing either platform has
to host.
---
## 2. Plain web host
**What it takes:** a small VM or container host, a reverse proxy terminating TLS, a process manager,
and a backup of the data directory.
**Advantages.** Anyone with a link can join, which matters for a game whose discovery mechanism is
"read a code aloud to your friends." Deployment is unremarkable and every stack has good support for
it.
**Costs.** You own TLS certificates, updates, backups, and uptime. And a public URL means the server
is exposed to the open internet, which raises questions the game does not otherwise have — abuse of
game creation, resource exhaustion from unbounded lobbies, and the need for at least basic rate
limiting.
---
## 3. StartOS service
**What it takes:** packaging the server as an `.s9pk`. The workspace guide is on disk at
`start-technologies/projects/start-sdk/docs/src/``recipe-basic-service.md` for the overall shape,
`interfaces.md` for how the UI is exposed, `recipe-health-checks.md` for readiness, and
`recipe-backups.md` for the data directory. Start with `recipes.md`, which is the intent index.
**Advantages.** Installation, updates, TLS and backups are the platform's job rather than yours. The
service declares what it exposes; the *user* decides where it is reachable from. For a friends-and-
family game that fits well — the person running it already controls who gets the address.
**Costs.** Players must reach the host, which is a real constraint for a browser game: everyone needs
network access to that StartOS box. How they get it is the user's configuration decision, not
something the application chooses or should claim.
**One thing the architecture must respect.** A packaged service should not assume it is reachable at
a fixed, publicly-routable URL. Anything that bakes an origin into the client — absolute WebSocket
URLs, hard-coded hostnames in links, CORS allow-lists pinned to one domain — will break. Serve the
client from the same origin as the API and use relative URLs throughout. This costs nothing on a
plain web host and is required on StartOS, so it should simply be the rule.
---
## 4. Recommendation
**Build for both; decide later.** The requirements in §1 are satisfied identically by either path, and
the only architectural constraint that differs is the same-origin/relative-URL rule in §3 — which is
good practice regardless.
Concretely, do this from the start:
1. **Single process, single port**, serving both the client and the API.
2. **Relative URLs everywhere** in the client. No baked-in origin, no hard-coded hostname.
3. **All state under one configurable data directory**, path supplied by environment variable.
4. **No assumption of public reachability** anywhere in the code.
5. **Bind address and port configurable** by environment variable.
That set keeps both doors open at no cost. The decision only needs making when there is something
worth deploying — and by then it will be an easier call, because you will know whether the people
playing it are on your network or scattered.
---
## 5. Explicitly not decided here
- The stack. Still open; nothing above depends on it.
- Whether to publish to a StartOS registry (`publishing.md` in the guide) — irrelevant until the game
runs.
- Domain names, TLS specifics, or backup schedules for the plain-host path.
+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.
+173
View File
@@ -0,0 +1,173 @@
# Lobby and Sessions
Everything outside the rules engine: creating and joining games, seating, reconnection, and
persistence. Target is **small self-hosted** scale — a handful of concurrent games among people who
know each other.
Written against [`../rules/rules-v0.2.md`](../rules/rules-v0.2.md).
---
## 1. Identity
No accounts to begin with. A player is a **display name** plus a **session token** the server issues
on join and the client stores locally.
```
Session
token : opaque, unguessable
gameId
playerIndex
displayName
```
The token is what makes reconnection work: it proves "I am the player who was sitting at seat 2,"
which is the only identity claim the game needs. Keep it out of URLs so it is not shoulder-surfed or
pasted into a chat.
Real accounts can be layered on later without touching the rules engine, which is exactly why
[`overview.md`](overview.md) keeps that boundary sharp.
---
## 2. Creating and joining
```
Lobby.Create { config } → { gameId, gameCode, token }
Lobby.Join { gameCode, displayName } → { token, playerIndex }
```
A **game code** — short, human-speakable, e.g. `RAIL-4471` — is the whole discovery mechanism. No
matchmaking, no browsing, no public game list. Players are already talking to each other; the code
just needs to survive being read aloud.
The creating player is the **host**: they set the config (§3) and start the game. Host-only rights
end at `Lobby.Start` — the rules give no player special standing during play, and the Superintendent
role rotates independently (§5).
**Player counts.** Solitaire is 1. Competitive and Co-op need at least 2. The upper bound is a
practical judgment rather than a rules limit: the Division grows by one Office and one Mainline card
per player (§4.3), and every added player lengthens the Superintendent's rotation and the
phase-major waiting. **6 is a sensible cap** for the prototype.
---
## 3. Configuration, and when it locks
Set before start, immutable after:
```
mode : solitaire | competitive | coop
victory : firstToTarget | highestAfterDays
length : short | standard | campaign
optionalRules : { reducedVisibility, sisterTrains, employeeRotation, emergencyToolbox }
```
These must lock at `Lobby.Start`. Changing `length` mid-game would move the finish line; changing
`mode` would switch which failure floors apply (§3.4, §3.5). Neither has a coherent meaning
mid-game, so the server should refuse rather than try.
---
## 4. Seating
**Seating order is not cosmetic.** The players form a physical west-to-east chain of Offices (§4.3),
and seat order determines three separate things:
1. Which Office is adjacent to which — and therefore where each player's trains arrive from.
2. The Superintendent rotation, which passes one seat left every three Stages (§5).
3. Acting order within a phase, which starts at the Superintendent and proceeds left (Gap 1).
So seating must be settled before the opening D12 rolls, and the lobby should show the chain
visually — a player should see whose Office lies east and west of theirs before the game begins.
**The opening rolls** (§4.4, §4.5) happen server-side at `Lobby.Start`, from the game seed: highest
D12 takes the Eastern Division Point, and a second roll picks the opening Superintendent. Emit both
as events so clients can show the rolls rather than just the outcome — it is the game's first moment
of drama and there is no reason to hide it.
**Employee Rotation** (Appendix B), if enabled, moves every player one seat left at the end of each
Day, carrying their Revenue and the Fedora with them. Note what this means for the model: a player's
*seat* changes while their *score* follows them, so `Player.index` and Office ownership must be
separable. This is the one optional rule with real structural consequences — worth wiring in from the
start rather than retrofitting.
---
## 5. Disconnection and reconnection
Turn-based with one actor at a time makes this far easier than it would be in a real-time game.
**On disconnect:** keep the seat. Do not remove the player, do not auto-play. The game simply waits
if it was their turn. Broadcast a `PlayerDisconnected` event so everyone else can see why nothing is
happening — silence with no explanation is the worst version of this.
**On reconnect:** the client presents its token, and the server replies with a full current view plus
the event tail it missed. Because state is `fold(events)` ([`protocol.md`](protocol.md)), catching up
is a replay, not a special case.
**On a player who does not come back:** the honest options at this scale are to wait, or to let the
host end the game. An optional **turn timer** is worth having — a lobby setting, off by default,
never part of the rules — that on expiry takes the safest legal action:
| Phase | Timeout action |
| --- | --- |
| Local Operations | `Switch.End` / `Draw.End` — forfeit the remaining action |
| New Train | `NewTrain.PassCar` if legal, else place any legal car |
| Mainline clearance | **Deny** clearance — the safe answer; a held train costs a Stage, a wrecked one costs 5 Revenue and counts toward the collision floor |
| Load/Unload | `LoadUnload.End` |
Denying clearance on timeout is the right default and worth stating explicitly: the asymmetry between
the two outcomes is large, and in Competitive mode a timed-out clearance that causes a wreck could
end the game for everybody (§3.4).
**Do not substitute an AI player.** The Superintendent's clearance decisions materially affect other
players' scores; a bot making them on an absent player's behalf changes the game rather than
preserving it.
---
## 6. Persistence
Persist the **event log**, not a state snapshot. It is smaller, it is the thing that already exists,
and it makes a mid-game server restart a replay rather than a recovery.
```
games : gameId → { config, seed, status, createdAt, gameCode }
events : gameId → ordered event list
sessions : token → { gameId, playerIndex, displayName }
```
Requirements are modest enough that the storage choice is genuinely open — SQLite on a single node
covers this comfortably, and so would flat files with an append-only log per game. The constraint
worth honouring is that the **event log is append-only**: rewriting history breaks the one property
that makes replay trustworthy.
**Snapshotting** is an optimisation, not a requirement. If a Campaign game's log grows long enough
that replay feels slow, periodically store a state snapshot with its `eventSeq` and replay forward
from there. Do not build this until it is needed.
**Retention.** Finished games **keep their event log, seed and config**, because post-game replay is
a wanted capability (see [`overview.md`](overview.md#post-game-replay--a-desired-future-capability))
and the log is the only thing it needs. Do not delete finished games by default.
Retention is an operator setting rather than an application decision — a self-hosted instance among
friends may as well keep everything, since a finished game's log is small. If a cap is wanted, expire
by age or by total count and say so plainly in the UI, because a replay that silently stops existing
is worse than one that was never offered.
Session tokens are the exception: those can expire on a short window once their game has finished.
---
## 7. What this deliberately omits
At small self-hosted scale these are not needed, and building them early costs more than it returns:
- **Matchmaking or a public game browser.** The game code covers discovery.
- **Ranking, ladders, persistent profiles.** §3's timed modes produce comparable scores, so a
high-score table would be a natural first addition — but it is a feature, not infrastructure.
- **Spectators.** Straightforward to add later, since a spectator is just a view with no `private`
section and no ability to send intents — the same shape post-game replay needs.
- **Horizontal scaling and cross-process coordination.** One process holds every active game
in memory.
+187
View File
@@ -0,0 +1,187 @@
# Architecture Overview
Station Master as a multiplayer digital game: players connect from a web browser, an authoritative
server manages lobbies and game state. Target scale is **small self-hosted** — a handful of
concurrent games, friends-and-family — hosted on a website or packaged as a StartOS service.
**No stack is chosen.** These documents describe what any implementation must do. Where a concrete
shape helps, it is written as pseudo-structure, not as a language.
Written against [`../rules/rules-v0.2.md`](../rules/rules-v0.2.md).
---
## The server is authoritative
The server owns all game state and is the sole party that may change it. Clients render state and
submit *intents*; the server validates, applies, and broadcasts.
This is not a default choice — the rules force it:
**The game has hidden information.** Each player holds up to three cards (four with the Emergency
Toolbox) that no other player may see. The Home Office deck is face-down and its order must not be
knowable. A client holding full state could read both.
**The game has randomness that decides outcomes.** D12 rolls set the Eastern Division Point, the
opening Superintendent, and — critically — the timetable slot a newly played Timetabled Train lands
in (§7). Deck shuffles decide everything else. A client-side RNG is a client-side cheat.
**Turn legality is global, not local.** After Gap 1, phases resolve phase-major with one player
acting at a time. Whether *you* may act depends on where every other player is. Only a global
observer can answer that. The Mainline Phase adds a wrinkle: it is automatic except when §8.1's
fourth condition prompts the Superintendent to rule on a following-train clearance — an input that
arrives mid-phase, out of the normal turn order, and can be triggered by another player's train.
**Collision evaluation must be identical for everyone.** After Gap 2, collisions are automatic — a
pure function of board state at each movement step. Two clients disagreeing about a wreck is a
divergent game, and in Competitive mode a wreck can end the game for everyone (§3.4).
### What that means in practice
| Concern | Where it lives |
| --- | --- |
| Deck order, shuffles, all RNG | Server only. Seeded, so a game is replayable for debugging. |
| Player hands | Server. Sent only to their owner. |
| Board state (track, pieces, facilities, yards) | Server. Public — broadcast to everyone. |
| Whose turn it is | Server. Derived, never client-asserted. |
| Rules validation | Server. The client may pre-validate for responsiveness, but the server's answer is the only one that counts. |
| Rendering, animation, local UI state | Client. |
The client is allowed to know the rules and grey out illegal moves — that is good UX. It is never
allowed to *decide* one.
---
## State, intents, events
Three flows, and keeping them distinct is what keeps the implementation tractable:
```
client ──── intent ────► server "I want to move Crew Tray 3 to card B4"
├─ validate against rules + current phase + current actor
├─ apply to authoritative state
client ◄──── event ─────────┘ "Crew Tray 3 moved to B4; 4 Moves remain"
client ◄──── view ─────────┘ per-player redacted snapshot
```
- **Intents** are what a player wants to do. They are proposals; they can be rejected.
- **Events** are what happened. They are facts, ordered, and form the game's history.
- **Views** are per-player projections of state, with other players' hands redacted.
An event log that fully determines state is worth building even at this scale. It gives
reconnection (replay to catch up), persistence (store the log, not a snapshot), and debugging (replay
a reported bug exactly) for one design decision.
### Post-game replay — a desired future capability
**Not to be implemented yet.** Recorded here so it is not accidentally designed out, because the
architecture above already produces almost everything it needs.
The goal: when a game finishes, players can watch it back at speed — the Division filling with
traffic, the wrecks, who was Superintendent when. For a game whose drama is largely invisible while
you are playing your own Office, this is worth more than it would be in most games. You spend the
game watching your own station; the replay is where you find out what the railroad was doing.
State is already `fold(events)` over an ordered, append-only log, and all randomness derives from a
stored seed. Replay is therefore a **read-only projection over the existing log** — no new rules-engine
surface, no second code path, nothing the server has to do differently during play.
Three constraints keep it cheap, and all three are free if honoured from the start:
1. **Events must render standalone.** A `TrainMoved` should carry its from/to, not just an id the
renderer has to resolve against live state. An event stream that only makes sense when applied in
order to a mutable world is replayable but not *displayable* — the renderer would need to
reconstruct the whole board to draw one frame.
2. **Finished games keep their log, seed and config.** See the retention note in
[`lobby-and-sessions.md`](lobby-and-sessions.md).
3. **Timing is a presentation concern.** The log has no wall-clock pacing and does not need any —
the replay UI decides how fast to advance. Do not record timestamps for the benefit of replay;
Stage and phase boundaries are the natural beats.
The one genuine open question is **whose view a replay shows**. Hands were private during play, and
revealing them afterwards is a design choice rather than a technical one — it changes what players
learn about each other between games. Worth deciding before building, not during.
---
## Transport
The game is turn-based with exactly one actor at a time, so the traffic pattern is bursty and small —
but every player needs to *watch* the acting player, and above all to watch the Mainline Phase, where
trains move and wrecks happen while the Superintendent rules on clearances.
That means the server must push. A request/response API alone would leave four players polling to
watch a fifth switch cars.
**WebSocket, with the HTTP endpoints alongside it** for lobby operations (list games, create, join)
where a request/response shape is the natural fit. Server-Sent Events would also serve, since the
push is nearly one-directional and intents are rare enough to send over HTTP — worth keeping in mind
if the eventual stack makes SSE materially simpler.
What matters more than the choice: **every state change reaches clients as an event on one ordered
stream**. Clients apply events in order and never mutate state locally in a way that could drift.
---
## Timing and the absence of clocks
Nothing in the rules is real-time. A Stage advances when the phase sequence completes, not when a
timer fires. There are no reflexes to test and no reason to rush a player.
The one place real time may need to intrude is a player who walks away mid-game. That is a session
concern, not a rules concern — see [`lobby-and-sessions.md`](lobby-and-sessions.md). Any turn timer
is a lobby setting layered on top of the rules, never part of them.
---
## What the server does not need
Worth stating, because small self-hosted scale makes several standard concerns disappear:
- **No horizontal scaling.** A handful of concurrent games fits in one process. Game state lives in
memory; the event log is persisted for durability, not for coordination.
- **No matchmaking service.** Players share a game code (see `lobby-and-sessions.md`).
- **No accounts system, initially.** A display name plus a session token is enough to join and
reconnect. Real accounts can be layered on later without touching the game engine.
- **No anti-cheat beyond authority.** Server authority plus redacted views is the whole model.
---
## The one boundary that matters
Keep the **rules engine** free of any knowledge of networking, storage, or players-as-connections:
```
rules engine pure. state + intent → events, or a rejection.
▲ no I/O, no clock, no sockets. deterministic given a seed.
game session owns one game's state, applies intents, emits events
lobby / transport connections, reconnection, persistence, HTTP + WebSocket
```
The rules engine being pure and deterministic is what makes the whole thing testable — you can drive
a full game through it in a unit test with no server at all, which matters a great deal for a game
with this much rules surface. It is also what would make a future Solitaire offline mode or an AI
opponent cheap rather than a rewrite.
Everything in [`game-state.md`](game-state.md) belongs to the rules engine. Everything in
[`lobby-and-sessions.md`](lobby-and-sessions.md) belongs outside it.
**The engine is reactive *and* active.** It exposes two entry points, not one:
```
apply(state, intent) → events | rejection a player did something
advance(state) → events | needsInput the game moves on its own
```
The second matters more than it looks. Mainline movement, collision resolution, Stage and Day
advance, Fedora rotation and train make-up all happen with no player acting — at a table the players
do this collectively, and in software something must. Keeping that **phase driver** inside the engine
rather than in the session layer means all rules knowledge stays in one pure deterministic place, and
a balance simulation becomes a loop over these two functions with no server involved.
For the full list of buildable pieces — where each runs, when, and how big — see
[`components.md`](components.md).
+269
View File
@@ -0,0 +1,269 @@
# Protocol
The client↔server message vocabulary, and how per-player views are redacted. Written against
[`../rules/rules-v0.2.md`](../rules/rules-v0.2.md) and [`game-state.md`](game-state.md).
Three message families, matching the flows in [`overview.md`](overview.md):
- **Intent** — client → server. A proposal. May be rejected.
- **Event** — server → clients. A fact. Ordered, and the game's history.
- **View** — server → one client. A redacted state snapshot.
Message names below are illustrative. What matters is the *set* of decisions a player can make, which
is fixed by the rules.
---
## 1. Intents
Every intent carries `gameId`, `playerId`, and a `seq` the server uses to reject duplicates from a
reconnecting client.
### 1.1 Local Operations Phase
The phase opens with a three-way exclusive choice (§6). Choosing one forecloses the others for that
Stage.
```
LocalOps.Choose { option: switch | draw | freightAgent }
```
**If `switch`** — six Moves, divisible across Crew Trays (§6.1). Five Moves under Reduced Visibility
during night Stages (Appendix B).
```
Switch.Move { trayId, to: GridCoord }
Switch.DropCars { trayId, count } -- from the tray end; order is fixed (§A.3)
Switch.SetDirection{ trayId, direction } -- costs a Move; a Move never reverses (§2.4)
Switch.End { } -- forfeit remaining Moves
```
Pick-up is **not** an intent. Moving into standing cars couples them automatically and mandatorily
(§A.4) — the server does it as part of resolving `Switch.Move`. Offering a choice would be a rules
violation.
`Switch.DropCars` takes a count, not a set of car ids: cars come off in seated order (§A.3), so the
only free choice is how many.
**If `draw`** (§6.2):
```
Draw.FromHomeOffice { }
Draw.FromDepartment { slot: 0|1|2 }
Card.Play { cardId, placement? } -- placement required for track/facility/office cards
Card.Discard { cardId, toSlot: 0|1|2 }
Draw.End { } -- server rejects while hand > 3
```
`placement` is a `GridCoord` for track, Facility and Office cards (§11.2); absent for train cards,
which go to the timetable or to an Extra's staging.
**If `freightAgent`** — exactly one of three operations (§6.3):
```
FreightAgent.StockToOutbound { facilityId, stockType, loaded }
FreightAgent.InboundToClass { facilityId, stockIndex }
FreightAgent.UnjamToClass { facilityId, from: outbound|inbound|menAtWork, index }
```
### 1.2 New Train Phase
Per train being made up, each player adds one car in Superintendent-then-left order (§7):
```
NewTrain.PlaceCar { trainId, stockType, loaded }
NewTrain.PassCar { trainId } -- only legal when no suitable car exists in the Division Yard
```
**This cycles** (§7, Gap 9). The server keeps going round the table — one car per player per pass —
until the consist is full or no suitable car remains in the Division Yard. It is not a single pass, so
the client must not assume one prompt per player per train. At five or more players the round ends
mid-pass when the consist fills; players not yet reached simply are not prompted.
`PassCar` must be validated, not trusted: §7 requires the player to "make every effort to find a
suitable car." The server checks the Division Yard against the train's `consistSpec` and rejects a
pass when a legal car is available. A pass by every player in a full pass is the loop's other
termination condition.
For a played Extra (§7, Gap 4c) the owning player chooses its whole consist:
```
Extra.Launch { trainCardId, at: westDP | eastDP, direction }
Extra.LoadConsist { trainCardId, stock: [{stockType, loaded}] } -- ≤3 cars + caboose
```
### 1.3 Mainline Phase
The phase is automatic **except** for the Superintendent's clearance decision (§8.1, fourth
condition). The server pauses movement, sets `clock.pendingDecision`, and waits:
```
Mainline.Clearance { trainId, allow: boolean }
```
Only the current Superintendent may send this, and only for the train named in the pending decision.
This intent arrives out of the normal turn order and may concern another player's train — the one
place in the game where a player acts during someone else's traffic.
The Red Flag card (Emergency Toolbox, Appendix B) also interrupts here:
```
RedFlag.Play { } -- averts an imminent collision; card is removed from the game
```
### 1.4 Load/Unload Phase
Each Laborer and Porter is usable once per Stage (§9.1). Resolve one at a time.
```
Porter.Board { facilityId } -- §9.2, +1 Revenue
Porter.Detrain { facilityId } -- §9.2, +1 Revenue
Laborer.AdvanceLoad { facilityId, loadRef } -- Green → MEN → AT → WORK → car (§9.3)
Laborer.BeginUnload { facilityId, carIndex } -- first step of unloading
LoadUnload.End { }
```
A freight load needs four `Laborer.AdvanceLoad` intents to score one point; a Porter scores in one
(§9.3). The client should show this clearly — it is the single most surprising thing about the game's
economy for a new player.
### 1.5 Lobby
Covered in [`lobby-and-sessions.md`](lobby-and-sessions.md); listed here for completeness.
```
Lobby.Create { config }
Lobby.Join { gameCode, displayName }
Lobby.Leave { }
Lobby.SetConfig { config } -- host only, before start
Lobby.Start { } -- host only
```
---
## 2. Rejections
An intent is rejected with a reason a client can render, not a stack trace:
```
Rejected { seq, code, message, ... }
codes: NOT_YOUR_TURN | WRONG_PHASE | OPTION_ALREADY_CHOSEN | NO_MOVES_REMAINING
ILLEGAL_MOVE | TRACK_OCCUPIED | NOT_OPERATIONAL_RAIL | WOULD_REVERSE
CONSIST_FULL | CONSIST_ORDER | HAND_LIMIT | CARD_NOT_IN_HAND
NO_PLACEMENT | NOT_CONNECTED | RESOURCE_SPENT | SUITABLE_CAR_EXISTS
NOT_SUPERINTENDENT | NO_PENDING_DECISION
```
The client may pre-validate to grey out illegal moves — good UX — but the server's answer is the only
one that counts (`overview.md`). Rejections should be rare in a well-built client and are therefore
worth logging server-side: a spike usually means client and server rules have drifted.
---
## 3. Events
Events are the authoritative history. State is `fold(events)`, which is what gives reconnection,
persistence and replay for one design decision.
**Phase and clock**
```
StageBegan { day, stage }
PhaseBegan { phase }
ActorChanged { playerIndex | null }
SuperintendentChanged { playerIndex }
DayEnded { day, revenues }
```
**Local operations**
```
LocalOpsOptionChosen { playerIndex, option }
TrayMoved { trayId, from, to, movesRemaining }
CarsCoupled { trayId, stock[] } -- automatic, from a Move
CarsDropped { trayId, at, stock[] }
CardDrawn { playerIndex, source, cardId? } -- cardId omitted for other players
CardPlayed { playerIndex, cardId, placement? }
OfficeUpgraded { playerIndex, from, to } -- property change; connections unaffected (§11.3)
CardDiscarded { playerIndex, cardId, toSlot }
DeckReshuffled { }
```
**Trains**
```
TrainScheduled { trainCardId, timetableSlot } -- from the 1D12 roll (§7)
TrainMadeUp { trainCardId, trayId, at }
CarPlacedOnTrain { playerIndex, trayId, stock }
ClearanceRequested { trainId, followingInto, occupiedBy }
ClearanceGiven { trainId, allow }
TrainHighballed { trayId, from, to }
TrainMoved { trayId, from, to }
TrainCompleted { trayId, atDivisionPoint }
```
**Consequences**
```
CollisionOccurred { at, trains[], struckCars[], faultPlayer, penalty: 5 }
RedFlagPlayed { playerIndex, avertedAt }
RevenueChanged { playerIndex, delta, total, reason }
CollisionCountChanged { collisionsToday }
GameEnded { result, winner?, reason }
```
`CollisionOccurred` carries `faultPlayer` explicitly rather than leaving clients to derive it. Fault
depends on *where* the wreck happened — Superintendent for a Mainline card, the local player between
his Limits (§10) — and getting that wrong misattributes a 5 and, in Competitive, contributes to a
floor that ends the game.
---
## 4. Views and redaction
Each client receives a projection with other players' private state removed.
| State | Visibility |
| --- | --- |
| Board: track grids, Offices, Limits, trains, standing cars | **Public** |
| Facilities: boxes, `MEN AT WORK`, Laborer/Porter usage | **Public** |
| Division Yard, Classification Yard contents | **Public** — physical piles on the table |
| Salvage Yard | **Public** — face up, explicitly so players can audit discards (§2.6) |
| Department slots (the three face-up cards) | **Public** |
| Home Office deck **contents and order** | **Secret** — never sent, to anyone |
| Home Office deck **count** | Public — players can see the pile's height |
| A player's hand | **Owner only**; others see the count |
| Red Flag held | Public — it is a known starting card (Appendix B) |
| Revenue totals | **Public** — the race is the game |
| RNG seed | **Secret** — sending it leaks all future shuffles |
```
View {
public : PublicState -- identical for every client
private : { hand: CardId[], redFlag: boolean }
you : PlayerIndex
canAct : boolean
legalIntents? : IntentSummary[] -- optional server-computed affordances
}
```
**Two redaction traps.** First, `CardDrawn` from the Home Office must omit `cardId` for every client
except the drawer — the obvious version of this event leaks the draw to the table. Second, the deck
*count* is public but the *order* is secret; a naive implementation that ships the deck array and
tells the client not to look is not redaction.
`legalIntents` is optional but worth it: the server already computes legality to validate, so
returning the affordance set costs little and removes any need for the client to reimplement rules
like turnout directionality or the four-slot consist limit.
---
## 5. Ordering and idempotency
- Events carry a monotonic `eventSeq` per game. Clients apply strictly in order and request a replay
on a gap rather than guessing.
- Intents carry a client `seq`. The server ignores a repeat of one it has already applied, so a
reconnecting client can safely resend anything it is unsure about.
- The server never applies two intents concurrently within a game. With one actor at a time this is
free — a per-game queue is sufficient and there is no need for anything cleverer at this scale.