270 lines
10 KiB
Markdown
270 lines
10 KiB
Markdown
# 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.
|