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:
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user