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
+9 -5
View File
@@ -111,7 +111,8 @@
`server/headers.py` to **every** response, including errors. FastAPI's `/docs`,
`/redoc` and `/openapi.json` stay disabled because their bundled HTML pulls Swagger
assets from `cdn.jsdelivr.net`; the wire contract lives in `docs/PROTOCOL.md`
(written by phase 06) instead.
(written by phase 06; its message tables are kept equal to
`server/protocol.py`'s models by `tests/unit/test_protocol_doc_sync.py`) instead.
- **Docs are guarded like code.** `tests/unit/test_docs_commands.py` requires every
command line in a `bash` fence of `README.md`/`docs/*.md` to start with a known-safe
prefix (`uv `, `podman `, `curl `, `cd `, `bash .agents/validate.sh`, `#`, or a
@@ -180,17 +181,20 @@
| Configuration (env vars → typed `Settings`), debugpy bootstrap | `src/undercooked/config.py`, `.env.example`, `src/undercooked/debug.py` |
| Pure game rules (movement, stations, orders, scoring, hazards) | `src/undercooked/sim/` |
| The fixed-step tick: `step`/`build_state`, the seeded RNG, the subsystem hooks (contract: `docs/SIMULATION.md`) | `src/undercooked/sim/step.py`, `state_build.py`, `rng.py`, `movement.py`, `interact.py`, `carry.py`, `stations.py`, `freshness.py`, `orders.py`, `hazards.py`, `round_state.py` |
| Movement/collision: velocity integration, facing, stun, soft push, and the pure geometry under it (what blocks, the swept stop, the body footprint) | `src/undercooked/sim/movement.py`, `src/undercooked/sim/collision.py` (rules in `docs/SIMULATION.md` §"Movement and collision") |
| Movement/collision: velocity integration, facing, stun, soft push, the ghost park (a disconnected cook's stale input does not move it; a stun that runs out on a disconnected cook revives it `DISCONNECTED`, not `IDLE`), and the pure geometry under it (what blocks, the swept stop, the body footprint) | `src/undercooked/sim/movement.py`, `src/undercooked/sim/collision.py` (rules in `docs/SIMULATION.md` §"Movement and collision") |
| The action button: reach-zone targeting, the verb ladder, named `DENIED` reasons, the one-press-one-event gate, contested arbitration and the long-press trash | `src/undercooked/sim/interact.py` (rules in `docs/SIMULATION.md` §"The action button") |
| The hands: one-item-per-hand carry, the marked set-down and the counter hand-off, the always-available long-press trash (contract: `docs/SIMULATION.md` §"The hand") | `src/undercooked/sim/carry.py` |
| The ghost: what a dropped cook's state and hands do (`sim/ghost.py` — the sim's half, pure; the wall-clock grace is the room's): `mark_ghost` (→ `connected=False`, `DISCONNECTED`; idempotent), `restore_player` (→ `connected=True`, `IDLE`; keeps a still-carried item; idempotent), `release_ghost_carry` (the park's release — once, idempotent: onto the station on the cook's tile via `place_item`/`DROPPED`, or into `counters["lost_items"]` + `GHOST_PARK`; an item is never deleted silently), `station_at`, `PARK_AFTER_S = 3.0` (guards: `tests/unit/test_ghost_parking.py`; the room's half in `tests/integration/test_ws_disconnect.py`) | `src/undercooked/sim/ghost.py` |
| The station pipeline: the acceptance matrix, the transition timers, the worker hold and release, dirty props and the plate/pot stock (contract: `docs/SIMULATION.md` §"The station pipeline") | `src/undercooked/sim/stations.py` |
| Freshness (stage 4b): the burn countdown (tick-exact, 8.0 s), the board's linear decay (20 s), the half-rate carry, the burnt `WASTED` push-out (a usable raw is never overwritten), and the dirty loop's stock (use dirties, a wash cleans, `…_clean + …_dirty` conserved; the no-dead-end guarantee) (contract: `docs/SIMULATION.md` §"Freshness") | `src/undercooked/sim/freshness.py` |
| Serve resolution and scoring: the dish's named rejections (`NO_ORDERS`/`WRONG_RECIPE`/`INCOMPLETE`/`BURNT`/`NO_TARGET`), FIFO order matching, `round(base × quality × streak × time_bonus)` with the capped streak and the deadline share, the miss penalty and streak reset, the per-player contribution (rules: `docs/SIMULATION.md` §"Serve scoring") | `src/undercooked/sim/scoring.py` |
| Level/recipe content | `levels/*.yaml` |
| Content schema, ASCII-grid parser, content loader, playability validator (contract: `docs/LEVELS.md`) | `src/undercooked/sim/recipe.py`, `level.py`, `content.py`, `validation.py` |
| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py` (the supervisor: registry, capacity, the one-token-one-room index, the 5 s GC, `start`/`shutdown`), `room.py` (the guarded FSM: the `RoomState`/`ROOM_TRANSITIONS` table, the round data, the per-tick input queue, the one owned deadline-scheduled tick task and its crash isolation, `tick_once` — contract: `docs/ROOMS.md`), `clock.py` (injectable time — the only `server/` module that imports `time`; a grep test keeps the wall clock out of the rest), `codes.py` (Crockford code minting + input normalisation), `seats.py` (the lobby's data: `Seat`/`Spectator`, the name boundary, the 4-colour palette, the host handoff, the emote whitelist, the named lobby refusals), `routes_rooms.py` (`POST /api/rooms`, `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join`, `GET /api/session` — the lifecycle over HTTP before the phase-06 transport), `sessions.py` (`SessionRecord` + `BestRun` + `mint_token` + the bounded LRU `SessionStore`: `record_result`'s win-only unlock bump (monotonic), best-score replacement, `is_unlocked` / `next_level_id`, `to_public` — in memory; a process restart clears all progression) |
| Wire messages + validation | `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md` |
| HTTP app (`/`, `/healthz`, `/static`), lifespan, startup content gate | `src/undercooked/server/app.py` |
| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py` (the supervisor: registry, capacity, the one-token-one-room index, the 5 s GC, `start`/`shutdown` — a room created while the GC loop is running already gets its tick task), `room.py` (the guarded FSM: the `RoomState`/`ROOM_TRANSITIONS` table, the round data, the per-tick input queue (capacity `INPUT_QUEUE_PER_SEAT x len(seats)`, overflow drops the sender's oldest frame, a zero-substep tick keeps the queue so a frame is never dropped without being applied), `stats()` (the lobby/round counters plus `inputs_received`/`inputs_dropped`/`stale_seqs`/`clamped_moves`/`large_seq_gaps`), the one owned deadline-scheduled tick task and its crash isolation, `tick_once`, the ghost and seat-grace verbs (`on_disconnect` — mark + one `GHOST_ON` + the sim switch, a no-op if already a ghost; `on_reconnect` — mark + one `GHOST_OFF` only when it was a ghost + the sim restore; `_park_ghosts` — the per-`PLAYING`-tick park off `seat.last_seen`, one release per token per round; `_abandon_expired`/`_abandon_seat` — the `SeatAbandoned` at `guest_idle_timeout_s` in every state: forced release + `GHOST_OFF` + the seat out; the `on_close` hook awaited by `close()`) — contract: `docs/ROOMS.md`), `clock.py` (injectable time — the only `server/` module that imports `time`; a grep test keeps the wall clock out of the rest), `codes.py` (Crockford code minting + input normalisation), `seats.py` (the lobby's data: `Seat`/`Spectator`, the name boundary, the 4-colour palette, the host handoff, the emote whitelist, the named lobby refusals), `routes_rooms.py` (`POST /api/rooms`, `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join`, `GET /api/session` — the lifecycle over HTTP before the phase-06 transport), `sessions.py` (`SessionRecord` + `BestRun` + `mint_token` + the bounded LRU `SessionStore`: `record_result`'s win-only unlock bump (monotonic), best-score replacement, `is_unlocked` / `next_level_id`, `to_public` — in memory; a process restart clears all progression) |
| Wire messages + validation | `src/undercooked/server/protocol.py` (the message models, `parse_client_message` — never raises, the `ErrorCode`/`CloseCode` constants, `MESSAGE_WHITELIST`), `docs/PROTOCOL.md` (the contract in prose; `tests/unit/test_protocol_doc_sync.py` keeps the two equal) |
| The snapshot encoder (task 04): `encode_snapshot` (the PLAN §4 shape, quantized, explicit field maps per type — no `pickle`/`asdict`; no `token`/`display_name`/`seed`/`rng_state`/station-internal-id ever reach the wire), the delta mode (diff vs the previous frame, `gone` tombstones), `encode_events` (the stable `k` names + the `EVENT_PAYLOAD_SHORT` map, `MAX_EVENTS_PER_FRAME = 24`), `snapshot_size` (the UTF-8 budget hook; a 4p/4o/3h frame ≤ 1.5 KB, a 64×64 level ≤ 4 KB), the `SnapshotFanout` (encode-once/fan-out-many, the per-audience `prev` + the `FULL_SNAPSHOT_EVERY_TICKS = 20` cadence, the `needs_full` resync, the non-destructive event cursor, the `events_dropped` count) in `server/ws.py`, the room's pacing seam (`_snapshot_pacing_tick`, the seq-tagged `event_buffer`, the `countdown`/`round`/`ev` broadcasts) in `server/room.py` (contract: `docs/PROTOCOL.md` "The `snap` frame"; guards: `tests/unit/test_encoding_no_leak.py`, `tests/integration/test_encoding.py`) | `src/undercooked/server/encoding.py` |
| The WebSocket transport: the pre-accept origin check, the `hello` handshake (timeout → `4400`), the `p` gate, the supervisor seat/spectate (`4401`/`4402`; a `CLOSED` room is an `err room_gone` + close `4401` before any seat), the `welcome`'s `rejoined` flag (judged before the `join`: a token that holds a *disconnected* seat is a rejoin), the `ping`/`pong` keep-alive (any inbound frame refreshes `last_seen`) and the heartbeat watchdog (`HEARTBEAT_DEAD_S = 12` s of silence → the server closes `1000` and the standard teardown runs — the room sees the same drop as a hard close), the `input` ingestion (the `seq` discipline: duplicates dropped, stale → `err` + a `resync` event, gaps ≤ 3 tolerated, larger gaps accepted and counted; `move` clamped to unit length and quantized, `face` derived, `act` coerced, `received_at_tick` stamped server-side; a `DISCONNECTED` seat's frames are silently ignored, spectators get `not_seated`), the per-connection guardrails (size cap → `too_big`, the token bucket → `rate_limited`, the shape check, the per-state whitelist → `wrong_state`; 3 consecutive rate/oversize or 5 shape violations → `4404`; a *binary* frame is a frame the cap and the parser apply to — a `too_big` / a `bad_json`, never a stack exception), one-socket-per-token eviction (`4403`, the evicted socket never marks the seat disconnected), the capped send queue (the oldest snapshot drops first; `lobby`/`round`/`err` never; a `rate_limited` coalesces to one) and the once-only `on_disconnect` teardown | `src/undercooked/server/ws.py` (the `Connection`/`ConnectionRegistry`/`TokenBucket`/`AbuseTracker`, the `seq_verdict`/`to_player_input` input path, the `pump_out`/`read_in`/`heartbeat_watchdog` tasks, the registry's census (`stats()`: `sockets`, the `send_queue_max` high-water mark, `memory_mb` — the phase-06 abuse/budget numbers); contract: `docs/PROTOCOL.md` incl. the manual probe, "Heartbeats, drops, and seat grace" and "Testing the protocol" (the test seams + the budget table with the measured values); `tests/integration/test_ws_handshake.py` drives every guardrail over live sockets, `tests/integration/test_ws_input.py` the input rules over live sockets, `tests/integration/test_ws_disconnect.py` the heartbeat/disconnect/grace/rejoin end to end, `tests/integration/test_ws_round_two_clients.py` the complete two-client round (the `live_server`/`ws_client` fixtures, the `levels_round` campaign), `tests/integration/test_ws_abuse.py` the guardrails at scale (2000 malformed frames, the 500-burst, the 64 KB / 4 KB size cap, the host-check flood, 200 handshakes), `tests/integration/test_ws_budgets.py` the `slow` budgets (tick cost over 400 real ticks, `snap` size, downlink, echo), `tests/unit/test_send_queue_policy.py` the queue policy + bucket arithmetic, `tests/unit/test_ws_input_path.py` the ingestion unit) |
| HTTP app (`/`, `/healthz`, `/static`), lifespan, startup content gate, the `room_close` hook (a closing room closes its own sockets with `err room_gone` + close `4401`) and the test-only seam `POST /api/test/kill_socket/{code}/{token}` (registered only when `UNDERCOOKED_TEST_MODE=1`) | `src/undercooked/server/app.py` |
| Content HTTP API (`/api/levels`, `/api/levels/{id}`, `/api/config`) + strict response models | `src/undercooked/server/routes_content.py` |
| Author CLI `undercooked-levels` (`validate`, `list`, `show`, `new`, `axes`); workflow in `docs/LEVELS.md` | `src/undercooked/tools/levels.py` |
| The replay harness: input scripts, golden hashes, `run_script`/`compare`, the `record`/`check` CLI (contract: `docs/SIMULATION.md` "Replaying and re-blessing goldens") | `src/undercooked/sim/replay.py`, `tests/fixtures/replays/`, `tests/goldens/`, `tests/sim/test_replay.py`, `tests/sim/test_perf_smoke.py` (`slow`) |