phase: 06_realtime_websocket_protocol

All green. Final verification complete — writing the report.

**Final pass report — phase 06 `realtime_websocket_protocol`**

- Verified: all 6 task files present in `complete/`; this was the final verification pass (no implementation work outstanding)
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors
- `uv run undercooked-levels validate` → content OK; `uv run pytest` (full suite) → **1751 passed, 0 failed, coverage 97.30% (>90% floor)**; slow sim stage (`-m slow tests/sim --no-cov`) → 2 passed; repo hygiene clean
- App launches: `/healthz` 200, `/` 200, `/api/levels`, `/api/config`, `POST /api/rooms` (201), `/api/stats` all verified live on an ephemeral port (server cleaned up)
- Criteria: scripted client room→hello→ready→start→countdown→≥20 snaps→round — PASS (`test_ws_round_two_clients.py`); identical `round` payloads both clients — PASS (`round_a == round_b`); version mismatch → `err protocol_mismatch` + close `4400` — PASS; 64 KB refused / 4 KB boundary, 500-burst → `rate_limited` → `4404` with bounded queue, 2000 malformed + 200 handshakes within memory bound — PASS; snap ≤ 1.5 KB (4p/4o/3h) and 64×64 ≤ 4 KB, downlink ≤ 60 KB/s over 100 ticks — PASS; `docs/PROTOCOL.md` model-sync test-enforced in both directions + full-round sequence diagram present — PASS
- Docs: README.md had 3 stale spots — status section (claimed 01–05 complete, 06 pending → now 01–06 complete, 07 pending), "until phases 06/07 wire the transport" wording, and the `PROTOCOL.md` layout note ("phase 06 completes it"). All fixed. AGENTS.md verified consistent — no changes needed
- No defects found in completed phases; no live infrastructure touched

