The third release from the audit; nothing a player sees changes. CHANGELOG has the detail. The 0.4.9 playtest line is no longer maintained (Jesse, 2026-09-29): the deploy rule that existed for it is gone and #85 is moot. The table test (#39 #35 #42a #40) is closed — every line of the checklist was met at a table. #46 is done and cannot regrow: the 36 unused declarations are removed and `noUnusedLocals`/`noUnusedParameters` are on; two of them were dead bot functions from rejected candidates the round said it had deleted. The documents no longer teach `trainCapSlack` (a knob that throws), point at `as-built.md` (deleted in 0.8.2), model `officeType` (the engine says `tier`) or describe `collisionOccurred` (never emitted); the README's account of bot flags now matches the bot's. Five playtest saves committed in `docs/` against the repository's own rule are in the ignored `playtests/`. What the audit found and did not fix is written down as TODO #112-#117, each with its reason. #112 is `docs/plans/structure.md`, the proposal for `http.ts`, `main.ts` and `check`. #117 — `/api/save` hands a seat the seed mid-game — waits on a conversation. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FrCWubm9GAftYCm2hWdKwK
166 lines
10 KiB
Markdown
166 lines
10 KiB
Markdown
# Protocol
|
||
|
||
The client↔server message vocabulary, and how per-player views are redacted.
|
||
|
||
**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.
|
||
|
||
| 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; narration, not the record (§3) | 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` |
|
||
|
||
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
|
||
|
||
**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.
|
||
|
||
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.
|
||
|
||
### What is deliberately NOT an intent
|
||
|
||
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.
|
||
|
||
- **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.
|
||
|
||
### Two loops the client must not flatten
|
||
|
||
- **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
|
||
|
||
`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.
|
||
|
||
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 narrate; they do not reconstruct.** This section claimed `state = fold(events)` until
|
||
v0.4.0, and it was never true: `applyIntent` goes through `reduce`, but the phase driver mutates state
|
||
and *then* emits a descriptive event, so fourteen of the forty-six event types are never reduced — the
|
||
clock, and the whole Mainline phase, which is to say every train movement in the game.
|
||
|
||
**The canonical record is `{ seed, history: Intent[] }`**, replayed by `fromSave`. That is what save,
|
||
share, undo, restart recovery and post-game replay all run on, and it is what persistence stores
|
||
(`multiplayer.md` §10). Events drive the display and the notifications. Do not build anything on
|
||
folding them without first making the phase driver reduce, which is a rewrite of the most rule-dense
|
||
code in the project and buys nothing the current plan uses.
|
||
|
||
**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.
|
||
|
||
Two consequences worth knowing before adding an event:
|
||
|
||
- **`trainsDestroyed` carries the player at fault (`player`) explicitly** rather than leaving clients to
|
||
derive it (the event was called `collisionOccurred` in this design; the engine emits `trainsDestroyed`).
|
||
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 applies the intent and pushes the resulting
|
||
`Frame` (`multiplayer.md` D2/D3). Events are narration and cues, not the protocol — and a reconnect
|
||
gets a fresh `Frame` rather than the tail it missed.
|
||
- **What events EARN does go over the wire, as four transient signals** (2026-08-23): the sound cues,
|
||
the timetable slot a D12 just filled, a one-line announcement, and the id of the card that just came
|
||
into this seat's hand. They ride beside the `Frame` rather than on it because they mark a *moment*
|
||
and are consumed — putting them on the Frame would re-fire them on every redraw. Before this a
|
||
remote client got none of them, so multiplayer had no sound at all, no flash and no announcements
|
||
while solitaire had all three. The first three are shared and identical in every seat's push; the
|
||
fourth is **not** — `game.justDrawn` is one field for the whole game and does not say whose card it
|
||
is, so the server remembers who drew and sends it to that seat alone
|
||
(`test/server/session.test.ts`, "the four transient signals"). A reconnect gets none of the shared
|
||
three: a fresh connection is drawing a state, and replaying the sounds of everything it missed is a
|
||
burst of noise about the past.
|
||
|
||
---
|
||
|
||
## 4. Views and redaction
|
||
|
||
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, 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 — a known starting card (Appendix B) |
|
||
| Revenue totals | **Public** — the race is the game |
|
||
| RNG seed and state | **Secret** — either leaks every future shuffle and roll |
|
||
|
||
**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`.**
|
||
|
||
**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 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 count is the server's,
|
||
for the life of the game**: the connect push carries the seat's last accepted `seq` (`lastSeq`)
|
||
and the client continues from it, never from 1 — a page that restarted its own count after a
|
||
reload re-sent a number the server had already applied, and the move was silently swallowed as
|
||
a resend (v0.8.4).
|
||
- **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. It is a real queue (`http.ts`'s `inTurn`), not a
|
||
reliance on the single thread: the handler awaits the disk write between applying and answering,
|
||
and two moves arriving together used to interleave across that await (v0.8.4). 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.
|