v0.4.0 — multiplayer Phases 0 and 1: seat and player split apart, turn state per player, the page behind a Session, and eight seat/player mix-ups fixed with tests that fail without them

This commit is contained in:
Jesse
2026-08-13 14:06:02 -04:00
parent 216006b091
commit 49f8504b05
34 changed files with 1743 additions and 526 deletions
+83 -214
View File
@@ -1,269 +1,138 @@
# 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).
The client↔server message vocabulary, and how per-player views are redacted.
Three message families, matching the flows in [`overview.md`](overview.md):
**The vocabulary is code, not prose.** This document used to spell out every message with an
illustrative name and an illustrative payload. It was written before the engine existed, and the
engine then went and defined all three families for real — so the document became a second,
drifting, subtly-wrong copy of three TypeScript files. It is now a map of those files plus the rules
that live nowhere else.
- **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.
| Family | Direction | Authority |
| --- | --- | --- |
| **Intent** — a proposal, may be rejected | client → server | [`src/engine/intents.ts`](../../src/engine/intents.ts) — `Intent`, 28 variants |
| **Event** — a fact, ordered, the game's history | server → clients | [`src/engine/events.ts`](../../src/engine/events.ts) — `GameEvent`, 46 variants |
| **View** — a redacted projection | server → one client | [`src/sim/view.ts`](../../src/sim/view.ts) — `Frame` and `snapshot(state, …, seat)` |
| **Rejection** — why an intent was refused | server → one client | `RejectionCode` in `intents.ts` |
Message names below are illustrative. What matters is the *set* of decisions a player can make, which
is fixed by the rules.
Read those files for the shapes. Each variant carries its own doc comment explaining the rule behind
it and, where the field arrangement is load-bearing, why it is arranged that way. What follows is
what the types cannot say.
The transport that carries these — SSE down, HTTP POST up — and the decision to push `Frame` + `Menu`
rather than a raw event stream, are in [`multiplayer.md`](multiplayer.md) (D2/D3, D5). Written against
[`../rules/rules-v0.2.md`](../rules/rules-v0.2.md) and [`game-state.md`](game-state.md); the flows are
in [`overview.md`](overview.md).
---
## 1. Intents
Every intent carries `gameId`, `playerId`, and a `seq` the server uses to reject duplicates from a
reconnecting client.
**Intents are already the wire format.** A saved game is `{ seed, history: Intent[] }` and `fromSave`
replays it, which means the client→server message type was fixed the day save/restore worked. There
is no separate protocol layer to design and nothing to keep in sync.
### 1.1 Local Operations Phase
Every intent is validated server-side even when the client only ever offers legal ones — `legalActions`
and `check` are the same code, so a client-side menu is a convenience, never a guarantee.
The framing an intent needs on the wire (`gameId`, who sent it, a `seq` for de-duplicating a
reconnecting client's resend) is added by the transport and is not part of `Intent` itself: the engine
is given the actor by its caller.
The phase opens with a three-way exclusive choice (§6). Choosing one forecloses the others for that
Stage.
### What is deliberately NOT an intent
```
LocalOps.Choose { option: switch | draw | freightAgent }
```
These are the places where offering a choice would be a rules violation, and they are worth stating
here because "the message is missing" looks like an oversight in a way that "the message exists" never
does.
**If `switch`** — six Moves, divisible across Crew Trays (§6.1). Five Moves under Reduced Visibility
during night Stages (Appendix B).
- **Picking up cars.** Moving into standing cars couples them automatically and mandatorily (§A.4).
The engine does it while resolving `switch.move`.
- **Which cars come off.** `switch.dropCars` takes a *count*: cars come off in seated order (§A.3), so
the only free choice is how many, and from which end (`fromNose`).
- **Mainline movement.** The whole phase is automatic except the Superintendent's clearance decision
(`mainline.clearance`, §8.1) — which arrives out of turn order and may concern another player's
train. It is the one place a player acts during someone else's traffic.
- **Scheduling.** `trainScheduled` comes from the 1D12 (§7); nobody chooses it.
```
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
```
### Two loops the client must not flatten
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
```
- **New Train cycles.** §7 goes round the table one car per player per pass until the consist is full
or no suitable car remains. It is *not* one prompt per player per train. At five or more players a
round can end mid-pass with players never prompted.
- **`newTrain.passCar` is validated, not trusted.** §7 requires "every effort to find a suitable car",
so the engine checks the Division Yard against the consist spec and refuses a pass when a legal car
is there.
---
## 2. Rejections
An intent is rejected with a reason a client can render, not a stack trace:
`RejectionCode` in `intents.ts` is the closed set. `applyIntent` returns
`{ ok: false, code, message }` — a reason a client can show, never a stack trace. The `message` today
is the code restated (`apply.ts`); a client that wants prose should key off `code`, which is stable,
rather than parsing it.
```
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.
A client may pre-validate to grey out illegal moves — that is what `legalActions` is for, and it is
good UX — but the server's answer is the only one that counts. **Rejections should therefore be rare,
which makes them worth logging server-side: a spike 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.
Events are the authoritative history: `state = fold(events)`, which buys reconnection, persistence and
post-game replay for one design decision.
**Phase and clock**
**Events must render standalone** — the rule is stated at the top of `events.ts` and it is the one that
gets broken by accident. Carry the `from`/`to`, not an id the renderer resolves against live state,
or a replay viewer has to reconstruct the whole board to draw one frame.
```
StageBegan { day, stage }
PhaseBegan { phase }
ActorChanged { playerIndex | null }
SuperintendentChanged { playerIndex }
DayEnded { day, revenues }
```
Two consequences worth knowing before adding an event:
**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.
- **`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 their Limits (§10) — and getting it wrong misattributes a −5 and, in Competitive, feeds a
floor that ends the game.
- **Events are not what goes over the wire.** The server folds them and pushes the resulting `Frame`
(`multiplayer.md` D2/D3). They are the store and the replay format, not the protocol.
---
## 4. Views and redaction
Each client receives a projection with other players' private state removed.
Each client receives `snapshot(state, …, seat)` — a `Frame`, which is already a projection built for
rendering and already takes the viewing seat. Nothing else is sent.
| 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) |
| Salvage Yard | **Public** — face up, 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) |
| Red Flag held | Public — a known starting card (Appendix B) |
| Revenue totals | **Public** — the race is the game |
| RNG seed | **Secret** — sending it leaks all future shuffles |
| RNG seed and state | **Secret** — either leaks every future shuffle and roll |
```
View {
public : PublicState -- identical for every client
private : { hand: CardId[], redFlag: boolean }
you : PlayerIndex
canAct : boolean
legalIntents? : IntentSummary[] -- optional server-computed affordances
}
```
**The redaction surface is four fields, not sixty event types** — `seed`, `rngState`,
`deckReshuffled.order`, and `cardDrawn.cardId` when the draw was from the Home Office. The full
argument, including why `trainScheduled` is public despite being a die roll, is in
[`multiplayer.md` §7](multiplayer.md). It reduces to: **call `snapshot` and never send `GameState`.**
**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.
**This is enforced, not assumed.** The redaction test is the single most important test in the
multiplayer work: everything else degrades gracefully, a redaction bug hands a player the deck.
---
## 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.
- Events carry a monotonic sequence 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.
- **The server never applies two intents concurrently within a game.** A per-game queue is sufficient
and there is nothing cleverer to do at this scale. Note this is a serialisation rule, not a
one-actor-at-a-time rule: per-player turn state (`turns: Map<PlayerIndex, TurnState>`) means several
players may hold an open turn at once, and their intents still land one at a time.