Initial commit
This commit is contained in:
@@ -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 4–6 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 1–4, 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 | 1–7 | ~2,650 | ~3,980 |
|
||||
| B. Server | 8–12, 20 | ~610 | ~2,100 |
|
||||
| C. Browser | 13–16 | ~1,250 | ~3,300 |
|
||||
| D. Dev-side | 17–19 | ~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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user