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:
+83
-214
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user