Next pending phase: `07_canvas_client_and_hud`.
This commit is contained in:
2026-10-09 11:13:25 -04:00
parent f51ba919af
commit f26849c1f3
44 changed files with 11525 additions and 72 deletions
+145 -17
View File
@@ -17,8 +17,12 @@ movement and collision, the action button, the hands, the replay goldens),
serve scoring, freshness and the dirty loop, the round verdict with stars)
and **05** (anonymous lobbies and room lifecycle: the `RoomSupervisor`,
the guarded `Room` FSM, the in-memory session store, the lifecycle
integration suite) are **complete**; the next pending phase is
`06_realtime_websocket_protocol`. What
integration suite) and **06** (the realtime WebSocket protocol: the
`/ws/{code}` transport, the versioned JSON wire contract with inbound
guardrails, input ingestion with sequence discipline, the quantized
snapshot stream, heartbeats and seat grace — `docs/PROTOCOL.md`)
are **complete**; the next pending phase is
`07_canvas_client_and_hud`. What
runs today:
- **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
@@ -184,7 +188,8 @@ runs today:
(empty rooms past a 30 s grace, idle lobbies past `lobby_idle_timeout_s`, stale
tokens). Everything runs on an injected clock (`server/clock.py`), so the tests
drive the garbage collector with a `FakeClock` and never sleep. The lifecycle is
testable over HTTP before the phase-06 WebSocket transport exists:
testable over HTTP, and it now also rides the phase-06 WebSocket
transport (`/ws/{code}`; below):
`POST /api/rooms` (201, the session from the `X-Undercooked-Token`
header or the body's `token` — or a server-minted token, returned
once),
@@ -216,19 +221,131 @@ runs today:
criterion 4), the seat-grace and lobby-idle expiries through the GC, and
one room's crash closing only itself (the test-only `_test_step_override`
seam on `Room`; contract: `docs/ROOMS.md` "Failure and expiry" and
"The lifecycle walkthrough"). The client
buttons still answer
"Not implemented until phase 07." until phases 06/07 wire the transport.
"The lifecycle walkthrough"). The client buttons in the page still
answer "Not implemented until phase 07." until the phase-07 client
wiring replaces them.
- **The WebSocket transport (phase 06, task 02).** The room core is on
the wire: `GET /api/stats` answers the ops census (`rooms` / `players`
— the occupied seats / `sockets` — live WebSocket connections /
`tick_hz`), and `WS /ws/{code}` is the game socket — an origin check
*before* the accept (a disallowed `Origin` is an HTTP `403`, never a
close frame that looks like a network error), a `hello` within
`hello_timeout_s` (else close `4400`), a `p` gate (`protocol_mismatch`
+ `4400` before any seat is taken), the supervisor's seat/spectate
(`room_gone` + `4401` for an unknown code, `room_full` + `4402` for a
fresh token on a full room), the `welcome` + the current `lobby`, and
the two per-connection tasks (a non-blocking `pump_out` over a capped
send queue — the *oldest snapshot* drops first, `lobby` / `round` /
`err` never — and the `read_in` guardrails: the `max_message_bytes`
cap → `too_big`, the `max_inputs_per_s` token bucket →
`rate_limited`, the shape check, the per-state whitelist →
`wrong_state`; 3 consecutive rate/oversize violations or 5 shape
violations close `4404`). One socket per token: a second `hello` with
the same token evicts the first (`evicted` + `4403`, marked dead
before the close so no ghost frames) — and an evicted socket never
marks the seat disconnected, so the crown stays put. A *genuine*
disconnect reaches the room exactly once (`mark_disconnected`),
releases the registry slot, and leaves no error in the log — an
abrupt transport close (no close handshake) included. The contract in
prose is `docs/PROTOCOL.md` (message tables kept equal to the models
by `tests/unit/test_protocol_doc_sync.py`, close/error codes, the
limits, and a manual probe that reproduces the handshake without a
browser); the guardrails are driven by real sockets in
`tests/integration/test_ws_handshake.py` and the queue policy by
`tests/unit/test_send_queue_policy.py`.
- **Input ingestion (phase 06, task 03).** The `input` message is on the
wire: the `seq` discipline (equal → dropped as an idempotent resend,
backwards → `err` + a `resync` event the client hard-snapshots to,
gaps ≤ 3 tolerated UDP-style, larger gaps accepted and counted in the
room stats), the anti-cheat clamp (`move` clamped to unit length and
quantized to 3 decimals server-side — a client sending `(1000, 1000)`
walks at `MAX_SPEED` like everyone else, and the position never
arrives because the message has no position field and `extra="forbid"`
rejects one), `face` derived from `move` when absent, `act` coerced,
and `received_at_tick` stamped by the server, never the client. The
room's per-tick input queue caps at `4 x len(seats)` (overflow drops
the sender's oldest frame, `inputs_dropped` bumped), a `DISCONNECTED`
seat's frames are silently ignored, a spectator's get `not_seated`,
and `Room.stats()` carries the input counters. A wall-clock tick too
short to fill one substep keeps the queue, so a frame is never
dropped without being applied. The rules are in `docs/PROTOCOL.md`
("Input rules") and driven by real sockets in
`tests/integration/test_ws_input.py` (the hostile-move bound included).
- **The snapshot stream (phase 06, task 04).** `PLAYING` now broadcasts:
quantized snapshots (an explicit field map — no `pickle`/`asdict`; the
grid, station ids, spawn coordinates and the round seed never leave
the server) at `SNAPSHOT_HZ` for players and half that for
spectators, encoded once per (frame, audience) and fanned out across
every connection of it. The stream runs in delta mode (only the
changed entities, plus a `gone` tombstone for each vanished one; a
typical idle-kitchen delta is ≥ 40% smaller than a full), with a
full frame on a fresh connection's first frame, on a dropped
snapshot's next frame, and on the full cadence (every 20 encoded
frames), so a client can always resync cheaply. The boundary's
events ride the frame (the stable `k` names, an explicit short-key
payload map, capped at 24 per frame with `events_dropped` on
overflow); outside `PLAYING` they flush as the standalone `ev`
message, and the `countdown` / `round` state frames reach every
connection. `UNDERCOOKED_SIM_LATENCY_MS` applies the server-side send
delay the netcode tests use to simulate RTT. The shape, the delta
rules and a captured frame are `docs/PROTOCOL.md` ("The `snap`
frame"), the no-leak/quantization guard is
`tests/unit/test_encoding_no_leak.py`, and the budgets (a 4-player /
4-order frame ≤ 1.5 KB, a 64×64 level ≤ 4 KB, the 100-tick downlink
≤ 60 KB/s) are measured over live sockets in
`tests/integration/test_encoding.py`.
- **Disconnects, the ghost, and the rejoin (phase 06, task 05).** A
dropped socket (an abrupt close, a heartbeat timeout, or the
test-only `kill_socket` seam) keeps the seat for
`UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (default 45 s): the other
players see one `ghost_on` event and a `connected: false` seat, the
sim's cook switches to `disconnected`, and after 3 s the cook's
carried item is released — onto the station on the cook's tile (a
marked set-down, grabbable), or into the `lost_items` counter when
no station is under the tile (an item is never deleted silently). A
`hello` with the same token inside the window rejoins the same seat
(`welcome.rejoined: true`, one `ghost_off`, the snapshot stream
resumes within one tick); past it the seat is abandoned (one
`ghost_off`, the spectate/join rules apply, an emptied room closes
through the 30 s seat grace / the GC). The client keeps the socket
honest with a `ping` at ≥ 5 Hz; 12 s of inbound silence is a dead
client (the server closes `1000` — the room sees the same drop as an
abrupt close), and a closing room closes its sockets with
`err room_gone` + close `4401`, so a client can tell "the room
died" apart from "you left" (`1000`). The grace, the park, the
ghost events and the close-code decision table are
`docs/PROTOCOL.md` ("Heartbeats, drops, and seat grace"); the
park's rules are `sim/ghost.py` (driven in
`tests/unit/test_ghost_parking.py`), the drop/rejoin/seam paths in
`tests/integration/test_ws_disconnect.py`.
- **The protocol, tested (phase 06, task 06).** The wire contract's
final proof is three real-socket suites in `tests/integration/`:
`test_ws_round_two_clients.py` (two scripted clients play a complete
round — the countdown, the `snap` stream, the scripted soup, the
shared `served` event and the byte-identical `round` frames, a
mid-round spectator, the `to_lobby` return), `test_ws_abuse.py`
(2000 malformed frames, the 500-message burst, the 64 KB refusal /
4 KB boundary, the host-check and emote-throttle flood, 200
concurrent handshakes) and `test_ws_budgets.py` (`slow`: the tick
cost over 400 real ticks, the `snap` size, the downlink rate, the
input→snapshot echo). The `live_server` / `ws_client` fixtures
(`tests/integration/conftest.py`) drive them — a real `uvicorn`
server on an ephemeral loopback port and a scripted client with a
merged snapshot view (`expect` / `collect` / `send_model`). The
contract, the test seams and the budget table with the measured
values are `docs/PROTOCOL.md` ("Testing the protocol").
- **The rules are tests, not promises.** The no-external-asset rule is
`tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is
`tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by
`tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
phase pipeline runs after every task.
What does not exist yet: the hazards (phase 10), the
WebSocket
protocol (06 — the lobby buttons answer "Not implemented until phase 07.") and the
playable Canvas renderer (07). Execute phases with the `phased-execution` skill
What does not exist yet: the hazards (phase 10) and the
in-browser
playable Canvas renderer (07 — the phase-06 WebSocket transport, including
the snapshot stream, is live but the client still answers "Not
implemented until phase 07."). Execute phases with the
`phased-execution` skill
(`auto-phase.sh`); it always takes the lowest-numbered directory still in
`.agents/phases/todo/`.
@@ -245,13 +362,18 @@ keeps only a random per-tab token and the last code you typed, both in `sessionS
The campaign does remember per token — unlocks and best scores live server-side,
in memory, and a server restart clears all progression (A6: no database).
What actually works today is the shell of that plus the server-side room shell:
What actually works today is the shell of that plus the server-side room
shell and the phase-06 WebSocket transport:
the page loads, normalises a typed code to five uppercase alphanumerics (codes
themselves are Crockford base32, minted by the room supervisor — `server/rooms.py`,
phase 05, which is live and testable over `POST /api/rooms` / `GET /api/rooms/{code}` /
`POST /api/rooms/{code}/join` / `GET /api/session`), and the lobby buttons still answer "Not implemented
until phase 07." until the phase-06 WebSocket transport and the phase-07 client
wiring replace them. The ready-up, the 20 Hz tick ownership, the session store
`POST /api/rooms/{code}/join` / `GET /api/session`), a `WS /ws/{code}`
client can already create a room over HTTP, handshake (`hello` → `welcome`
+ `lobby`), ready up and keep alive — the full contract, including the
guardrails and the close codes, is `docs/PROTOCOL.md` — but the lobby
buttons in the page still answer "Not implemented
until phase 07." until the phase-07 client
wiring replaces them. The ready-up, the 20 Hz tick ownership, the session store
(the unlocks and best scores, in memory — a server restart clears all
progression) and the lifecycle integration suite are live. Movement keys and
touch controls are defined by the phase-07/08 client and documented in
@@ -388,6 +510,8 @@ every variable with its default; tests keep both in sync with `config.py`.
| `UNDERCOOKED_MAX_MESSAGE_BYTES` | `4096` | Hard cap on inbound WS frame size |
| `UNDERCOOKED_MAX_INPUTS_PER_S` | `40` | Per-connection inbound message rate limit |
| `UNDERCOOKED_SIM_LATENCY_MS` | `0` | Injected server-side downlink latency (netcode tests) |
| `UNDERCOOKED_HELLO_TIMEOUT_S` | `5` | A WebSocket that sends no `hello` within this many seconds is closed (code 4400) |
| `UNDERCOOKED_TEST_MODE` | `0` | `1` = register the test-only seam route (`POST /api/test/kill_socket/...`); never set in production |
| `UNDERCOOKED_ALLOWED_ORIGINS` | *(empty)* | Comma-separated `Origin`s accepted on the WebSocket handshake; empty = same-host only |
| `DEBUGPY` | `0` | `1` = open a debugpy listener on `DEBUGPY_PORT` at startup |
| `DEBUGPY_PORT` | `5678` | debugpy listen port |
@@ -460,9 +584,13 @@ tests/ unit/, integration/, e2e/ (one Playwright suite per stor
levels; `_bad/` holds the deliberately-broken campaign the `_`
discovery rule ignores); conftest.py
docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract),
SIMULATION.md (the tick contract), TESTING.md (how this project
is verified), DEPLOYMENT.md; PROTOCOL / CLIENT land with their
own phases (the index is PLAN.md §13)
SIMULATION.md (the tick contract), ROOMS.md (rooms &
lobby lifecycle), PROTOCOL.md (the v1 wire contract —
message tables, error/close codes, limits, the full-round
sequence, the test seams and the budget table), TESTING.md
(how this project
is verified), DEPLOYMENT.md; CLIENT lands with
phase 07 (the index is PLAN.md §13)
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
```