Files
station-master/docs/architecture/protocol.md
T
Jesse.MarkowitzandClaude Fable 5.1 04ca74c365 v0.8.5 — housekeeping from the audit, and the playtest line retired
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
2026-09-29 17:02:33 -04:00

10 KiB
Raw Blame History

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 — Intent, 28 variants
Event — a fact, ordered; narration, not the record (§3) server → clients src/engine/events.ts — GameEvent, 46 variants
View — a redacted projection server → one client 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 (D2/D3, D5). Written against ../rules/rules-v0.2.md and game-state.md; the flows are in 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. 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.