From f26849c1f30f0b5923ace04382af5afe9033080c Mon Sep 17 00:00:00 2001 From: ducoterra Date: Fri, 9 Oct 2026 11:13:25 -0400 Subject: [PATCH] phase: 06_realtime_websocket_protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. --- .../00_phase.md | 0 .../01_protocol_models_and_wire_contract.md | 0 .../02_ws_endpoint_and_handshake.md | 0 .../03_input_ingestion_and_validation.md | 0 .../04_snapshot_and_event_encoding.md | 0 .../05_heartbeat_disconnect_and_seat_grace.md | 0 .../06_websocket_integration_suite.md | 0 AGENTS.md | 14 +- README.md | 162 +- docs/PROTOCOL.md | 907 ++++++++++ docs/ROOMS.md | 54 +- docs/TESTING.md | 2 +- src/undercooked/config.py | 19 + src/undercooked/server/app.py | 122 +- src/undercooked/server/encoding.py | 390 ++++ src/undercooked/server/protocol.py | 616 +++++++ src/undercooked/server/room.py | 512 +++++- src/undercooked/server/rooms.py | 34 +- src/undercooked/server/ws.py | 1583 +++++++++++++++++ src/undercooked/sim/ghost.py | 156 ++ src/undercooked/sim/model.py | 4 + src/undercooked/sim/movement.py | 21 +- .../levels_playable/01_first_kitchen.yaml | 45 + tests/fixtures/levels_playable/recipes.yaml | 80 + .../levels_round/01_first_kitchen.yaml | 59 + tests/fixtures/levels_round/recipes.yaml | 80 + tests/integration/conftest.py | 443 +++++ tests/integration/test_encoding.py | 870 +++++++++ tests/integration/test_room_fsm.py | 10 +- tests/integration/test_room_isolation.py | 13 +- tests/integration/test_ws_abuse.py | 450 +++++ tests/integration/test_ws_budgets.py | 318 ++++ tests/integration/test_ws_disconnect.py | 686 +++++++ tests/integration/test_ws_handshake.py | 477 +++++ tests/integration/test_ws_input.py | 517 ++++++ .../integration/test_ws_round_two_clients.py | 589 ++++++ tests/unit/test_docs_index.py | 1 + tests/unit/test_encoding_no_leak.py | 434 +++++ tests/unit/test_ghost_parking.py | 303 ++++ tests/unit/test_protocol_doc_sync.py | 247 +++ tests/unit/test_protocol_parse.py | 380 ++++ tests/unit/test_protocol_whitelist.py | 94 + tests/unit/test_send_queue_policy.py | 344 ++++ tests/unit/test_ws_input_path.py | 561 ++++++ 44 files changed, 11525 insertions(+), 72 deletions(-) rename .agents/phases/{todo => complete}/06_realtime_websocket_protocol/00_phase.md (100%) rename .agents/phases/{todo => complete}/06_realtime_websocket_protocol/01_protocol_models_and_wire_contract.md (100%) rename .agents/phases/{todo => complete}/06_realtime_websocket_protocol/02_ws_endpoint_and_handshake.md (100%) rename .agents/phases/{todo => complete}/06_realtime_websocket_protocol/03_input_ingestion_and_validation.md (100%) rename .agents/phases/{todo => complete}/06_realtime_websocket_protocol/04_snapshot_and_event_encoding.md (100%) rename .agents/phases/{todo => complete}/06_realtime_websocket_protocol/05_heartbeat_disconnect_and_seat_grace.md (100%) rename .agents/phases/{todo => complete}/06_realtime_websocket_protocol/06_websocket_integration_suite.md (100%) create mode 100644 docs/PROTOCOL.md create mode 100644 src/undercooked/server/encoding.py create mode 100644 src/undercooked/server/protocol.py create mode 100644 src/undercooked/server/ws.py create mode 100644 src/undercooked/sim/ghost.py create mode 100644 tests/fixtures/levels_playable/01_first_kitchen.yaml create mode 100644 tests/fixtures/levels_playable/recipes.yaml create mode 100644 tests/fixtures/levels_round/01_first_kitchen.yaml create mode 100644 tests/fixtures/levels_round/recipes.yaml create mode 100644 tests/integration/conftest.py create mode 100644 tests/integration/test_encoding.py create mode 100644 tests/integration/test_ws_abuse.py create mode 100644 tests/integration/test_ws_budgets.py create mode 100644 tests/integration/test_ws_disconnect.py create mode 100644 tests/integration/test_ws_handshake.py create mode 100644 tests/integration/test_ws_input.py create mode 100644 tests/integration/test_ws_round_two_clients.py create mode 100644 tests/unit/test_encoding_no_leak.py create mode 100644 tests/unit/test_ghost_parking.py create mode 100644 tests/unit/test_protocol_doc_sync.py create mode 100644 tests/unit/test_protocol_parse.py create mode 100644 tests/unit/test_protocol_whitelist.py create mode 100644 tests/unit/test_send_queue_policy.py create mode 100644 tests/unit/test_ws_input_path.py diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/00_phase.md b/.agents/phases/complete/06_realtime_websocket_protocol/00_phase.md similarity index 100% rename from .agents/phases/todo/06_realtime_websocket_protocol/00_phase.md rename to .agents/phases/complete/06_realtime_websocket_protocol/00_phase.md diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/01_protocol_models_and_wire_contract.md b/.agents/phases/complete/06_realtime_websocket_protocol/01_protocol_models_and_wire_contract.md similarity index 100% rename from .agents/phases/todo/06_realtime_websocket_protocol/01_protocol_models_and_wire_contract.md rename to .agents/phases/complete/06_realtime_websocket_protocol/01_protocol_models_and_wire_contract.md diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/02_ws_endpoint_and_handshake.md b/.agents/phases/complete/06_realtime_websocket_protocol/02_ws_endpoint_and_handshake.md similarity index 100% rename from .agents/phases/todo/06_realtime_websocket_protocol/02_ws_endpoint_and_handshake.md rename to .agents/phases/complete/06_realtime_websocket_protocol/02_ws_endpoint_and_handshake.md diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/03_input_ingestion_and_validation.md b/.agents/phases/complete/06_realtime_websocket_protocol/03_input_ingestion_and_validation.md similarity index 100% rename from .agents/phases/todo/06_realtime_websocket_protocol/03_input_ingestion_and_validation.md rename to .agents/phases/complete/06_realtime_websocket_protocol/03_input_ingestion_and_validation.md diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/04_snapshot_and_event_encoding.md b/.agents/phases/complete/06_realtime_websocket_protocol/04_snapshot_and_event_encoding.md similarity index 100% rename from .agents/phases/todo/06_realtime_websocket_protocol/04_snapshot_and_event_encoding.md rename to .agents/phases/complete/06_realtime_websocket_protocol/04_snapshot_and_event_encoding.md diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/05_heartbeat_disconnect_and_seat_grace.md b/.agents/phases/complete/06_realtime_websocket_protocol/05_heartbeat_disconnect_and_seat_grace.md similarity index 100% rename from .agents/phases/todo/06_realtime_websocket_protocol/05_heartbeat_disconnect_and_seat_grace.md rename to .agents/phases/complete/06_realtime_websocket_protocol/05_heartbeat_disconnect_and_seat_grace.md diff --git a/.agents/phases/todo/06_realtime_websocket_protocol/06_websocket_integration_suite.md b/.agents/phases/complete/06_realtime_websocket_protocol/06_websocket_integration_suite.md similarity index 100% rename from .agents/phases/todo/06_realtime_websocket_protocol/06_websocket_integration_suite.md rename to .agents/phases/complete/06_realtime_websocket_protocol/06_websocket_integration_suite.md diff --git a/AGENTS.md b/AGENTS.md index a79c8ca..94d932c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`) | diff --git a/README.md b/README.md index 7760df6..b0105af 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..b52fe72 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,907 @@ +# PROTOCOL.md — the wire contract (v1) + +## Purpose + +This document is the wire contract of the Undercooked server, in prose: what +the client sends, what the server answers, when each is legal, and what +happens when a frame is malformed, oversized, or out of its state. It is the +specification both sides of the WebSocket are built against — the client +(phases 07/08) reads it table by table, and the server's `protocol.py` is the +code of record for it. Phase 06's tasks have landed their sections as the +transport pieces did (input rules, the `snap` encoder, heartbeats and seat +grace); where a section still awaits its task it says so explicitly. Read +`docs/ROOMS.md` for the pre-socket HTTP lifecycle this contract rides on. + +The single source of the wire contract. The code of record is +`src/undercooked/server/protocol.py`; `tests/unit/test_protocol_doc_sync.py` +keeps every table in this document field-for-field equal to the models' +`model_fields` (and the error/close code tables equal to the code constants), +so neither can drift. **Change a wire field and this file moves in the same +task — and `PROTOCOL_VERSION` is bumped (see "Versioning policy").** + +The transport is a native WebSocket (`/ws/{room_code}`, PLAN A3) carrying +versioned JSON frames (PLAN A14). The HTTP surface (`/api/rooms`, +`/api/session`, `/api/levels`) is the pre-socket lifecycle +(`docs/ROOMS.md`); the socket is the live channel. + +## Framing + +- One JSON object per frame, UTF-8, ≤ `max_message_bytes` (default 4096; + `UNDERCOOKED_MAX_MESSAGE_BYTES`). A bigger frame is `too_big`. +- Every frame has a `type` string — the discriminator. Client→server + discriminators: `hello`, `ready`, `set_level`, `start`, `input`, `emote`, + `to_lobby`, `next_level`, `ping`. Server→client: `welcome`, `lobby`, + `countdown`, `snap`, `round`, `ev`, `err`, `pong`. +- The protocol version `p` travels **in the client's `hello` only**. The + server checks it there (outside `[MIN_SUPPORTED_VERSION, PROTOCOL_VERSION]` + → `protocol_mismatch`, close `4400`) and the connection is then bound to + the negotiated version. Every server→client frame *also* carries `p` so a + client can see what it is talking to. +- Unknown fields are **rejected, not ignored** (`extra="forbid"`). This is + intentional: a client and a server that disagree on shape must fail loudly + (`bad_field`), not silently misinterpret. It is also what makes + "no position is ever client-sent" trivially true — an `input` with a + `pos` field is a `bad_field`, full stop. +- `hello` and `ping` are legal in every room state (the handshake — including + a mid-round rejoin — and the keep-alive); everything else is legal only in + the states the per-state whitelist (the "Accepted when" column below, and + `protocol.py` `MESSAGE_WHITELIST`, the single source) allows. A message + outside its state is `wrong_state`. +- Per-connection rate limit: `max_inputs_per_s` (default 40; + `UNDERCOOKED_MAX_INPUTS_PER_S`) via a token bucket — over it is + `rate_limited` (task 02). One socket per token: a second `hello` with the + same token evicts the first (`evicted`, close `4403`, task 02). + +## Versioning policy + +- `p` is a single integer. The server accepts + `[MIN_SUPPORTED_VERSION, PROTOCOL_VERSION]` (today `1..1`); outside it the + handshake is refused with `protocol_mismatch` and close code `4400`. +- **Field changes bump `PROTOCOL_VERSION`.** Because unknown fields are a + rejection, *any* change to a message shape — added, removed, renamed, + re-typed — is a breaking change: bump `PROTOCOL_VERSION` (and + `MIN_SUPPORTED_VERSION` only if older clients must keep working), update + this file, and update the client in the same task (AGENTS.md "Protocol + changes need a version bump"). +- **Patch-compatible** (no bump): new *optional, additive* server→client + fields that a client may ignore, and new error-code *usages* of existing + codes. The client ignores unknown server fields; the server rejects unknown + client fields. +- A `hello` carrying an unsupported `p` is refused before anything else + happens on the socket — no seat is taken, no `welcome` is sent. + +## Sequence: handshake and a full round + +```text +browser A (host) server (one room) browser B + │ POST /api/rooms {name, token?} │ + │───────────────────────────────────────▶ │ + │ ◀── 201 {room_code, you:{token, seat, host}} ── │ + │ │ + │ WS /ws/{room_code} │ + │───────────────────────────────────────▶ accept │ + │ {"type":"hello","p":1,"token":…,"name":…} │ + │───────────────────────────────────────▶ seat A (0, host) │ + │ ◀── {"type":"welcome","you",…,"seats":[A]} ── │ + │ ◀── {"type":"lobby",…} ── │ + │ │ WS /ws/{room_code} + │ ◀─────────────────── B connects + │ ◀── {"type":"lobby",…} (re-broadcast) ────────────────────────▶ B + │ │ {"type":"hello","p":1,"token":B} + │ │ ◀── welcome + lobby + │ {"type":"ready"} (B) │ + │ ◀─────────────────── + │ ◀── {"type":"lobby",… ready:true …} ─────────────────────────▶ B + │ {"type":"start"} (A, host) │ + │───────────────────────────────────────▶ COUNTDOWN (3 s) │ + │ ◀── {"type":"countdown","n":3} 2 1 ────────────────────────▶ B + │ PLAYING (20 Hz) │ + │ {"type":"input","seq":1,"move","act","face"} │ + │───────────────────────────────────────▶ tick, snapshot out │ + │ ◀── {"type":"snap","raw":{…}} (SNAPSHOT_HZ) ────────────────▶ B + │ ◀── {"type":"ev","events":[…]} ───────────────────────────────▶ B + │ {"type":"ping","t":…} │ + │───────────────────────────────────────▶ │ + │ ◀── {"type":"pong","t":…,"now":…} ───────────────────────────▶ B + │ verdict + 1 s settle│ + │ ◀── {"type":"round","result","score",…} ─────────────────────▶ B + │ {"type":"next_level"} / {"type":"to_lobby"} │ + │───────────────────────────────────────▶ COUNTDOWN / LOBBY │ +``` + +## Client → Server messages + +| `type` | Model | Payload | Accepted when | +|---|---|---|---| +| `hello` | `HelloMsg` | `{p, token?, name?}` | always (handshake / rejoin) | +| `ready` | `ReadyMsg` | `{}` | `LOBBY` | +| `set_level` | `SetLevelMsg` | `{level}` | `LOBBY` (host only; level unlocked) | +| `start` | `StartMsg` | `{}` | `LOBBY` (host only) | +| `input` | `InputMsg` | `{seq, move, act, face}` | `PLAYING` | +| `emote` | `EmoteMsg` | `{id}` | `LOBBY`, `PLAYING` | +| `to_lobby` | `ToLobbyMsg` | `{}` | `ROUND_END` | +| `next_level` | `NextLevelMsg` | `{}` | `ROUND_END` | +| `ping` | `PingMsg` | `{t}` | always | + +### HelloMsg + +The handshake: identity (or anonymous → the server mints a token) and the +protocol version. `name` is sanitized server-side (control/format characters +stripped, whitespace collapsed, capped at 16) and is non-unique and +non-authoritative. + +| Field | Type | Notes | +|---|---|---| +| `type` | `"hello"` | discriminator | +| `p` | int | protocol version; outside `[1, 1]` → `protocol_mismatch`, close `4400` | +| `token` | str \| null | 1–128 chars; the session token (PLAN A16), null = mint one | +| `name` | str \| null | 0–64 chars pre-sanitize; the display name | + +### ReadyMsg + +The ready-up (US-03): flips the caller's seat `ready` on (a toggle — sending +again flips it off; the `lobby` frame carries the flag). + +| Field | Type | Notes | +|---|---|---| +| `type` | `"ready"` | discriminator | + +### SetLevelMsg + +The host's level pick (PLAN §6 "limited to unlocked"). The id must be in the +room's campaign (`bad_field` if not) and unlocked by the host's session +(`level_locked` if not). + +| Field | Type | Notes | +|---|---|---| +| `type` | `"set_level"` | discriminator | +| `level` | str | 1–64 chars; a campaign level id (e.g. `01_first_kitchen`) | + +### StartMsg + +The host's *Start* (US-03). Preconditions (a selected level; a solo host, or ≥ 1 +ready non-host) are the room's — a refusal is an `err` whose `msg` names the +reason (`no_level` / `no_ready_players`). + +| Field | Type | Notes | +|---|---|---| +| `type` | `"start"` | discriminator | + +### InputMsg + +One frame of intent, sent on change plus a keep-alive at ≥ 5 Hz (PLAN §4). +The sim clamps the magnitude again (`movement.intent_direction()`); the wire +rejection is the early, explicit half of that rule. `seq` semantics (drop/resync, gaps) are the "Input rules" section +below. + +| Field | Type | Notes | +|---|---|---| +| `type` | `"input"` | discriminator | +| `seq` | int | ≥ 0; the client's monotonically increasing counter | +| `move` | object | `Vec2Msg` — an intent direction, magnitude ≤ 1 | +| `act` | bool | the primary action (use/pick/drop) press this frame | +| `face` | `"up"` \| `"right"` \| `"down"` \| `"left"` \| null | explicit facing; null = derive from `move` | + +### EmoteMsg + +An emote (PLAN §7: no chat in v1, emote whitelist). `id` must be in +`seats.EMOTES` — `wave`, `cheer`, `thumbs_up`, `thumbs_down`, `angry`, +`star` — checked server-side (an unknown id is `bad_field` with the whitelist +in the message), throttled to 2 per 1.0 s per connection, and re-broadcast to +everyone as an `ev` event. + +| Field | Type | Notes | +|---|---|---| +| `type` | `"emote"` | discriminator | +| `id` | str | 1–32 chars; one of the six whitelisted emotes | + +### ToLobbyMsg + +Return to the lobby after a round (PLAN §6): ready flags reset, waiting +spectators are promoted into free seats. + +| Field | Type | Notes | +|---|---|---| +| `type` | `"to_lobby"` | discriminator | + +### NextLevelMsg + +Start the next campaign level after a round (PLAN §6 "next-level"). The level +id is resolved server-side from the viewer's session (`Room.next_level_id`): +after a win it is `order + 1`'s level, after a loss the level just played. A +"retry" is a `next_level` whose resolution is the level just played — the +message carries no payload by design. The host's *pick* in the lobby is +`set_level`. + +| Field | Type | Notes | +|---|---|---| +| `type` | `"next_level"` | discriminator | + +### PingMsg + +Keep-alive / RTT probe; answered with `pong`. + +| Field | Type | Notes | +|---|---|---| +| `type` | `"ping"` | discriminator | +| `t` | int | ≥ 0; the client's timestamp, echoed back in `pong` | + +## Server → Client messages + +| `type` | Model | When | +|---|---|---| +| `welcome` | `WelcomeMsg` | after `hello` | +| `lobby` | `LobbyMsg` | on every lobby-state change (and once in `welcome`'s wake) | +| `countdown` | `CountdownMsg` | `COUNTDOWN`, one per whole second (3, 2, 1) | +| `snap` | `SnapMsg` | `PLAYING`, at `SNAPSHOT_HZ` (players) / half (spectators) | +| `round` | `RoundMsg` | `ROUND_END` (after the 1 s settle) | +| `ev` | `EvMsg` | any — the FX/log batch for the boundary | +| `err` | `ErrMsg` | on rejection | +| `pong` | `PongMsg` | after `ping` | + +### WelcomeMsg + +The handshake answer: who you are in this room, which room, its state, the +selected level, the seat list. + +| Field | Type | Notes | +|---|---|---| +| `type` | `"welcome"` | discriminator | +| `p` | int | the server's protocol version | +| `you` | object | `YouWire` — your own seat | +| `room` | str | the room code | +| `state` | str | the room state (`lobby`/`countdown`/`playing`/`round_end`/`closed`) | +| `level` | str \| null | the selected level id, or null for none | +| `seats` | object[] | `SeatWire` per occupied seat, in seat order | +| `rejoined` | bool | `true` when this `hello` reclaims a *disconnected* seat (the rejoin inside the grace, task 05); a fresh join and a duplicate `hello` on a live socket are `false` | + +### LobbyMsg + +The lobby state (the body `Room.lobby_payload` builds, phase 05): sent on every +change and once right after `welcome`, so a joiner has the full picture. + +| Field | Type | Notes | +|---|---|---| +| `type` | `"lobby"` | discriminator | +| `p` | int | the server's protocol version | +| `code` | str | the room code | +| `state` | str | the room state | +| `level` | str \| null | the selected level id | +| `host_seat` | int \| null | the crown holder's seat index, or null | +| `can_start` | bool | whether a start pressed now would be accepted | +| `seats` | object[] | `SeatWire` per occupied seat, in seat order | +| `spectators` | object[] | `SpectatorWire` per watching token, join order | +| `unlocked` | int \| null | the *viewer's* `unlocked_level` (null for an anonymous viewer) | +| `round_no` | int | how many rounds this room has started | + +### CountdownMsg + +One per whole second of the 3 s countdown: `n` = 3, then 2, then 1. + +| Field | Type | Notes | +|---|---|---| +| `type` | `"countdown"` | discriminator | +| `p` | int | the server's protocol version | +| `n` | int | whole seconds left (3 → 2 → 1) | + +### SnapMsg + +A snapshot frame (PLAN §4): encoder-owned — the model wraps the +compacted, quantized snapshot in `raw`. Note the two `p`s: **this** +message's `p` is the protocol version; `raw`'s `p` field is its *players* +array, nested one level down. The shape, the delta mode, the pacing and +the size budgets are the "The `snap` frame" section below. + +| Field | Type | Notes | +|---|---|---| +| `type` | `"snap"` | discriminator | +| `p` | int | the server's protocol version | +| `raw` | object | the snapshot body — see "The `snap` frame" below | + +### RoundMsg + +The verdict after the 1 s settle: `sim/round_state.py` `result_payload`'s exact +keys, plus `unlocked`. `players` is per-seat contribution, display only +(PLAN §5 — never competitive gatekeeping). + +| Field | Type | Notes | +|---|---|---| +| `type` | `"round"` | discriminator | +| `p` | int | the server's protocol version | +| `result` | `"won"` \| `"lost"` \| `"aborted"` \| null | the verdict (null before the verdict) | +| `score` | int | the round's final score | +| `target` | int | the level's `target_score` | +| `stars` | int | 0–3, from the remaining clock share (PLAN §5) | +| `time_left` | float | seconds, frozen at the verdict | +| `counters` | object | `served` / `missed` / `wasted` counts (0 where never written) | +| `longest_streak` | int | the round's high-water mark of consecutive serves | +| `players` | object[] | `PlayerScoreWire` per seat, in seat order | +| `level_id` | str | the level that was played | +| `seed` | int | the round's seed (replayable) | +| `ticks` | int | how many sim ticks the round ran | +| `unlocked` | str \| null | the viewer session's next level id (null when none/unknown) | + +### EvMsg + +A batch of transient events for the boundary — `chopped`, `burnt`, +`served`, an emote, a `denied` nudge, an `err` note, … FX/log only, +never game truth. The batch caps at 24 events per frame +(`MAX_EVENTS_PER_FRAME`); the overflow counts in +`Room.stats()["events_dropped"]`. The events' short-keyed payload map is +the "Events onto frames" subsection of "The `snap` frame" below. + +| Field | Type | Notes | +|---|---|---| +| `type` | `"ev"` | discriminator | +| `p` | int | the server's protocol version | +| `events` | object[] | `EventWire` per event, in occurrence order | + +### ErrMsg + +A rejection. `code` is a stable `ErrorCode` value (see "Error codes"); `msg` is +human-readable; `seq` — when the rejection refers to an `input` frame — is +that frame's `seq`, which is what the client resyncs off (task 03). + +| Field | Type | Notes | +|---|---|---| +| `type` | `"err"` | discriminator | +| `p` | int | the server's protocol version | +| `code` | str | one of the `ErrorCode` values | +| `msg` | str | human-readable explanation | +| `seq` | int \| null | the offending `input` frame's `seq`, when applicable | + +### PongMsg + +The answer to a `ping`: `t` echoed, `now` is the server's current time in +seconds (the room's injected `Clock`) — the RTT is `now - t` (plus the +one-way path, which a client estimates from the delta). + +| Field | Type | Notes | +|---|---|---| +| `type` | `"pong"` | discriminator | +| `p` | int | the server's protocol version | +| `t` | int | the client's `ping` timestamp, echoed | +| `now` | float | the server's current time, seconds | + +## Wire objects + +### Vec2Msg + +A movement intent (the `input` `move`). Both coordinates finite, magnitude +`hypot(x, y)` ≤ 1 — an intent direction, not a speed: the sim clamps the same +way (`movement.intent_direction()`), so a client sending `(50, 0)` is a +`bad_field` on the wire and walks at `MAX_SPEED` at worst in the sim. + +| Field | Type | Notes | +|---|---|---| +| `x` | float | finite, with `y` magnitude ≤ 1 | +| `y` | float | finite, with `x` magnitude ≤ 1 | + +### YouWire + +The viewer's own seat (as `welcome`'s `you`): the token is the viewer's own, +echoed; it never appears anywhere else. + +| Field | Type | Notes | +|---|---|---| +| `token` | str | the viewer's session token (echoed once) | +| `seat` | int | the viewer's seat index | +| `host` | bool | whether the viewer's seat holds the crown | + +### SeatWire + +One seat as the lobby shows it — no token, ever (PLAN §11). + +| Field | Type | Notes | +|---|---|---| +| `index` | int | 0-based seat index | +| `name` | str | the sanitized display name | +| `color` | str | the palette colour (one of the four) | +| `ready` | bool | the ready-up flag | +| `host` | bool | the crown flag | +| `connected` | bool | a dropped seat stays, `connected: false` | + +### SpectatorWire + +A watching token (PLAN §6 "SPECTATING"): no seat, not in the sim. + +| Field | Type | Notes | +|---|---|---| +| `name` | str | the sanitized display name | +| `color` | str | the palette colour | + +### EventWire + +One transient event (`sim/model.py` `Event`, projected): the `EventKind` value +by its short name, its position in tile units, and the payload (short keys — +e.g. a `served` event carries the order id as `i`). + +| Field | Type | Notes | +|---|---|---| +| `kind` | str | the `EventKind` value (e.g. `served`, `burnt`, `emote`, `err`) | +| `x` | float | tile units (0 for HUD-level events) | +| `y` | float | tile units (0 for HUD-level events) | +| `payload` | object | short-keyed extras (e.g. `{"i": 17}`) | + +### PlayerScoreWire + +One seat's contribution for the result screen (display only). + +| Field | Type | Notes | +|---|---|---| +| `id` | str | the seat's sim player id | +| `name` | str | the seat's display name | +| `score` | int | this seat's contribution to `score` | +| `dishes_served` | int | how many of the served dishes this seat made | +| `missed_contrib` | int | how many missed orders count against this seat | + +## The `snap` frame (encoder-owned) + +The `raw` body of a `snap` message, built by `server/encoding.py` (an +explicit field map per type — no `pickle`, no `dataclasses.asdict`). The +projection is quantized: positions to 2 decimals (`Vec2.quantized(2)`), +progress/quality/life to 2 decimals, enums to short strings. A frame is a +projection of one `KitchenState` — the same state always encodes to the +same frame (a replay's frames are replayable too), and it is the single +source of truth for the client's interpolation. + +**Nothing internal leaves the server.** A frame carries no `token` field, +no `display_name`, no round `seed`, no `rng_state`, and no station +internal `id` — a station's wire identity is its kind plus coordinates +(`k` + `x`/`y`), and an event's payload reaches the wire only through the +explicit short-key map below (a key that is not listed there never +leaves). `tests/unit/test_encoding_no_leak.py` checks this recursively on +full and delta frames alike; `snapshot_size(frame)` (the UTF-8 length of +the frame's JSON) is the budget hook. + +#### The shape (full frame) + +| `raw` key | Type | Meaning | +|---|---|---| +| `t` | int | the sim tick number | +| `s` | int | the score | +| `tgt` | int | the target score | +| `tl` | float | time left, seconds (2 decimals) | +| `full` | bool | `true` in a full frame, `false` in a delta (below) | +| `p` | object[] | players: `i` (the sim player id), `x`/`y` (tile units, 2 decimals), `f` (facing 0–3: up/right/down/left), `h` (the carried item's short form, e.g. `tomato_chopped`, or null), `s` (state: `idle`/`moving`/`stunned`/`working`), `pi` (seat index) | +| `st` | object[] | stations: `k` (kind, a short string: `spawn_shelf`/`chop_board`/`stove`/`prep`/`serve`/`sink`), `x`/`y` (tile units), `g` (the first content's short form, e.g. `onion_raw`, or null), `q` (the first content's quality 0–1, 2 decimals; 1.0 for an empty station), `p` (progress 0–1, 2 decimals), `s` (state: `idle`/`working`/`ready`/`burning`/`dirty`/`blocked`) | +| `o` | object[] | orders: `id` (int), `r` (recipe id), `l` (deadline share 0–1, 2 decimals — the order's `time_left` over its deadline window), `pts` (the order value) | +| `hz` | object[] | hazards: `t` (kind: `fire`/`spill`/`moving_obstacle`/`rush`), `x`/`y` (tile units), `l` (life — seconds remaining, 2 decimals; 0.0 = permanent until removed) | +| `e` | object[] | the events that rode this boundary (the compact event shape, below) | + +A real captured frame (level 1, the first `PLAYING` boundary, two seats — +`tests/integration/test_encoding.py` produces the same shape and asserts +its keysets): a new connection's first frame is always a full one. + +```json +{ + "t": 0, "s": 0, "tgt": 45, "tl": 150.0, "full": true, + "p": [ + {"i": "tok-a", "x": 2.5, "y": 7.5, "f": 2, "h": null, "s": "idle", "pi": 0}, + {"i": "tok-b", "x": 9.5, "y": 7.5, "f": 2, "h": null, "s": "idle", "pi": 1} + ], + "st": [ + {"k": "spawn_shelf", "x": 1.0, "y": 1.0, "g": "tomato_raw", "q": 1.0, "p": 0.0, "s": "idle"}, + {"k": "chop_board", "x": 4.0, "y": 1.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"}, + {"k": "stove", "x": 8.0, "y": 1.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"}, + {"k": "prep", "x": 2.0, "y": 3.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"}, + {"k": "serve", "x": 9.0, "y": 3.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"}, + {"k": "sink", "x": 3.0, "y": 5.0, "g": null, "q": 1.0, "p": 0.0, "s": "idle"}, + {"k": "spawn_shelf", "x": 8.0, "y": 5.0, "g": "onion_raw", "q": 1.0, "p": 0.0, "s": "idle"} + ], + "o": [], "hz": [], "e": [] +} +``` + +#### Delta frames + +The stream runs in delta mode: a frame encoded against the *previous* +frame (which may itself be a delta) is `"full": false` and carries its +entities under `d` — `d.p`, `d.st`, `d.o`, `d.hz` — **only the changed +entities** (new, moved, or state-changed since `prev`; an entity that +moved back to its previous values is in, one unchanged is out; the +diff is a whole entry, so a changed entry carries all its values), +plus a `{"gone": true}` **tombstone** for each entity that vanished +(an order served or missed, a hazard extinguished — a tombstone carries +the entity's identity keys so the client can drop it). The scalars +(`t`, `s`, `tgt`, `tl`) and the tick's `e` ride every frame, full or +delta. A delta of an idle kitchen shrinks a typical frame by ≥ 40%; +the client merges by **upserting on the identity key and dropping the +tombstones** (the identity keys: `p` by `i`, `st` by `k`+`x`+`y`, `o` +by `id`, `hz` by `t`+`x`+`y`) — applying a delta chain to the last full +lands on the server's own re-encode (the decode-equivalence the test +suite checks). + +The stream keeps the client resyncable: a fresh connection's first frame +is a full one, a **dropped snapshot** (the send queue's drop policy) +marks the connection `needs_full` so its next frame is a full one again, +and the **full cadence** — a full frame every 20 encoded frames +(`FULL_SNAPSHOT_EVERY_TICKS = 20`; the fulls land at passes 1, 21, 41, … +per audience) — means a client can always resync cheaply. + +#### Pacing (encode-once, fan-out-many) + +The room paces an encode pass every `tick_hz / snapshot_hz` ticks for +the player audience and every `2 ×` that for spectators (the default +20 Hz tick / 20 Hz snapshot: a player pass on every tick, a spectator +pass on every second one). One pass encodes **once** and reuses the +frame across every connection of that audience (encode-once, +fan-out-many); a pass with no audience encodes nothing. `snap` frames +arrive at `SNAPSHOT_HZ` for players and half that for spectators; the +`countdown` and `round` state frames reach *every* connection in the +room. `UNDERCOOKED_SIM_LATENCY_MS` (default 0) applies a server-side +send delay — the pump sleeps that long per batch — so netcode tests +simulate RTT without a proxy; with 150, an input→frame round trip is +lower-bounded by 150 ms (the integration suite measures it). + +#### Events onto frames (`e`) and the `ev` batch + +An event rides the frame of the boundary it occurs in. The compact `e[]` +entry (the snapshot's shape) is `{"k": , +"x"/"y" (tile units, 0 for HUD-level events), …the payload's short +keys flattened…}`; the standalone `ev` message (a non-`PLAYING` +boundary — a lobby emote, a `resync` nudge, the round-end verdict's +`round_end` event — plus every state change outside `PLAYING`) carries +the same events in the `EventWire` shape (`kind`, `x`, `y`, `payload` +with the short keys nested). Events are **capped at 24 per frame** +(`MAX_EVENTS_PER_FRAME`); the overflow is not spread over the next +frame — it is counted in `Room.stats()["events_dropped"]`. + +The payload keys reach the wire only through this explicit map +(`EVENT_PAYLOAD_SHORT` in `encoding.py`) — anything unlisted (a station's +internal id, the per-event `tick` — the frame's `t` is the tick) never +leaves the server: + +| payload key | short | | payload key | short | +|---|---|---|---|---| +| `player_id` | `i` | | `item` | `it` | +| `to` | `to` | | `form` | `fm` | +| `order_id` | `oi` | | `score_delta` | `sd` | +| `id` | `oi` | | `stun` | `stn` | +| `points` | `pts` | | `emote` | `em` | +| `quality` | `q` | | `result` | `res` | +| `streak` | `stk` | | `score` | `sc` | +| `delta` | `d` | | `target` | `tgt` | +| `reason` | `r` | | `stars` | `stars` | +| `verb` | `v` | | `seq` | `seq` | + +So a `served` event is `{"k": "served", "oi": 17, "pts": 40, …}` on the +snapshot and `{"kind": "served", "payload": {"oi": 17, "pts": 40, …}}` +in an `ev` batch; the `resync` nudge of the "Input rules" section is +`{"k": "resync", "i": , "seq": }` attached (or the +`EventWire` shape in an `ev` flush). + +#### Size budgets + +A 4-player / 4-order / 3-hazard frame is **≤ 1.5 KB**; a 64×64 level's +frame is **≤ 4 KB** (the grid, the station ids and the spawn coordinates +never leave the server — a large level encodes to the same handful of +stations as a small one). The 100-tick downlink stays **≤ 60 KB/s** per +player. The integration suite measures all three over live sockets. + +## Error codes + +`err.code` values (the `ErrorCode` constants in `protocol.py`); the +`tests/unit/test_protocol_doc_sync.py` guard keeps this table equal to the +code. + +| Code | Meaning | +|---|---| +| `unknown_type` | the `type` discriminator is not a known client message type | +| `bad_json` | the frame is not valid JSON | +| `bad_field` | shape/field validation failed (wrong type, missing/extra field, non-finite or magnitude > 1 move, negative/non-int `seq`, non-bool `act`, unknown `face`, unknown emote id, out-of-range token/name/level length) | +| `too_big` | the frame exceeded `max_message_bytes` (task 02) | +| `rate_limited` | the per-connection token bucket is empty (task 02) | +| `not_seated` | a verb from a token that holds no seat in this room (a spectator, or none) | +| `not_host` | `start` / `set_level` from a non-host | +| `level_locked` | `set_level` on a level not unlocked by the session | +| `wrong_state` | a message not in `MESSAGE_WHITELIST` for the room's current state | +| `room_full` | joining a full room (or the process is at `max_rooms`) — close `4402` | +| `room_gone` | unknown code, or the room is closed/gone — close `4401` | +| `protocol_mismatch` | `hello` `p` outside `[MIN_SUPPORTED_VERSION, PROTOCOL_VERSION]` — close `4400` | +| `evicted` | a second socket for the same token replaced this one — close `4403` | +| `internal` | a server-side failure (a tick crash aborts the room; the players are told, the rest of the server is unaffected) | + +## Close codes + +WebSocket close codes (the `CloseCode` constants in `protocol.py`): + +| Code | Name | Meaning | +|---|---|---| +| `1000` | `normal` | clean close | +| `4400` | `protocol_mismatch` | the `hello` `p` is outside the supported range (also: no `hello` within the 5 s handshake window — task 02) | +| `4401` | `room_gone` | the code is unknown, or the room is closed/gone | +| `4402` | `room_full` | the room (or the process) has no free seat | +| `4403` | `evicted` | a second socket for the same token replaced this one | +| `4404` | `abuse` | repeated oversize / rate violations (task 02) | + +## Limits + +| Limit | Default | Env var | What it caps | +|---|---|---|---| +| `max_message_bytes` | 4096 | `UNDERCOOKED_MAX_MESSAGE_BYTES` | one inbound frame (→ `too_big`) | +| `max_inputs_per_s` | 40 | `UNDERCOOKED_MAX_INPUTS_PER_S` | the per-connection inbound rate (→ `rate_limited`) | +| `tick_hz` | 20 | `UNDERCOOKED_TICK_HZ` | the sim rate (5–60) | +| `snapshot_hz` | 20 | `UNDERCOOKED_SNAPSHOT_HZ` | the `snap` rate to players (≤ `tick_hz`; spectators get half) | +| `sim_latency_ms` | 0 | `UNDERCOOKED_SIM_LATENCY_MS` | the injected server-side send delay per pump batch (netcode RTT simulation; 0 = never sleeps) | +| `max_players` | 4 | `UNDERCOOKED_MAX_PLAYERS` | seats per room (→ `room_full`) | +| name length | 16 | — | after `sanitize_name` (raw `hello` `name` ≤ 64 pre-sanitize) | +| token length | 128 | — | `hello` `token` | +| emote throttle | 2 / 1.0 s | — | per connection | +| handshake window | 5 s | — | no `hello` within it → close `4400` (task 02) | +| heartbeat dead window | 12 s | — | no inbound traffic → the server closes `1000` and runs the disconnect teardown (task 05) | +| seat grace (guest idle) | 45 s | `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` | a disconnected seat is kept, then `SeatAbandoned` (task 05) | + +## Manual probe (websocat equivalent) + +The handshake, the happy path and the refusal closes can be reproduced +without a browser — and without `websocat` (an external tool the project +does not ship) — with `curl` (the pre-socket lifecycle) plus any +WebSocket client; the project's own `websockets` dependency is the +docstring's probe. Against a running `uv run undercooked` +(`http://127.0.0.1:8000`): + +```bash +uv run undercooked +# in a second shell: mint a room, then read its census +# (the `sockets` counter is the live `/ws/{code}` census) +curl -s -X POST http://127.0.0.1:8000/api/rooms -H 'content-type: application/json' -d '{}' +curl -s http://127.0.0.1:8000/api/stats +``` + +Then the socket half (replace `CODE` with the `room_code` curl printed; +`token` may be omitted — the server mints one and echoes it in the +`welcome`): + +```python +import json +import websockets + +async def main() -> None: + ws = await websockets.connect("ws://127.0.0.1:8000/ws/CODE") + await ws.send(json.dumps({"type": "hello", "p": 1, "token": "tok-probe"})) + print(json.loads(await ws.recv())) # the `welcome` + print(json.loads(await ws.recv())) # the current `lobby` + await ws.send(json.dumps({"type": "ready"})) + print(json.loads(await ws.recv())) # the `lobby` re-broadcast, `ready: true` + await ws.send(json.dumps({"type": "ping", "t": 1})) + print(json.loads(await ws.recv())) # the `pong` + await ws.close() + +import asyncio + +asyncio.run(main()) +``` + +The refusal closes, observed from the client's close code: + +| What you do | The `err` you get | The close code | +|---|---|---| +| connect `/ws/ZZZZZ` (no such room) | `room_gone` | `4401` | +| `hello` with `p: 99` | `protocol_mismatch` | `4400` | +| connect, then send nothing for `hello_timeout_s` | — | `4400` | +| join a full room with a fresh token | `room_full` | `4402` | +| a second socket, same token | `evicted` (the first socket) | `4403` | +| 3 consecutive oversized / rate-limited frames | `too_big` / `rate_limited` | `4404` | +| an `Origin` outside `allowed_origins` (non-empty) | — (refused before the accept) | HTTP `403` | + +An *abrupt* drop (the client's transport vanishes without a close +handshake — e.g. `ws.transport.close()` in the probe client) is not an +error: the room sees exactly one disconnect, keeps the seat for the +rejoin path, and the `sockets` census in `/api/stats` drops by one. + +## Input rules + +`input` is the only client message that reaches the simulation. A frame +passes four layers before its intent is in the per-tick queue — the +per-state whitelist (`input` is `PLAYING`-only), the seat check, the +`seq` discipline, and the clamp — and the room's input counters +(`stats()`) record what each layer did. The wire's `Vec2Msg` rejection +(magnitude `> 1` → `bad_field`) is the early, explicit half of the +clamp; the layers below are the ones a frame that survives it still +traverses. + +**The `seq` discipline** (per connection, against `last_seq`, which +starts at `0` — the client's first frame must therefore be `seq ≥ 1`): + +- `seq == last_seq` — an **idempotent resend**: silently dropped (no + error, no stat; the frame's effect already landed, and a client that + retransmits over a flaky connection is not at fault). +- `seq < last_seq` — a **replay**: `err bad_field` *carrying the frame's + `seq`*, plus a `resync` event (below). Counted in `stale_seqs`; + `last_seq` is never lowered — a late duplicate of an earlier frame + cannot un-accept what came after. +- `seq > last_seq` — **accepted**, and `last_seq` advances to it. A + jump of **≤ 3** ahead is tolerated silently (UDP-ish loss: a snapshot + boundary can swallow up to three ticks' worth of inputs without the + round noticing); a larger jump is accepted **and counted** in + `input_gaps` (a reconnect that re-sent from a stale counter is a + condition worth seeing in the room's stats, not a refusal — the + `resync` path is what corrects a client that goes backwards). + +**The `resync` contract.** The `resync` event — `{"k": "resync", +"i": , "seq": }` when it rides a `snap`'s `e` (a `PLAYING` +boundary), the `EventWire` shape in an `ev` flush otherwise — is the +server's way of saying "your counter has left mine": on it, the client +**hard-snapshots** — it discards its prediction buffer, adopts the latest +`snap` as ground truth, and resumes `seq` from the `err`'s `seq` + 1. +The `err` frame's `seq` field is what the client resyncs *off*; the +event is what tells it *to*. + +**Clamping and quantization** (`to_player_input`, the wire layer's +second half of the magnitude rule). An accepted frame's `move` is +clamped to magnitude `≤ 1` (a `(1000, 1000)` intent becomes a unit +diagonal — the sim's `movement.intent_direction()` is the backstop +behind this one) and quantized to 3 decimals (a replay must reproduce +a frame's effect; past 3 decimals a float is client noise, not +intent). `face` must be a known value (`"up"/"right"/"down"/"left"` — +an unknown one is `bad_field` at the wire); when it is `null` it is +**derived from `move`** — the dominant axis wins (a perfect 45° press +resolves to the horizontal axis, the sim's `update_facing` rule); a +zero `move` derives `null` ("hold"). `act` is coerced to `bool`. +`received_at_tick` is the **server's own tick** — the contested +arbitration's ordering (arrival, then `seq`, then seat) never depends +on asyncio scheduling or a client's clock. A frame whose `move` +magnitude exceeded 1.0 is counted in `clamped_moves`. + +**No position is ever client-sent.** There is no position field in the +protocol, and `extra="forbid"` makes an `input` carrying one a +`bad_field`, full stop (teleport is structurally impossible — the sim +integrates velocity from intents, so even an accepted `move` is capped +at `MAX_SPEED` by `movement.desired_velocity`, whatever it is). + +**The per-tick queue and its stats.** The room's input queue is +per-tick (a tick consumes it, then it is empty) with a capacity of +`4 × seats` (16 at a full room). At capacity, a new frame drops the +*oldest* entry **for that player** (the newest intent wins) and is +counted in `inputs_dropped`; a full queue that holds none of the +player's entries drops the *new* frame instead (one spammer cannot +clear another player's tick). The queue never grows unbounded. A +`DISCONNECTED` seat's actions are ignored (the frame's socket is not +the one that dropped, and the sim parks the cook anyway); a spectator's +are `err not_seated`. `stats()` — `inputs_received` (frames queued), +`inputs_dropped`, `stale_seqs`, `clamped_moves`, `input_gaps` — is the +room's fixed-shape census of this discipline. + +## Heartbeats, drops, and seat grace (task 05) + +#### Heartbeats + +The client keeps the socket honest with a `ping` at ≥ 5 Hz (any inbound +frame refreshes the server's `last_seen`; the `ping` is the documented +keep-alive, answered `pong{t, now}`). The server watches the silence: no +inbound traffic for **`HEARTBEAT_DEAD_S` = 12 s** (a constant, not a +setting — 12 s of silence is dead on any honest client) and the server +closes the socket with **`1000`** and runs the standard disconnect +teardown. The room sees exactly the same drop as an abrupt close (one +`mark_disconnected`, one `GHOST_ON`) — this is what catches a +*half-open TCP*, where the read side would wait forever. + +#### A drop: `on_disconnect` + +A closed or dead socket (an abrupt close, the heartbeat timeout, or the +test seam) marks the seat `connected = false` — **kept** for the rejoin +path — emits one `ghost_on` event (at the cook's position, payload `i` += the player id), switches the sim's cook (`connected = false`, state +`disconnected`), and applies the host-crown rule (a dropped host is a +host *leave*). + +**The park (after 3 s).** After **`PARK_AFTER_S` = 3.0 s** of +disconnection the cook's carried item is released — once, idempotently +(a double call cannot double-release): onto the station on the cook's +tile (a marked set-down, grabbable, `dropped` event; the hand-off +window applies, so a released item can be passed), or into +`counters["lost_items"]` (a `ghost_park` event) when no station sits +under the tile. An item is **never deleted silently**: the station or +the counter is its tombstone. A cook that carried nothing parks with no +release at all. + +**`SeatAbandoned` (after the grace).** A seat disconnected for longer +than `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (default 45 s) is emptied by the +room's own tick in every state: one `ghost_off`, the seat out (a host +transfer, `empty_since` for the close rules), and a *forced* release of +the cook's carried item (the seat is going away; the release must not +wait for the park deadline when the grace is shorter). The cook's sim +player stays (parked). When the room empties, it closes through the +existing rules (the 30 s seat grace in `LOBBY`/`ROUND_END`; the +supervisor's GC in any state, a 5 s pass) — its sockets get +`err room_gone` + close `4401`. + +#### The rejoin + +- **Inside the grace** — a `hello` with the same token reclaims the + seat: `welcome.rejoined = true`, `connected = true`, one `ghost_off`, + the sim's cook restored (`connected = true`, state `idle` — a + still-carried item is kept; an already-parked cook comes back + empty-handed), and the snapshot stream resumes on the next player pass + (within one tick of `SNAPSHOT_HZ`; a rejoining socket's first frame + is a full one, its `needs_full` resync). +- **After the grace** — the seat is gone: the spectate/join rules apply + (`PLAYING` → a spectator; `LOBBY` → a free seat). A room that closed + in the meantime answers the `hello` with `err room_gone` + close + `4401` (a stale token) before any seat is taken. + +#### Ghost events + +| `k` | When | Payload (short keys) | +|---|---|---| +| `ghost_on` | a socket dropped (once per drop) | `i`; at the cook's position | +| `ghost_off` | a rejoin inside the grace, or a `SeatAbandoned` (once each) | `i`; at the cook's position | +| `ghost_park` | a park release with no station under the tile | `i`, `it`, `fm`; `counters["lost_items"]` + 1 | + +A park release onto a station is the plain `dropped` event, with the +set-down mark. The client's ghost label renders off the `disconnected` +player state / the seat's `connected` flag plus these events. + +#### The close-code decision (what the client can tell apart) + +| Situation | Code | The client does | +|---|---|---| +| a clean close — or the server closing a dead client (the heartbeat) | `1000` | treat as a *drop*: the seat is kept for the grace; reconnect with backoff (any inbound frame resets the 12 s clock) | +| a `hello` `p` unsupported, or no `hello` in time | `4400` | fix the client; do not auto-retry | +| an unknown code, or the room closed/gone (including a stale-token rejoin after the grace) | `4401` (+ `err room_gone`) | **the room died**: no auto-retry into that code | +| a full room (a new token) | `4402` | tell the player; try another room | +| evicted by a second socket for the token | `4403` | the token's seat is live on the other socket; stop this one | +| repeated oversize/rate/shape violations | `4404` | the connection was abusive; a fresh socket resets the bucket | + +#### The test-only seam + +`POST /api/test/kill_socket/{code}/{token}` — registered **only when +`UNDERCOOKED_TEST_MODE=1`** (never set in production): force-closes the +token's connection server-side (a hard drop for the client; the room +sees the standard `on_disconnect`), so e2e tests can simulate a dropped +socket. `404 {"code": "no_connection"}` when the room or the token +holds no live socket. + +## Testing the protocol (task 06) + +The contract above is driven by real sockets against a real +`uvicorn.Server` (an ephemeral `127.0.0.1` port per test — never a +routable interface) in three suites under `tests/integration/`: + +| Suite | What it drives | +|---|---| +| `test_ws_round_two_clients.py` | two scripted clients play a complete round (the lobby → the ready-ups → the start → the `countdown` → the `snap` stream → inputs → the shared `served` event and the byte-identical `round` frames → the mid-round spectator → the `to_lobby` return) | +| `test_ws_abuse.py` | the guardrails: 2000 malformed frames (the `4404` close, the other connections untouched, `/healthz` still `200`), the 500-message burst (`rate_limited` → `4404`, the send-queue high-water mark within its bound), the size cap (64 KB refused `too_big` — text and binary — 4 KB boundary admitted), the `start`/`set_level`/`emote` flood (the host check and the emote throttle hold), 200 concurrent handshakes | +| `test_ws_budgets.py` (`slow`) | the performance budgets, on a live 4-player room: the tick cost over 400 real ticks, the `snap` size, the downlink rate, the input→snapshot echo | + +**The test seams.** The suites reach the server's own state without a +second protocol: `app.state.supervisor` (the room's `stats()`, the seat +flags, the `mark_disconnected` verb the endpoint's `finally` runs on a +genuine drop) and `app.state.ws_registry.stats()` (the connection +census: `sockets`, the send-queue high-water mark `send_queue_max`, +the process's peak RSS `memory_mb`). The scripted client +(`tests/integration/conftest.py` `ws_client`) is a connected socket +with a background reader (a merged snapshot view: full frames reset, +delta frames patch, `e` events accumulate) and the verbs +`send_model` / `expect` / `collect`. One rule the suites follow (and +the reason a drain here is bounded by a window, not by a silence): a +live room's `snap` stream never goes quiet while the round is +`PLAYING` — a healthy server produces frames for the whole 150 s +time limit, so "wait for silence" is a wait that only a *broken* +server ends (the pre-fix tick crashed the room on its first sim +event, and the unbounded drains "passed" on that abort; the fix made +them hang, and the windows made them real). + +**The budget table** (the bounds the budget suite asserts; the +*measured* column is this suite's run — the bound is the PLAN §10 +number, the measurement is what a regression moves): + +| Budget | Bound | Measured (this run) | +|---|---|---| +| mean tick cost, 4-player room, 400 real ticks | ≤ 2 ms | 0.53 ms (p99 1.13 ms) | +| `snap` frame size (the 4p wire shape) | ≤ 1.5 KB | 1111 B max (101 frames / 5 s window) | +| downlink rate (one client, 5 s window, steady state) | ≤ 60 KB/s | 12.3 KB/s | +| input→snapshot echo (send → the first `snap` after it) | ≤ 120 ms | 50 ms (one tick, at 20 Hz) | +| 200 concurrent handshakes — p95 | < 1 s | 164 ms (wall 0.18 s for all 200) | +| 200 concurrent handshakes — RSS growth | ≤ 128 MB | 23.4 MB | +| send-queue high-water mark (the 500-burst) | ≤ 4 × `MAX_SEND_QUEUE` | 2 | + +Two measurement notes. The downlink window runs *after* a 2 s drain: +the tick-measurement phase leaves the kernel's TCP buffer holding the +window's share (the socket is unread while the ticks are measured), +and without the drain the window measures the backlog's catch-up — +a one-time burst — not the steady state a continuously-reading +client sees. And the echo's `0 ms` samples are the frame already +in the socket's buffer (sent by the tick between the send and the +read); the bound applies to the slowest of the three samples. diff --git a/docs/ROOMS.md b/docs/ROOMS.md index ca077ae..4bf040b 100644 --- a/docs/ROOMS.md +++ b/docs/ROOMS.md @@ -76,6 +76,8 @@ already `CLOSED`) and stops the tick task. | `last_result` | the `sim.round_state.result_payload` of the last finished round (`verdict`, `round_no`, `score`, `time_left`, `deadline_share`) | | `round_no` / `round_seed` | the round counter (1, 2, …) and its seed: round 1 plays the base seed, every later round steps the LCG cursor — a fresh, and from one base pinned, seed | | `events_since_snapshot` / `take_events` | the sim events (scored, wasted, burnt, …) since the last client snapshot; drained, never accumulated | +| `_queued_inputs` / `queue_input` | the per-tick input queue (capacity `INPUT_QUEUE_PER_SEAT x len(seats)`; see "One 20 Hz tick task", the `Inputs` paragraph) | +| `stats()` / `note_input_stat` | the room's diagnostics census: the lobby/round counters plus the input counters (`inputs_received`, `inputs_dropped`, `stale_seqs`, `clamped_moves`, `large_seq_gaps`) | | `snapshot_seq` | the room's snapshot counter (the phase 06 transport stamps it on frames) | ## One 20 Hz tick task, deadline-scheduled @@ -102,8 +104,15 @@ deterministic test (100 ticks over exactly 5.0 s, no drift). at zero, fire `countdown_done` and build the round. - **`PLAYING`** — `sim.step(sim_state, dt, queued, recipe_book)`: the kernel's own `STEP_DT` substeps, `dt` clamped to `[0, MAX_DT]` by the sim (a jump - costs at most two substeps). The per-tick input queue is drained into the - step (the sim stamps `received_at_tick`); the sim's events are appended to + costs at most two substeps). The per-tick input queue is drained into + the step (the frames carry their `received_at_tick` stamp from + `queue_input`) — **unless the tick advances no substep** (a wall-clock + `dt` too short to fill a `STEP_DT` once the accumulator is counted): + then the queue is *kept* and the step is not called, so a frame is never + dropped without being applied (a 20 Hz wall-clock loop jitters 48-51 ms; + without the guard a queued frame would be lost on a good fraction of + ticks). A settled state (`result` set) still drains: the kernel ignores + its inputs there by design. The sim's events are appended to `events_since_snapshot`. When the sim's `result` lands, the room opens the **settle** — `sim.round_state.SETTLE_S` (1.0 s) of wall time, the sim in its frozen stage-7 machine in between — and only then captures @@ -118,10 +127,20 @@ deterministic test (100 ticks over exactly 5.0 s, no drift). floored at 0, so a backwards `Clock` never un-runs the sim. Inputs: `queue_input` accepts a `sim.model.PlayerInput` only while `PLAYING` -and for a seated token (an unknown token is an `err` event — the room's only -in-band error channel; a dropped input outside playing is a silent no-op, the -client is not at fault). The queue is per-tick: drained into the step, cleared -after it, never carried between ticks. +and for a seated, connected token: an unknown token is an `err` event with +`reason=not_seated` (the player is not a seat at all) or `reason=unknown_player` +(the token is a seat but the sim id is not its) — the room's only in-band +error channel — while a `DISCONNECTED` seat's frame is a **silent no-op** (the +client is not at fault, so no error is queued) and `last_seq` on the +connection is left untouched. The queue is per-tick with a capacity of +`INPUT_QUEUE_PER_SEAT x len(seats)` (4 x the seated players): at capacity, +the *oldest* frame of the arriving player is dropped (the newest intent +wins; `inputs_dropped` bumped) — the queue never grows unbounded. The +queue is drained by the next tick that advances a substep (a zero-substep +tick keeps it — see the `PLAYING` bullet) and cleared then; `stats()` +counts the input traffic (`inputs_received`, `inputs_dropped`, `stale_seqs`, +`clamped_moves` — the clamp itself is the transport's, `ws.to_player_input` — +and `large_seq_gaps`, the tolerated-loss sibling). ## Crash isolation @@ -189,13 +208,22 @@ The three ways a room leaves the registry, and the windows that govern each `join`s while `CLOSED` (the window before the drop) is a named 410 `room_closed`. -**Disconnected seats.** A dropped connection marks the seat -`connected=false`; the seat is *kept* for the rejoin path (a token -`join`s its own room again, the seat is marked connected, the sim keeps -its player). The grace that drops such a seat is -`UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (45 s) — that rule lands with phase 12 -(reconnect resilience); until then a disconnected seat lingers until the -room empties and the seat grace runs. +**Disconnected seats (task 05).** A dropped connection marks the seat +`connected=false`; the seat is *kept* for the rejoin path. The park +comes first: after `PARK_AFTER_S` (3 s) the cook's carried item is +released (onto the station on the tile, or `counters["lost_items"]`), +and the sim's cook sits in `disconnected`. The grace that drops the +seat is `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (45 s), enforced by the +room's own tick in every state (`SeatAbandoned`): the seat is emptied +(one `ghost_off` event, a host transfer, `empty_since` for the close +rules) and any still-carried item is released at once. A rejoin inside +the grace reclaims the seat (`welcome.rejoined`; the snapshot stream +resumes within one tick); after it, the spectate/join rules apply. The +emptied room closes through the 30 s seat grace above (the room's own +check in `LOBBY`/`ROUND_END`; the GC in any state), and a closing room +closes its sockets with `err room_gone` + close `4401` — the client's +"the room died" signal (the contract in `docs/PROTOCOL.md`, +"Heartbeats, drops, and seat grace"). **The crash policy** is the crash-isolation section above, restated as lifecycle: a crashed room fires `close` from inside its own tick (it diff --git a/docs/TESTING.md b/docs/TESTING.md index eb16acc..cd5cb03 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -23,7 +23,7 @@ layer live in `tests/conftest.py`, and layer-specific fixtures live in that laye | `tests/sim/` | tests of the pure kernel that need no server: grid geometry, the entity model, the A5 import-purity guard, and the level validator (phase 02), plus the tick itself (phase 03) — the seeded RNG's published golden vectors (`test_rng.py`), the kernel's clamping/accumulation/purity/determinism suite (`test_step_kernel.py`), and the movement stage: hand-computed geometry per rule (`test_movement.py`), a scripted 2000-substep four-cook walk on the sim's own RNG that re-checks containment, non-overlap, bounded displacement and hash equality after every substep (`test_movement_stress.py`), and the same walk through the whole `step()` on the shipped levels (`test_movement_kernel.py`), and the action button (phase 03 task 03): the reach-zone geometry and one-press-one-event press edge (`test_targeting.py`), and the table-driven verb ladder that covers every `Verb` and every `DeniedReason` plus the gate's contested arbitration (`test_verbs.py`); the golden-state-hash replay suites — `test_replay_carry.py` (task 04's two-player pass and contest), `test_replay.py` (the committed goldens against `sim/replay.py`'s input-script format, the `record`/`check` CLI and the perf seam) — and the `slow`-marked perf smoke (`test_perf_smoke.py`) | none — the sim must stay callable without a server | | `tests/integration/` | multi-module, in-process tests: app boot through `create_app()`, HTTP routes, static serving, room/protocol behaviour with real WebSocket clients (room + protocol integration is PLAN §9 layer 4, and lives here) | in-process app, loopback sockets | | `tests/e2e/` | one Playwright suite per user story, named `test_usNN_*.py`; multiplayer stories drive **two browser contexts** against one room | real browser, real socket | -| `tests/fixtures/` | fixture data only. `tests/fixtures/levels/` is the content dir every test sees — an autouse session fixture points `UNDERCOOKED_LEVELS_DIR` at it so no test can silently read the shipped `levels/` (`_bad/` holds the deliberately-broken campaign the `_` discovery rule ignores; `_replay/` holds the `pick_drop_pass` fixture kitchen, ignored the same way). `tests/fixtures/replays/` holds the committed input scripts and `tests/goldens/` the committed golden hashes they are gated on (`sim/replay.py`) | none | +| `tests/fixtures/` | fixture data only. `tests/fixtures/levels/` is the content dir every test sees — an autouse session fixture points `UNDERCOOKED_LEVELS_DIR` at it so no test can silently read the shipped `levels/` (`_bad/` holds the deliberately-broken campaign the `_` discovery rule ignores; `_replay/` holds the `pick_drop_pass` fixture kitchen, ignored the same way). `tests/fixtures/levels_playable/` is the same recipe book plus one playable level — the only fixture campaign a live-server room can start a round on, which the WebSocket input suite (task 03) uses — the live-server suites against the default fixture set cannot start a round at all (it has no levels). `tests/fixtures/replays/` holds the committed input scripts and `tests/goldens/` the committed golden hashes they are gated on (`sim/replay.py`) | none | There is **no `tests/server/`**: PLAN §9 counts server/route layers of coverage, not directories. A route asserted as a pure function (header values, status codes, CLI diff --git a/src/undercooked/config.py b/src/undercooked/config.py index 7b27591..5a56dc7 100644 --- a/src/undercooked/config.py +++ b/src/undercooked/config.py @@ -64,7 +64,9 @@ ENV_KEYS: Mapping[str, str] = { "max_message_bytes": f"{ENV_PREFIX}MAX_MESSAGE_BYTES", "max_inputs_per_s": f"{ENV_PREFIX}MAX_INPUTS_PER_S", "sim_latency_ms": f"{ENV_PREFIX}SIM_LATENCY_MS", + "hello_timeout_s": f"{ENV_PREFIX}HELLO_TIMEOUT_S", "allowed_origins": f"{ENV_PREFIX}ALLOWED_ORIGINS", + "test_mode": f"{ENV_PREFIX}TEST_MODE", "debugpy": DEBUGPY_ENV_KEY, "debugpy_port": DEBUGPY_PORT_ENV_KEY, } @@ -88,9 +90,14 @@ DEFAULT_GUEST_IDLE_TIMEOUT_S = 45.0 DEFAULT_MAX_MESSAGE_BYTES = 4096 DEFAULT_MAX_INPUTS_PER_S = 40 DEFAULT_SIM_LATENCY_MS = 0 +#: The `hello` handshake window (PLAN §7 "wait for `hello`", phase 06 task 02). +DEFAULT_HELLO_TIMEOUT_S = 5.0 DEFAULT_DEBUGPY = False DEFAULT_DEBUGPY_PORT = 5678 DEFAULT_ALLOWED_ORIGINS: tuple[str, ...] = () +#: `1` registers the test-only seam route (`POST /api/test/kill_socket/…`, +#: phase 06 task 05) — never set in production. +DEFAULT_TEST_MODE = False # Accepted ranges (PLAN.md A4/A10 §10/§11); outside them the process refuses # to start rather than run a broken simulation. @@ -133,6 +140,12 @@ class Settings: max_message_bytes: hard cap on an inbound WebSocket frame. max_inputs_per_s: per-connection inbound message rate limit. sim_latency_ms: injected server-side downlink latency for netcode tests. + hello_timeout_s: a WebSocket that sends no `hello` within this many + seconds is closed with code `4400` (phase 06 task 02). + test_mode: `1` registers the test-only seam route + (`POST /api/test/kill_socket/{code}/{token}` — force-closes a + connection server-side, so e2e tests can simulate a hard drop); + never set in production (phase 06 task 05). debugpy: open a debugger listener at startup (A18). debugpy_port: debugger listen port. allowed_origins: `Origin` values accepted on the WebSocket handshake; @@ -153,6 +166,8 @@ class Settings: max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES max_inputs_per_s: int = DEFAULT_MAX_INPUTS_PER_S sim_latency_ms: int = DEFAULT_SIM_LATENCY_MS + hello_timeout_s: float = DEFAULT_HELLO_TIMEOUT_S + test_mode: bool = DEFAULT_TEST_MODE debugpy: bool = DEFAULT_DEBUGPY debugpy_port: int = DEFAULT_DEBUGPY_PORT allowed_origins: tuple[str, ...] = DEFAULT_ALLOWED_ORIGINS @@ -308,6 +323,10 @@ def load_settings(env: Mapping[str, str] | None = None) -> Settings: env, f"{prefix}MAX_INPUTS_PER_S", DEFAULT_MAX_INPUTS_PER_S, MIN_INPUTS_PER_S ), sim_latency_ms=_int(env, f"{prefix}SIM_LATENCY_MS", DEFAULT_SIM_LATENCY_MS, MIN_LATENCY_MS), + hello_timeout_s=_float( + env, f"{prefix}HELLO_TIMEOUT_S", DEFAULT_HELLO_TIMEOUT_S, MIN_TIMEOUT_S + ), + test_mode=_bool(env, f"{prefix}TEST_MODE", DEFAULT_TEST_MODE), debugpy=_bool(env, DEBUGPY_ENV_KEY, DEFAULT_DEBUGPY), debugpy_port=_int(env, DEBUGPY_PORT_ENV_KEY, DEFAULT_DEBUGPY_PORT, PORT_MIN, PORT_MAX), allowed_origins=_origins(env, f"{prefix}ALLOWED_ORIGINS", DEFAULT_ALLOWED_ORIGINS), diff --git a/src/undercooked/server/app.py b/src/undercooked/server/app.py index 397efeb..a19d2ec 100644 --- a/src/undercooked/server/app.py +++ b/src/undercooked/server/app.py @@ -2,12 +2,18 @@ This is the process later phases attach to: the room supervisor (`server/rooms.py`, phase 05), the WebSocket endpoint (`/ws/{code}`, -phase 06) and the level manifest (phase 02) all register on the app built -here. The contract so far: +phase 06, in `server/ws.py`) and the level manifest (phase 02) all +register on the app built here. The contract so far: * ``GET /healthz`` — liveness probe for containers and tests; reports the live room count from the supervisor, +* ``GET /api/stats`` — the ops census (``rooms`` / ``players`` / + ``sockets`` / ``tick_hz``; the ``sockets`` count is the phase-06 + ``ConnectionRegistry``), * ``GET /`` — the client shell from ``app/static/index.html``, +* ``/ws/{code}`` — the game WebSocket (phase 06): the origin check, the + ``hello`` handshake, the seat/spectate, the guardrails and the close + codes (``server/ws.py``; contract: ``docs/PROTOCOL.md``), * ``/static/*`` — the client's ES modules, CSS and art (never a CDN, A12), * ``/api/levels``, ``/api/levels/{id}``, ``/api/config`` — the content API (:mod:`undercooked.server.routes_content`), fed by the bundle loaded once @@ -27,27 +33,40 @@ installed on ``app.state.supervisor`` — and only there: ``create_app`` already refuses to build without a validated content bundle, so a process with rooms is a process with working content. The supervisor's ``start``/``shutdown`` ride the lifespan (GC loop, room tick tasks). + +Two task-05 (disconnect/rejoin) wirings live here: the supervisor's +``room_close`` hook — a closing room closes its own sockets with +``err room_gone`` + close ``4401``, the client's "the room died" signal +(normal leave is ``1000``) — and the **test-only seam** +(``POST /api/test/kill_socket/{code}/{token}``, registered **only when** +``UNDERCOOKED_TEST_MODE=1``): it force-closes a connection server-side +so e2e tests can simulate a hard drop. Never set in production. """ from __future__ import annotations +import contextlib import logging from collections.abc import AsyncIterator from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI -from fastapi.responses import FileResponse +from fastapi import APIRouter, FastAPI, Request +from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from undercooked import __version__ from undercooked.config import ConfigError, Settings, get_settings from undercooked.debug import maybe_start_debugpy from undercooked.server.clock import SystemClock +from undercooked.server.codes import normalize_code from undercooked.server.headers import SecurityHeadersMiddleware +from undercooked.server.protocol import CloseCode, ErrMsg, ErrorCode +from undercooked.server.room import Room from undercooked.server.rooms import RoomSupervisor from undercooked.server.routes_content import content_router, load_validated_content from undercooked.server.routes_rooms import rooms_router +from undercooked.server.ws import ConnectionRegistry, SnapshotFanout, websocket_endpoint __all__ = [ "STATIC_DIR", @@ -89,6 +108,40 @@ def resolve_static_dir(settings: Settings) -> Path: return settings.static_dir if settings.static_dir is not None else STATIC_DIR +def _test_seam_router() -> APIRouter: + """The test-only seam (task 05): registered only when + ``UNDERCOOKED_TEST_MODE=1`` (documented test-only in + ``docs/PROTOCOL.md``; never set in production). + + ``POST /api/test/kill_socket/{code}/{token}`` force-closes the + token's connection server-side — a hard drop for the client, the + room sees the standard ``on_disconnect`` (seat kept for the grace, + one ``GHOST_ON``) — so e2e tests can simulate a dropped socket. + ``404 {"code": "no_connection"}`` when the room or the token holds + no live socket. + """ + router = APIRouter(tags=["test-seam"]) + + @router.post( + "/api/test/kill_socket/{code}/{token}", response_model=None + ) + async def kill_socket( + request: Request, code: str, token: str + ) -> dict[str, bool] | JSONResponse: + registry: ConnectionRegistry = request.app.state.ws_registry + normalized = normalize_code(code) or code + for conn in registry.for_room(normalized): + if conn.token == token: + close = getattr(conn.ws, "close", None) + if close is not None: + with contextlib.suppress(Exception): + await close(1000) + return {"closed": True} + return JSONResponse(status_code=404, content={"code": "no_connection"}) + + return router + + def settings_log_line(settings: Settings) -> str: """A single-line, fixed-vocabulary summary of the resolved settings.""" return " ".join(f"{name}={getattr(settings, name)}" for name in LOGGED_SETTINGS) @@ -139,12 +192,57 @@ def create_app(settings: Settings | None = None) -> FastAPI: ) application.state.settings = resolved application.state.content = content # loaded + validated once, above + # The phase-06 socket registry (one socket per token; the `/api/stats` + # census and the broadcast fan-out read through it). + application.state.ws_registry = ConnectionRegistry() + + def install_snapshot_fanout(room: Room) -> None: + """One room's snapshot stream (task 04): the `SnapshotFanout` + the room's tick task drives — installed once per room + (idempotent: a room the `start()` hook already installed is + left alone).""" + if room.broadcaster is not None: + return + room.set_broadcaster( + SnapshotFanout(room, application.state.ws_registry).pass_ + ) + + async def close_room_sockets(room: Room) -> None: + """The room's `on_close` hook (task 05): the "room died" close. + + Every live socket of a closing room gets `err room_gone` + + close `4401` — the client's signal that the room itself is + gone (a normal leave is `1000`; the client does not + auto-retry into a dead code). The socket's own endpoint + teardown then runs as usual (its `mark_disconnected` is a + no-op on a closed room).""" + registry: ConnectionRegistry = application.state.ws_registry + for conn in registry.for_room(room.code): + conn.enqueue( + ErrMsg(type="err", code=ErrorCode.ROOM_GONE, msg="the room is closed") + ) + close = getattr(conn.ws, "close", None) + if close is not None: + with contextlib.suppress(Exception): + await close(CloseCode.ROOM_GONE) + # The supervisor exists only because the content gate above passed: # that is its "feature flag" — no rooms in a process without content. - application.state.supervisor = RoomSupervisor(resolved, SystemClock(), content) + application.state.supervisor = RoomSupervisor( + resolved, + SystemClock(), + content, + room_ready=install_snapshot_fanout, + room_close=close_room_sockets, + ) application.add_middleware(SecurityHeadersMiddleware) application.include_router(content_router) application.include_router(rooms_router) + # The test-only seam (task 05): registered only in test mode + # (`UNDERCOOKED_TEST_MODE=1`) — never on a production host. + if resolved.test_mode: + application.include_router(_test_seam_router()) + application.add_api_websocket_route("/ws/{code}", websocket_endpoint) @application.get("/healthz") async def healthz() -> dict[str, object]: @@ -152,6 +250,20 @@ def create_app(settings: Settings | None = None) -> FastAPI: supervisor: RoomSupervisor = application.state.supervisor return {"status": "ok", "version": __version__, "rooms": len(supervisor.all_rooms())} + @application.get("/api/stats") + async def stats() -> dict[str, object]: + """The ops census (phase 06 task 02): `rooms` / `players` (the + occupied seats) / `sockets` (live WebSocket connections) / + `tick_hz`. Kept off `/healthz` so the liveness probe stays cheap.""" + supervisor: RoomSupervisor = application.state.supervisor + census = supervisor.stats() + return { + "rooms": census["rooms"], + "players": census["seats"], + "sockets": application.state.ws_registry.socket_count, + "tick_hz": application.state.settings.tick_hz, + } + @application.get("/") async def index() -> FileResponse: """The client shell: one HTML file, then the browser loads ES modules.""" diff --git a/src/undercooked/server/encoding.py b/src/undercooked/server/encoding.py new file mode 100644 index 0000000..d9a5be5 --- /dev/null +++ b/src/undercooked/server/encoding.py @@ -0,0 +1,390 @@ +"""The snapshot encoder (PLAN §4 "Snapshot content", phase 06 task 04). + +The whole module is a pure projection of one `KitchenState` (plus the tick's +events) into the compact JSON frame the client interpolates: no I/O, no +clock, no mutation — a `KitchenState` in, a `dict` out, and the same state +always encodes to the same frame (a replay's frames are replayable too). + +**The shape** (PLAN §4, `docs/PROTOCOL.md` "The `snap` frame") is an +explicit field map per type — no `pickle`, no `dataclasses.asdict`: + +* `t` the sim tick, `s` the score, `tgt` the target, `tl` the clock (2 + decimals), +* `p[]` the players — `i` (id), `x`/`y` (tile units, + `Vec2.quantized(2)`), `f` (facing 0-3), `h` (the carried item's short + form, e.g. `tomato_chopped`, or null), `s` (the state, a short string), + `pi` (the seat index), +* `st[]` the stations — `k` (the kind, a short string), `x`/`y`, `g` + (the first content's short form, or null), `q` (the first content's + quality, 2 decimals; 1.0 for an empty station), `p` (progress 0-1, 2 + decimals), `s` (the state, a short string). The station's *internal* id + (`stove_1`, …) never appears — the station's wire identity is `k` + + coordinates, +* `o[]` the orders — `id`, `r` (recipe id), `l` (time left 0-1, 2 + decimals, the `time_left` over the level's deadline window), `pts` (the + order value), +* `hz[]` the hazards — `t` (the kind, a short string), `x`/`y`, `l` + (the life in seconds, 2 decimals; 0.0 = permanent until removed), +* `e[]` the tick's events (`encode_events`: the `EventKind` by its stable + short name in `k`, the position, the payload through the *explicit* + `EVENT_PAYLOAD_SHORT` map — a payload key that is not listed there never + leaves the server, which is what keeps a station's internal id and the + sim's internals out of the frame). + +Quantization is the wire's precision floor: positions `Vec2.quantized(2)`, +progress/quality/life 2 decimals — enough for smooth interpolation, small +enough to keep frames ≈ 300-900 B (the ≤ 1.5 KB budget is `snapshot_size`'s +business; the per-frame event cap `MAX_EVENTS_PER_FRAME` is the +broadcaster's). + +**The delta mode.** When `prev` (the previous frame of the stream — a full +*or* a delta frame) is supplied, the frame is a delta: `full: false`, and +`d` carries *only the changed entities* (new, moved, or state-changed since +`prev`; an entity that returned to its `prev` values is out), plus a +`{"gone": true}` tombstone for each entity that vanished (an order served +or missed, a hazard extinguished). The scalars (`t`, `s`, `tgt`, `tl`) and +the tick's events ride every frame, full or delta. The stream's full +cadence — a full frame every `FULL_SNAPSHOT_EVERY_TICKS` encoded frames, so +a client can always resync cheaply — is the broadcaster's; this module only +knows "given a `prev`, diff against it". + +`snapshot_size(frame)` is the budget hook (PLAN §10): the UTF-8 byte length +of the frame's JSON. +""" + +from __future__ import annotations + +import json +import math +from collections.abc import Callable, Mapping, Sequence +from typing import Any, Final + +from undercooked.sim.model import ( + Event, + Facing, + Hazard, + Item, + KitchenState, + Order, + Player, + Station, +) +from undercooked.sim.step import STEP_DT + +__all__ = [ + "EVENT_PAYLOAD_SHORT", + "FULL_SNAPSHOT_EVERY_TICKS", + "MAX_EVENTS_PER_FRAME", + "SnapshotMeta", + "encode_events", + "encode_snapshot", + "event_wire_dict", + "snapshot_size", +] + +#: The per-frame event cap (task 04): a frame carries at most this many +#: events; the overflow is dropped and counted in +#: `Room.stats()["events_dropped"]`. +MAX_EVENTS_PER_FRAME: Final[int] = 24 + +#: The full-frame cadence (task 04): the broadcaster makes the every-Nth +#: encoded frame of a stream a full one, so a client that has missed a +#: delta can resync from the next full without waiting for a drop. +FULL_SNAPSHOT_EVERY_TICKS: Final[int] = 20 + +#: The previous frame of a stream — a full frame (entities top-level) or a +#: delta frame (entities under `d`). A delta is computed against it. +SnapshotMeta = dict[str, object] + +#: The facing's wire index (PLAN §4: `f` is 0-3). +_FACING_INDEX: Final[Mapping[Facing, int]] = { + Facing.UP: 0, + Facing.RIGHT: 1, + Facing.DOWN: 2, + Facing.LEFT: 3, +} + +#: The explicit event-payload field map (task 04: an explicit field map, no +#: `asdict` reflex): a payload key reaches the wire only under its listed +#: short name. Anything unlisted — including a station's internal id under +#: `station` and the per-event `tick` (the frame's `t` is the tick) — +#: never leaves the server. +EVENT_PAYLOAD_SHORT: Final[Mapping[str, str]] = { + "player_id": "i", + "to": "to", + "order_id": "oi", + "id": "oi", + "points": "pts", + "quality": "q", + "streak": "stk", + "delta": "d", + "reason": "r", + "verb": "v", + "item": "it", + "form": "fm", + "score_delta": "sd", + "stun": "stn", + "emote": "em", + "r": "r", + "result": "res", + "score": "sc", + "target": "tgt", + "stars": "stars", + "seq": "seq", +} + + +def _q2(value: float) -> float: + """The wire's 2-decimal quantization (PLAN §4).""" + return round(value, 2) + + +def _clamp01(value: float) -> float: + return min(1.0, max(0.0, value)) + + +def _item_short(item: Item | None) -> str | None: + """A carried/content item's short form (`tomato_chopped`), or `None`.""" + return None if item is None else f"{item.kind.value}_{item.form.value}" + + +def _player_wire(player: Player) -> dict[str, object]: + """One player's wire entry (PLAN §4 `p[]`): id, position + (`Vec2.quantized(2)`), the facing's index, the carried item's short + form, the state, the seat index. No name, no token *field* — the id is + the sim's identity for the seat (the same value `welcome.you.token` + echoes once, PLAN A16).""" + pos = player.pos.quantized(2) + return { + "i": player.id, + "x": pos.x, + "y": pos.y, + "f": _FACING_INDEX[player.facing], + "h": _item_short(player.carrying), + "s": player.state.value, + "pi": player.seat, + } + + +def _station_wire(station: Station) -> dict[str, object]: + """One station's wire entry (PLAN §4 `st[]`): the kind's short name, + the position, the first content's short form (or null), the first + content's quality (2 decimals; 1.0 for an empty station), the progress + (0-1, 2 decimals) and the state. The station's internal id is not a + field — the wire identity is `k` + coordinates.""" + first = station.contents[0] if station.contents else None + return { + "k": station.kind.value, + "x": _q2(station.x), + "y": _q2(station.y), + "g": _item_short(first), + "q": _q2(first.quality) if first is not None else 1.0, + "p": _q2(station.progress), + "s": station.state.value, + } + + +def _order_wire(order: Order, state: KitchenState) -> dict[str, object]: + """One order's wire entry (PLAN §4 `o[]`): id, recipe id, `l` — the + `time_left` over the level's deadline window (0-1, 2 decimals; a + no-deadline state reads 0.0) — and the order value.""" + window = state.order_deadline * STEP_DT + share = order.time_left / window if window > 0.0 else 0.0 + return { + "id": order.id, + "r": order.recipe_id, + "l": _q2(_clamp01(share)), + "pts": order.value, + } + + +def _hazard_wire(hazard: Hazard) -> dict[str, object]: + """One hazard's wire entry (PLAN §4 `hz[]`): the kind's short name, the + position (2 decimals) and the life — seconds remaining, 2 decimals + (0.0 = permanent until removed).""" + return { + "t": hazard.kind.value, + "x": _q2(hazard.pos.x), + "y": _q2(hazard.pos.y), + "l": _q2(hazard.life), + } + + +def _prev_entries(frame: SnapshotMeta, key: str) -> list[dict[str, object]]: + """The previous frame's entity entries for `key`: top-level on a full + frame, under `d` on a delta frame (a delta's `d` may itself contain + `gone` tombstones — those are *not* part of the previous state).""" + raw = frame.get(key) + delta = frame.get("d") + if isinstance(delta, Mapping): + raw = delta.get(key) + if isinstance(raw, list): + return [entry for entry in raw if isinstance(entry, Mapping) and not _is_gone(entry)] # type: ignore[return-value] + return [] + + +def _is_gone(entry: Mapping[str, object]) -> bool: + return entry.get("gone") is True + + +def _key_of_player(entry: Mapping[str, object]) -> object: + return entry["i"] + + +def _key_of_order(entry: Mapping[str, object]) -> object: + return entry["id"] + + +def _key_of_station(entry: Mapping[str, object]) -> tuple[object, object, object]: + return (entry["k"], entry["x"], entry["y"]) + + +def _key_of_hazard(entry: Mapping[str, object]) -> tuple[object, object, object]: + return (entry["t"], entry["x"], entry["y"]) + + +def _tombstone_player(key: object) -> dict[str, object]: + return {"i": key, "gone": True} + + +def _tombstone_station(key: tuple[object, object, object]) -> dict[str, object]: + return {"k": key[0], "x": key[1], "y": key[2], "gone": True} + + +def _tombstone_order(key: object) -> dict[str, object]: + return {"id": key, "gone": True} + + +def _tombstone_hazard(key: tuple[object, object, object]) -> dict[str, object]: + return {"t": key[0], "x": key[1], "y": key[2], "gone": True} + + +def _diff( + current: list[dict[str, object]], + previous: list[dict[str, object]], + key_of: Callable[[Mapping[str, object]], Any], + tombstone: Callable[[Any], dict[str, object]], +) -> list[dict[str, object]]: + """`d` for one collection: every current entry that is new or changed + (an entry back at its `prev` values is out — that is why the diff is + against the *previous* frame, not the last full), then a tombstone for + each previous entry that vanished. Deterministic: state order, then + removals in previous order.""" + prev_map = {key_of(entry): entry for entry in previous} + out: list[dict[str, object]] = [] + seen: set[Any] = set() + for entry in current: + key = key_of(entry) + seen.add(key) + if prev_map.get(key) != entry: + out.append(entry) + out.extend(tombstone(key) for key in prev_map if key not in seen) + return out + + +def encode_snapshot( + state: KitchenState, + prev: SnapshotMeta | None = None, + event_entries: Sequence[Mapping[str, object]] = (), +) -> dict[str, Any]: + """The `SnapMsg.raw` frame for `state` (the PLAN §4 shape). + + With `prev=None` the frame is full: `full: true`, every entity array + top-level (`p`, `st`, `o`, `hz`). With a `prev` the frame is a delta: + `full: false`, `d` with only the changed entities (new/moved/ + state-changed since `prev`) plus `{"gone": true}` tombstones for the + ones that vanished. The scalars (`t`, `s`, `tgt`, `tl`) and the + tick's events ride both — `event_entries` is the boundary's events + *already encoded* (the broadcaster runs `encode_events` and the + `MAX_EVENTS_PER_FRAME` cap first, so a cap can never re-spread a + frame). + """ + players = [_player_wire(player) for player in state.players] + stations = [_station_wire(station) for station in state.stations] + orders = [_order_wire(order, state) for order in state.orders] + hazards = [_hazard_wire(hazard) for hazard in state.hazards] + frame: dict[str, object] = { + "t": state.tick, + "s": state.score, + "tgt": state.target_score, + "tl": _q2(state.time_left), + } + if prev is None: + frame["full"] = True + frame["p"] = players + frame["st"] = stations + frame["o"] = orders + frame["hz"] = hazards + else: + frame["full"] = False + frame["d"] = { + "p": _diff(players, _prev_entries(prev, "p"), _key_of_player, _tombstone_player), + "st": _diff(stations, _prev_entries(prev, "st"), _key_of_station, _tombstone_station), + "o": _diff(orders, _prev_entries(prev, "o"), _key_of_order, _tombstone_order), + "hz": _diff(hazards, _prev_entries(prev, "hz"), _key_of_hazard, _tombstone_hazard), + } + frame["e"] = [dict(entry) for entry in event_entries] + return frame + + +def _short_value(value: object) -> object | None: + """One payload value for the wire: a finite float to 2 decimals, the + rest as-is; `None` (and a non-finite float) is dropped — a null or a + `NaN` in a JSON frame is noise, not information.""" + if value is None: + return None + if isinstance(value, float): + if not math.isfinite(value): + return None + return round(value, 2) + return value + + +def encode_events(events: Sequence[Event]) -> list[dict[str, Any]]: + """The frame's `e[]`: one entry per event, in occurrence order — the + `EventKind` by its stable short name under `k`, the position (2 + decimals), the payload through the explicit `EVENT_PAYLOAD_SHORT` map + (an unlisted key never reaches the wire).""" + out: list[dict[str, object]] = [] + for event in events: + entry: dict[str, object] = { + "k": str(event.kind), + "x": _q2(event.x), + "y": _q2(event.y), + } + for key, value in event.payload.items(): + short = EVENT_PAYLOAD_SHORT.get(key) + if short is None: + continue + rendered = _short_value(value) + if rendered is not None: + entry[short] = rendered + out.append(entry) + return out + + +def event_wire_dict(event: Event) -> dict[str, Any]: + """One event's `EventWire` body (the standalone `ev` message, PLAN §7): + the `EventKind` value under `kind`, the position, the same explicit + payload map as `encode_events`.""" + wire: dict[str, object] = { + "kind": str(event.kind), + "x": _q2(event.x), + "y": _q2(event.y), + "payload": {}, + } + for key, value in event.payload.items(): + short = EVENT_PAYLOAD_SHORT.get(key) + if short is None: + continue + rendered = _short_value(value) + if rendered is not None: + wire["payload"][short] = rendered # type: ignore[index] + return wire + + +def snapshot_size(frame: Mapping[str, object]) -> int: + """The budget hook (PLAN §10 "Snapshot size ≤ 1.5 KB JSON"): the UTF-8 + byte length of the frame's JSON, the same serialization the pump uses + (default `json.dumps` separators).""" + return len(json.dumps(dict(frame)).encode("utf-8")) diff --git a/src/undercooked/server/protocol.py b/src/undercooked/server/protocol.py new file mode 100644 index 0000000..c0fd243 --- /dev/null +++ b/src/undercooked/server/protocol.py @@ -0,0 +1,616 @@ +"""The wire contract: versioned JSON message models and inbound validation (PLAN §7, A14). + +This module is the **single source** of what is legal on the wire: the +`ClientMsgType` / `ServerMsgType` discriminators, the Pydantic message models +(`extra="forbid"`, frozen — unknown fields are rejected on purpose, which is +why a field change is a `PROTOCOL_VERSION` bump), `parse_client_message` +(never raises — it returns a `ProtocolError` with a stable `code`), the +`ErrorCode` / `CloseCode` constants, and `MESSAGE_WHITELIST` — the one table +for "what is legal when", imported by the endpoint (task 02) and never +duplicated. + +**Framing.** One JSON object per WebSocket frame, a `type` string as the +discriminator, and — on the client's `hello` only — the protocol version `p` +(the connection is then bound to the negotiated version; later frames carry +no `p`). Every server→client message carries `type` and `p` so a client can +tell what it is talking to. `SnapMsg` is encoder-owned (task 04): the model +wraps the snapshot as `raw`; the snapshot's own `p` field is its *players* +array, nested one level down, so the two never collide. + +**Inbound validation.** `parse_client_message` does the whole *shape* check — +JSON decode, discriminator dispatch, per-model field validation (types, +requiredness, `extra="forbid"`, the `Vec2Msg` finiteness/magnitude rule, the +token/name/level length caps, non-negative `seq`) — and reports a +`ProtocolError` with a stable `code` (`bad_json`, `unknown_type`, `bad_field`, +`protocol_mismatch` for an unsupported `p`). It never raises. The *legality* +checks that need the room (size cap → `too_big`, rate bucket → `rate_limited`, +`MESSAGE_WHITELIST` → `wrong_state`, token/seat/host lookups → `not_seated` / +`not_host`, unlock lookups → `level_locked`, capacity → `room_full`, room +existence → `room_gone`) are the endpoint's (task 02/03) — this module defines +their codes, `parse_client_message` does not guess at them. + +The contract in prose lives in `docs/PROTOCOL.md`; `tests/unit/ +test_protocol_doc_sync.py` keeps the doc's tables and these `model_fields` +equal in both directions, so the doc cannot drift from the code. +""" + +from __future__ import annotations + +import json +import math +from dataclasses import dataclass +from typing import Final, Literal, Self, cast + +from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator + +from undercooked.server.room import RoomState + +__all__ = [ + "CLIENT_MSG_TYPES", + "MESSAGE_WHITELIST", + "MIN_SUPPORTED_VERSION", + "PROTOCOL_VERSION", + "SERVER_MSG_TYPES", + "ClientMessage", + "ClientMsgType", + "CloseCode", + "CountdownMsg", + "EmoteMsg", + "ErrMsg", + "ErrorCode", + "EventWire", + "FaceValue", + "HelloMsg", + "InputMsg", + "LobbyMsg", + "NextLevelMsg", + "PingMsg", + "PlayerScoreWire", + "PongMsg", + "ProtocolError", + "ReadyMsg", + "RoomStateValue", + "RoundMsg", + "ServerMsgType", + "SetLevelMsg", + "SnapMsg", + "SpectatorWire", + "StartMsg", + "ToLobbyMsg", + "Vec2Msg", + "WelcomeMsg", + "YouWire", + "parse_client_message", + "wire_dict", +] + +#: The protocol version this server speaks (PLAN §7: "Protocol version `p: 1`"). +PROTOCOL_VERSION: Final[int] = 1 +#: The oldest `hello` `p` the server accepts; a mismatch closes with `4400`. +MIN_SUPPORTED_VERSION: Final[int] = 1 + +#: Float-noise tolerance for the move-magnitude rule: `hypot(0.6, 0.8)` is +#: `1.0` to well inside this, while any honest overshoot exceeds it. +_MOVE_EPS: Final[float] = 1e-9 + + +class ErrorCode: + """The stable `code` strings a rejection carries (PLAN §7 inbound guards). + + The doc's error-code table (`docs/PROTOCOL.md` "Error codes") must list + exactly these; `tests/unit/test_protocol_doc_sync.py` checks it. + """ + + UNKNOWN_TYPE: Final = "unknown_type" + BAD_JSON: Final = "bad_json" + BAD_FIELD: Final = "bad_field" + TOO_BIG: Final = "too_big" + RATE_LIMITED: Final = "rate_limited" + NOT_SEATED: Final = "not_seated" + NOT_HOST: Final = "not_host" + LEVEL_LOCKED: Final = "level_locked" + WRONG_STATE: Final = "wrong_state" + ROOM_FULL: Final = "room_full" + ROOM_GONE: Final = "room_gone" + PROTOCOL_MISMATCH: Final = "protocol_mismatch" + EVICTED: Final = "evicted" + INTERNAL: Final = "internal" + + +class CloseCode: + """WebSocket close codes (PLAN §7 / task 02).""" + + NORMAL: Final = 1000 + PROTOCOL_MISMATCH: Final = 4400 # `hello` `p` outside the supported range + ROOM_GONE: Final = 4401 # unknown code, or the room is closed/gone + ROOM_FULL: Final = 4402 # the room (or the process) has no free seat + EVICTED: Final = 4403 # a second socket for the same token replaced this one + ABUSE: Final = 4404 # repeated oversize / rate violations + + +#: The client→server discriminators (PLAN §7 table, row by row). +ClientMsgType = Literal[ + "hello", + "ready", + "set_level", + "start", + "input", + "emote", + "to_lobby", + "next_level", + "ping", +] + +#: The server→client discriminators (PLAN §7 table, row by row). +ServerMsgType = Literal[ + "welcome", + "lobby", + "countdown", + "snap", + "round", + "ev", + "err", + "pong", +] + +CLIENT_MSG_TYPES: Final[frozenset[str]] = frozenset( + {"hello", "ready", "set_level", "start", "input", "emote", "to_lobby", "next_level", "ping"} +) +SERVER_MSG_TYPES: Final[frozenset[str]] = frozenset( + {"welcome", "lobby", "countdown", "snap", "round", "ev", "err", "pong"} +) + +#: The room's lifecycle states, as they appear on the wire. +RoomStateValue = Literal["lobby", "countdown", "playing", "round_end", "closed"] + +#: The facing a client may send explicitly (`sim/model.py` `Facing`, by value). +FaceValue = Literal["up", "right", "down", "left"] + +#: Wire-transport strictness: unknown fields are a rejection, not an ignore — +#: the versioning policy (docs/PROTOCOL.md) is built on it. Inbound (C→S) +#: models are also *strict*: a wire number that is a JSON string (or a bool +#: that is a `1`) is a `bad_field`, not a coercion — the wire is JSON, and +#: the shape check is the contract. +_WIRE_CONFIG = ConfigDict(extra="forbid", frozen=True) +_CLIENT_CONFIG = ConfigDict(extra="forbid", frozen=True, strict=True) + + +class _WireModel(BaseModel): + """Base for server→client wire models (the shared `extra="forbid"` config).""" + + model_config = _WIRE_CONFIG + + +class _ClientModel(_WireModel): + """Base for client→server models (strict on top: no coercion on the wire).""" + + model_config = _CLIENT_CONFIG + + +# ------------------------------------------------------------------- client → server + + +class Vec2Msg(_ClientModel): + """A movement intent, `input`'s `move`. + + Both coordinates must be finite, and the magnitude `hypot(x, y)` must be + `<= 1` (within `_MOVE_EPS`): the input is an *intent direction*, and the + sim's `movement.intent_direction()` clamps the same way — the wire + rejection is the early, explicit half of that rule. (A position is never + client-sent: there is no position field, and `extra="forbid"` rejects one.) + """ + + x: float + y: float + + @model_validator(mode="after") + def _finite_and_bounded(self) -> Self: + if not (math.isfinite(self.x) and math.isfinite(self.y)): + msg = "move coordinates must be finite" + raise ValueError(msg) + magnitude = math.hypot(self.x, self.y) + if magnitude > 1.0 + _MOVE_EPS: + msg = f"move magnitude {magnitude:.3f} exceeds 1.0" + raise ValueError(msg) + return self + + +class HelloMsg(_ClientModel): + """The handshake: who I am (or: nobody — a fresh token is minted), and + which protocol I speak. `p` is enforced by `parse_client_message`: outside + `[MIN_SUPPORTED_VERSION, PROTOCOL_VERSION]` it is a `protocol_mismatch` + (close `4400`), not a field error. `token` / `name` are optional — an + anonymous hello gets a server-minted token; `name` is sanitized + (`server/seats.py`) server-side and is non-authoritative.""" + + type: Literal["hello"] + p: int + token: str | None = Field(default=None, min_length=1, max_length=128) + name: str | None = Field(default=None, max_length=64) + + +class ReadyMsg(_ClientModel): + """The ready-up (US-03): the caller's seat flips `ready` on. `LOBBY` only.""" + + type: Literal["ready"] + + +class SetLevelMsg(_ClientModel): + """The host's level pick (PLAN §6 "limited to unlocked"). `LOBBY`, host + only; an id outside the campaign is `bad_field`, a locked one is + `level_locked`.""" + + type: Literal["set_level"] + level: str = Field(min_length=1, max_length=64) + + +class StartMsg(_ClientModel): + """The host's *Start* (US-03): `LOBBY`, host only. The preconditions are + the room's (`NothingToStart` — no level selected, or no ready non-host and + not a solo host); the refusal's reason rides in the `err` message text.""" + + type: Literal["start"] + + +class InputMsg(_ClientModel): + """One frame of intent (PLAN §4 data flow). `PLAYING` only. + + `seq` is the client's monotonically increasing counter (the seq + discipline — drop/resync — is task 03's); `move` is an intent direction + (`Vec2Msg`, magnitude `<= 1`); `act` the primary-action press; `face` an + explicit facing, `null` to derive it from `move`. The sim clamps the + magnitude again (`movement.intent_direction()`); the wire never trusts it. + """ + + type: Literal["input"] + seq: int = Field(ge=0) + move: Vec2Msg + act: bool + face: FaceValue | None + + +class EmoteMsg(_ClientModel): + """An emote (PLAN §7 whitelist; no chat in v1). `LOBBY` or `PLAYING`; + `id` must be in `server/seats.py` `EMOTES` — membership is checked + server-side (an unknown id is a `bad_field` with the whitelist in the + message text), throttled to 2 per 1.0 s per connection.""" + + type: Literal["emote"] + id: str = Field(min_length=1, max_length=32) + + +class ToLobbyMsg(_ClientModel): + """Return to the lobby after a round (PLAN §6 "next-level / retry"); + ready flags reset, waiting spectators are promoted. `ROUND_END`.""" + + type: Literal["to_lobby"] + + +class NextLevelMsg(_ClientModel): + """Start the next campaign level (PLAN §6 "next-level"): `ROUND_END`. + The level id is resolved server-side from the viewer's session + (`Room.next_level_id`); a "retry" is a `next_level` whose resolution is + the level just played. `LOBBY`'s host pick is `set_level`.""" + + type: Literal["next_level"] + + +class PingMsg(_ClientModel): + """Keep-alive / RTT probe (PLAN §7: accepted always). `t` is the client's + timestamp, echoed back in `pong`.""" + + type: Literal["ping"] + t: int = Field(ge=0) + + +# ------------------------------------------------------------------- server → client + + +class YouWire(_WireModel): + """The viewer's own seat, as `welcome`'s `you`: the token (the viewer's + own, echoed), the seat index, and whether it holds the crown.""" + + token: str + seat: int = Field(ge=0) + host: bool + + +class SeatWire(_WireModel): + """One seat as the lobby shows it (no token, ever — PLAN §11): the + index, the sanitized name, the palette colour, the ready/host flags and + the connection flag (a dropped seat stays, `connected: false`).""" + + index: int = Field(ge=0) + name: str + color: str + ready: bool + host: bool + connected: bool + + +class SpectatorWire(_WireModel): + """A watching token (PLAN §6 "SPECTATING"): name + colour, nothing else — + spectators hold no seat and are not in the sim.""" + + name: str + color: str + + +class EventWire(_WireModel): + """One transient FX/log event (`sim/model.py` `Event`, projected): the + `EventKind` value, its position, and the payload (short names, e.g. the + served order id). FX/log only — never game truth.""" + + kind: str + x: float + y: float + payload: dict[str, object] = Field(default_factory=dict) + + +class PlayerScoreWire(_WireModel): + """One seat's contribution for the result screen (`result_payload`'s + `players` entry, in seat order): display only, never competitive + gatekeeping (PLAN §5).""" + + id: str + name: str + score: int + dishes_served: int = Field(ge=0) + missed_contrib: int = Field(ge=0) + + +class WelcomeMsg(_WireModel): + """The handshake answer (PLAN §7 "after `hello`"): who you are in this + room (`you`), which room (`room` = the code), its state, the selected + level (or `null`), the seat list, and `rejoined` (task 05): `true` + when this `hello` reclaims a *disconnected* seat — the rejoin inside + the grace (a fresh join, and a duplicate `hello` on a live socket, + are `false`).""" + + type: Literal["welcome"] = "welcome" + p: int = PROTOCOL_VERSION + you: YouWire + room: str + state: RoomStateValue + level: str | None = None + seats: list[SeatWire] + rejoined: bool = False + + +class LobbyMsg(_WireModel): + """The lobby state (PLAN §7; the body `Room.lobby_payload` builds, phase + 05): the code, the state, the selected level, the crown holder's seat + (or `null`), whether a start pressed now would be accepted, the seats and + spectators, the *viewer's* `unlocked_level` (or `null`), and the round + number.""" + + type: Literal["lobby"] = "lobby" + p: int = PROTOCOL_VERSION + code: str + state: RoomStateValue + level: str | None = None + host_seat: int | None = None + can_start: bool + seats: list[SeatWire] + spectators: list[SpectatorWire] + unlocked: int | None = None + round_no: int = 0 + + +class CountdownMsg(_WireModel): + """One `countdown` per whole second of the 3 s countdown (PLAN §6): + `n` is 3, then 2, then 1.""" + + type: Literal["countdown"] = "countdown" + p: int = PROTOCOL_VERSION + n: int + + +class SnapMsg(_WireModel): + """A snapshot frame (PLAN §4): encoder-owned (task 04) — the model wraps + the compacted, quantized snapshot in `raw`. `raw`'s own `p` field is its + *players* array, nested one level down; this message's `p` is the + protocol version. The size budget (≤ 1.5 KB) is task 04's.""" + + type: Literal["snap"] = "snap" + p: int = PROTOCOL_VERSION + raw: dict[str, object] + + +class RoundMsg(_WireModel): + """The verdict (PLAN §7 "ROUND_END"): `sim/round_state.py` + `result_payload`'s exact keys — `result` (`"won"`/`"lost"`/`"aborted"`), + `score`, `target`, `stars`, `time_left` (frozen at the verdict), + `counters` (`served`/`missed`/`wasted`), `longest_streak`, `players` (the + per-seat contributions, in seat order), `level_id`, `seed`, `ticks` — + plus `unlocked`, the viewer session's next level id (or `null`).""" + + type: Literal["round"] = "round" + p: int = PROTOCOL_VERSION + result: str | None = None + score: int + target: int + stars: int = Field(ge=0) + time_left: float + counters: dict[str, int] + longest_streak: int = Field(ge=0) + players: list[PlayerScoreWire] + level_id: str + seed: int + ticks: int + unlocked: str | None = None + + +class EvMsg(_WireModel): + """A batch of transient events (PLAN §7 "any"): the FX/log lines that + rode this boundary — `chopped`, `burnt`, `served`, an emote, an `err` + note, … Batching and the per-frame cap are task 04's.""" + + type: Literal["ev"] = "ev" + p: int = PROTOCOL_VERSION + events: list[EventWire] + + +class ErrMsg(_WireModel): + """A rejection (PLAN §7 "on rejection"): the stable `code` (an + `ErrorCode` value), a human-readable `msg`, and — when the rejection + refers to an `input` frame — its `seq` (the client resyncs off it).""" + + type: Literal["err"] = "err" + p: int = PROTOCOL_VERSION + code: str + msg: str + seq: int | None = None + + +class PongMsg(_WireModel): + """The answer to a `ping` (PLAN §7): the client's `t` echoed, and `now` + — the server's current time in seconds (the room's injected `Clock`).""" + + type: Literal["pong"] = "pong" + p: int = PROTOCOL_VERSION + t: int + now: float + + +# ------------------------------------------------------------------- dispatch + + +#: The discriminator → model table `parse_client_message` dispatches on. +_CLIENT_MODELS: Final[dict[str, type[_WireModel]]] = { + "hello": HelloMsg, + "ready": ReadyMsg, + "set_level": SetLevelMsg, + "start": StartMsg, + "input": InputMsg, + "emote": EmoteMsg, + "to_lobby": ToLobbyMsg, + "next_level": NextLevelMsg, + "ping": PingMsg, +} + + +@dataclass(frozen=True, slots=True) +class ProtocolError: + """A rejected frame, typed: a stable `code` (an `ErrorCode` value) and a + human-readable `detail`. `parse_client_message` never raises — it returns + one of these instead, and `to_err` turns it into the wire `err` frame.""" + + code: str + detail: str = "" + + def to_err(self, seq: int | None = None) -> ErrMsg: + """The `err` frame this rejection becomes (the endpoint sends it).""" + return ErrMsg(type="err", code=self.code, msg=self.detail or self.code, seq=seq) + + +def _summarize(exc: ValidationError) -> str: + """A short, stable text for a validation failure (the first 3 errors).""" + parts = [f"{err['loc']}: {err['msg']}" for err in exc.errors()[:3]] + return "; ".join(parts) + + +def _reject_if_any(message: _WireModel) -> ProtocolError | None: + """The post-validation gates, in order. + + 1. The `hello` `p` gate: `protocol_mismatch` when `p` is outside + `[MIN_SUPPORTED_VERSION, PROTOCOL_VERSION]`. (A `p` that is not an + int already failed as `bad_field`, so only the range is judged + here.) + 2. The dispatch desync guard: `internal` when a model that is not one + of the nine client models ever comes back from `_CLIENT_MODELS` — + unreachable in practice, kept so the return type below stays + honest and the dispatch auditable. + """ + if isinstance(message, HelloMsg) and not ( + MIN_SUPPORTED_VERSION <= message.p <= PROTOCOL_VERSION + ): + return ProtocolError( + ErrorCode.PROTOCOL_MISMATCH, + f"unsupported protocol version {message.p} " + f"(server speaks {MIN_SUPPORTED_VERSION}..{PROTOCOL_VERSION})", + ) + if not isinstance( + message, + ( + HelloMsg, + ReadyMsg, + SetLevelMsg, + StartMsg, + InputMsg, + EmoteMsg, + ToLobbyMsg, + NextLevelMsg, + PingMsg, + ), + ): + return ProtocolError(ErrorCode.INTERNAL, "internal: dispatch/validation desync") + return None + + +ClientMessage = ( + HelloMsg | ReadyMsg | SetLevelMsg | StartMsg | InputMsg | EmoteMsg + | ToLobbyMsg | NextLevelMsg | PingMsg +) + + +def parse_client_message(raw: bytes) -> ClientMessage | ProtocolError: + """Decode + validate one client frame; never raises. + + Steps: JSON decode (`bad_json`), shape check (a non-object top level or a + missing/non-string `type` is `bad_field`), discriminator dispatch + (`unknown_type` for a `type` that is not a `ClientMsgType`), per-model + validation (`bad_field` — type, requiredness, `extra="forbid"`, the + `Vec2Msg` rule, the length caps), then `_reject_if_any` (the `hello` + `p` gate and the internal desync guard). + + Returns: + The typed message, or a `ProtocolError` with a stable `code`. + """ + try: + data = json.loads(raw) + except ValueError: + return ProtocolError(ErrorCode.BAD_JSON, "frame is not valid JSON") + discriminator = data.get("type") if isinstance(data, dict) else None + if not isinstance(discriminator, str): + return ProtocolError( + ErrorCode.BAD_FIELD, + "top level must be a JSON object with a string 'type' field", + ) + model = _CLIENT_MODELS.get(discriminator) + if model is None: + return ProtocolError(ErrorCode.UNKNOWN_TYPE, f"unknown type {discriminator!r}") + try: + message = model.model_validate_json(raw) + except ValidationError as exc: + return ProtocolError(ErrorCode.BAD_FIELD, _summarize(exc)) + rejection = _reject_if_any(message) + if rejection is not None: + return rejection + # `_CLIENT_MODELS` holds exactly the nine client models, so the dispatch + # guarantees the cast is sound. + return cast(ClientMessage, message) + + +def wire_dict(model: _WireModel) -> dict[str, object]: + """Encode a wire model for the socket: JSON-mode dump (enums as values, + everything JSON-serializable — the frames go out as JSON text).""" + return model.model_dump(mode="json") + + +#: The one table for "what is legal when" (PLAN §7 "Accepted when"), keyed by +#: room state. Imported by the endpoint (task 02) — never duplicated. Host/ +#: seat/unlock legality is a layer above this one (the endpoint's checks); +#: this is the *state* layer. `hello` (the handshake, including a rejoin) +#: and `ping` (PLAN §7 "always") are legal in every state; `input` only +#: while playing; the lobby verbs only in the lobby; the round-end verbs +#: only at round end. +MESSAGE_WHITELIST: Final[dict[RoomState, frozenset[ClientMsgType]]] = { + RoomState.LOBBY: frozenset( + {"hello", "ready", "set_level", "start", "emote", "ping"} + ), + RoomState.COUNTDOWN: frozenset({"hello", "ping"}), + RoomState.PLAYING: frozenset({"hello", "input", "emote", "ping"}), + RoomState.ROUND_END: frozenset({"hello", "to_lobby", "next_level", "ping"}), + RoomState.CLOSED: frozenset({"hello", "ping"}), +} diff --git a/src/undercooked/server/room.py b/src/undercooked/server/room.py index 8b0c949..395ae88 100644 --- a/src/undercooked/server/room.py +++ b/src/undercooked/server/room.py @@ -72,6 +72,29 @@ comes from — and `round_over`'s half of the settle (`_record_round_result`) calls `store.record_result` for **every seated player**. A room without a store records nothing; spectators are not players. + +**The ghost and the seat grace (task 05).** A socket drop is +`on_disconnect`: the seat is marked `connected=False` (kept for the +rejoin path; the crown rule runs through the mark), one `GHOST_ON` +event is buffered, and the sim's cook is switched (`connected=False`, +state `DISCONNECTED` — the pure functions are `sim/ghost.py`). After +`PARK_AFTER_S` (3.0 s, `sim/ghost.py`) the cook's carried item is +released by `_park_ghosts` (every `PLAYING` tick, off `seat.last_seen`) +— onto the station on the cook's tile (a marked set-down, grabbable), +or into `counters["lost_items"]` (a `GHOST_PARK` event); an item is +never deleted silently and a release is idempotent (one per token per +round, `_parked`). A rejoin inside the grace is `on_reconnect`: the +seat is marked connected, one `GHOST_OFF` (only when it actually was a +ghost), the sim's cook restored (`connected=True`, `IDLE`, a +still-carried item kept). `SeatAbandoned` — a seat disconnected past +`settings.guest_idle_timeout_s` — is emptied by `_abandon_expired` +(every state, the room's own copy of the rule the supervisor's GC +applies externally): a forced release (the seat is going away; the sim's +player stays, parked), one `GHOST_OFF`, the seat out (a host transfer, +`empty_since` for the close rules). A room that empties closes through +the existing rules (the 30 s seat grace, the GC); closing it fires the +`on_close` hook the app installs — `err room_gone` + close `4401` on +the room's sockets, the client's "the room died" signal. """ from __future__ import annotations @@ -79,6 +102,7 @@ from __future__ import annotations import asyncio import contextlib import logging +import math import secrets from dataclasses import replace from enum import StrEnum @@ -101,10 +125,19 @@ from undercooked.server.seats import ( UnknownLevel, ) from undercooked.server.sessions import SessionRecord -from undercooked.sim.model import Event, EventKind, Item, ItemForm, KitchenState, StationKind +from undercooked.sim.ghost import PARK_AFTER_S, mark_ghost, release_ghost_carry, restore_player +from undercooked.sim.model import ( + Event, + EventKind, + Item, + ItemForm, + KitchenState, + PlayerState, + StationKind, +) from undercooked.sim.round_state import SETTLE_S, abort, result_payload from undercooked.sim.state_build import SeatSpec, build_state -from undercooked.sim.step import PlayerInput, step +from undercooked.sim.step import MAX_DT, STEP_DT, TICK_EPS, PlayerInput, step if TYPE_CHECKING: from collections.abc import Awaitable, Callable, Mapping, Sequence @@ -124,6 +157,12 @@ if TYPE_CHECKING: [KitchenState, float, Sequence[PlayerInput], RecipeBook | None], tuple[KitchenState, tuple[Event, ...]], ] + #: The room's broadcast seam (task 04): the wire layer's fanout, + #: installed by the app. Called from the tick task with the audiences + #: whose encode pass is due (``"player"`` / ``"spectator"`` / ``"ev"``) + #: and the state frames to fan out (the ``countdown`` / ``round`` + #: payloads; the fanout validates them against the protocol models). + Broadcaster = Callable[[set[str], tuple[dict[str, object], ...]], None] __all__ = [ "COUNTDOWN_S", @@ -242,6 +281,7 @@ class Room: seed: int | None = None, sessions: SessionLookup | None = None, store: SessionStore | None = None, + on_close: Callable[[Room], Awaitable[None]] | None = None, ) -> None: self.code = code self._settings = settings @@ -257,6 +297,11 @@ class Room: #: reads its `next_level_id`, and `round_over` records the result #: into every seated player's session through it. self._store = store + #: The close hook (task 05): the app's "room died" close — every + #: live socket of a closing room gets `err room_gone` + close + #: `4401`, so a client can tell "the room died" apart from "you + #: left". `None` for a test-built room (nothing to close). + self._on_close = on_close #: Late joins (PLAN §6 "SPECTATING"): tokens that arrived while the #: room was already past `LOBBY`. No seat, `role="spectator"`. self.spectators: list[Spectator] = [] @@ -298,12 +343,50 @@ class Room: self.round_seed: int | None = None #: Why the last round ended in a crash (`None` when it did not). self.abort_reason: str | None = None - #: Sim events since the last `take_events()` — the phase-06 `ev` - #: buffer; drained exactly once per snapshot. + #: Sim events since the last boundary (the phase-06 `ev`/snapshot + #: buffer): every entry is seq-tagged in `_event_seqs` (the wire's + #: cursor reads through `event_buffer`) and the list is capped at + #: `EVENT_BUFFER_CAP` (the oldest drops first). self.events_since_snapshot: list[Event] = [] + #: The monotonic seq of the last buffered event (0 = none yet). + self._event_seq: int = 0 + #: The buffer's seqs, parallel to `events_since_snapshot`. + self._event_seqs: list[int] = [] + #: Events lost to the per-frame cap (task 04, `stats()`). + self._events_dropped: int = 0 #: How many snapshot boundaries have passed since the room started. self.snapshot_seq: int = 0 + #: The snapshot pacing accumulators (task 04, in room-tick units): + #: the player pass is due every `tick_hz / snapshot_hz` ticks, the + #: spectator pass every `2 x` that (PLAN §4: spectators get half). + self._snap_player_accum: float = 0.0 + self._snap_spectator_accum: float = 0.0 + #: The whole second last broadcast in the countdown (task 04: the + #: `countdown` frame per whole second, 3 → 2 → 1). + self._countdown_last_n: int | None = None + #: The room's broadcast seam (task 04): the wire layer's fanout + #: (the app installs it; `None` for a test-built room — the event + #: buffer then keeps accumulating, its cap the only bound). + self._broadcaster: Broadcaster | None = None self._queued_inputs: list[PlayerInput] = [] + #: The tokens whose park release already landed (task 05): one + #: release per token per round; the release itself is idempotent + #: (`sim/ghost.py`), so the set only skips the re-check. + self._parked: set[str] = set() + #: The input discipline's counters (task 03, `stats()`): + #: `inputs_received` (frames queued), `inputs_dropped` (frames lost + #: to the per-tick queue's overflow), `stale_seqs` (backwards-seq + #: refusals), `clamped_moves` (frames whose `move` magnitude + #: exceeded 1.0 and was clamped) and `input_gaps` (accepted + #: frames whose `seq` jumped more than 3 ahead — tolerated, + #: counted). + self._input_stats: dict[str, int] = { + "inputs_received": 0, + "inputs_dropped": 0, + "stale_seqs": 0, + "clamped_moves": 0, + "input_gaps": 0, + } self._settle_left: float | None = None self._last_at: float | None = None #: The test-only seam (the `UNDERCOOKED_TEST_HOOK`): a `Callable` @@ -423,7 +506,9 @@ class Room: return True def mark_seat_disconnected(self, token: str, now: float | None = None) -> bool: - """Mark a seat's connection dropped (the rejoin path keeps the seat). + """Mark a seat's connection dropped (the rejoin path keeps the + seat). The raw mark — the supervisor and the wire's `finally` + use `on_disconnect` (this verb + `GHOST_ON` + the sim switch). When the marked seat held the crown, `transfer_host` runs — a dropped host connection is a host *leave* for the crown's purposes @@ -444,6 +529,8 @@ class Room: def mark_seat_connected(self, token: str, now: float | None = None) -> bool: """Mark a seat's connection restored (the rejoin path, US-02). + The raw mark — the supervisor's rejoin uses `on_reconnect` (this + verb + `GHOST_OFF` + the sim restore). Deliberately does **not** run `transfer_host`: a returning host does not automatically regain the crown (docs/ROOMS.md "Host rules" — @@ -477,6 +564,168 @@ class Room: """Stamp the last meaningful event — what the lobby-idle GC measures.""" self.last_activity = self._clock.now() if now is None else now + # ------------------------------------------- the ghost and seat grace (task 05) + + def on_disconnect(self, token: str, now: float | None = None) -> bool: + """A socket drop (task 05, US-09): the higher-level verb the + supervisor and the wire's `finally` use (above + `mark_seat_disconnected`, which stays the raw mark). + + Marks the seat `connected=False` (kept for the rejoin path; the + crown rule runs through the mark), emits one `GHOST_ON` (the + ghost presentation for the others), and switches the sim's cook + (`connected=False`, state `DISCONNECTED` — `sim/ghost.py`). A + seat that is *already* a ghost is a no-op: one `GHOST_ON` per + drop, never a second. + + Returns: + True when a seated token was (or already was) marked, False + when it was not seated. + """ + when = self._clock.now() if now is None else now + seat = self.seat_for_token(token) + if seat is None: + return False + if seat.connected: + self.mark_seat_disconnected(token, now=when) + self._emit_ghost(seat, EventKind.GHOST_ON) + self._switch_sim_player(token, ghost=True) + return True + + def on_reconnect(self, token: str, now: float | None = None) -> bool: + """A rejoin inside the grace (task 05): the higher-level verb the + supervisor's `join` rejoin path uses (above + `mark_seat_connected`, which stays the raw mark). + + Marks the seat connected again, emits one `GHOST_OFF` (only when + the seat actually was a ghost — a duplicate `hello` on a live + socket emits nothing), and restores the sim's cook + (`connected=True`, state `IDLE` — a still-carried item is kept; + an already-parked cook comes back empty-handed). + + Returns: + True when a seated token was marked, False when it was not + seated (the seat was abandoned: the spectate/join rules + apply — a new seat or a spectator, the supervisor's call). + """ + when = self._clock.now() if now is None else now + seat = self.seat_for_token(token) + if seat is None: + return False + was_ghost = not seat.connected + self.mark_seat_connected(token, now=when) + if was_ghost: + self._emit_ghost(seat, EventKind.GHOST_OFF) + self._switch_sim_player(token, ghost=False) + return True + + def _emit_ghost(self, seat: Seat, kind: EventKind) -> None: + """One ghost event, at the cook's position (0,0 in a lobby, where + the sim is not built yet): the payload carries the player id + (the wire's `i`).""" + x = y = 0.0 + sim = self.sim_state + if sim is not None: + player = next( + (p for p in sim.players if p.id == seat.player_id), None + ) + if player is not None: + x, y = player.pos.x, player.pos.y + self._buffer_event( + Event(kind=kind, x=x, y=y, payload={"player_id": seat.player_id}) + ) + + def _switch_sim_player(self, token: str, ghost: bool) -> None: + """The sim-side half of a (dis)connect (the pure functions in + `sim/ghost.py`, applied only when they would change anything): + a no-op when the round is not built, or the token has no sim + player (a seat added after the round's build), or the sim's cook + already holds the target state.""" + sim = self.sim_state + if sim is None: + return + player = next((p for p in sim.players if p.id == token), None) + if player is None: + return + if ghost: + if player.connected or player.state is not PlayerState.DISCONNECTED: + self.sim_state = mark_ghost(sim, player) + elif not player.connected: + self.sim_state = restore_player(sim, player) + + def _park_ghosts(self, when: float) -> None: + """The park deadline, every `PLAYING` tick (task 05): a ghost + cook's carried item is released once `PARK_AFTER_S` (3.0 s) + of disconnection has passed — onto the station on the cook's + tile (a marked set-down, a `DROPPED`), or into + `counters["lost_items"]` (a `GHOST_PARK`) when no station sits + under the tile; an item is never deleted silently. One release + per token per round; the release is idempotent, so a double + call cannot double-release.""" + sim = self.sim_state + if sim is None: + return + for seat in self.seats: + if seat.connected or seat.token in self._parked: + continue + player = next( + (p for p in sim.players if p.id == seat.player_id), None + ) + if player is None: + continue + if ( + player.connected + or player.state is not PlayerState.DISCONNECTED + ): + sim = mark_ghost(sim, player) + player = next(p for p in sim.players if p.id == seat.player_id) + if when - seat.last_seen + _EPS_S >= PARK_AFTER_S: + sim, events = release_ghost_carry(sim, player) + for event in events: + self._buffer_event(event) + self._parked.add(seat.token) + self.sim_state = sim + + def _abandon_expired(self, when: float) -> None: + """`SeatAbandoned` (task 05): a seat disconnected for longer + than `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` is emptied — the rejoin + window is over. Runs in every state (the room's own copy; the + supervisor's GC applies the same rule externally).""" + limit = self._settings.guest_idle_timeout_s + for seat in list(self.seats): + if not seat.connected and when - seat.last_seen > limit: + self._abandon_seat(seat.token, when) + + def _abandon_seat(self, token: str, when: float) -> None: + """Empty one seat (the `SeatAbandoned`): a forced release of the + cook's carried item (the seat is going away; the sim's player + stays, parked — the release must not wait for the park deadline + when the grace is shorter), one `GHOST_OFF`, then the seat out + (a host transfer, `empty_since` for the close rules).""" + seat = self.seat_for_token(token) + if seat is None: + return + sim = self.sim_state + if sim is not None: + player = next( + (p for p in sim.players if p.id == seat.player_id), None + ) + if ( + player is not None + and ( + player.carrying is not None + or player.connected + or player.state is not PlayerState.DISCONNECTED + ) + ): + sim, events = release_ghost_carry(sim, player) + for event in events: + self._buffer_event(event) + self.sim_state = sim + self._parked.add(token) + self._emit_ghost(seat, EventKind.GHOST_OFF) + self.remove_seat(token, now=when) + # ------------------------------------------------------------ the FSM def _fire(self, event: str) -> RoomState: @@ -508,7 +757,9 @@ class Room: self.round_seed = self._seed_cursor self.countdown_left = COUNTDOWN_S self._queued_inputs = [] + self._parked.clear() self._settle_left = None + self._countdown_last_n = None def request_start(self) -> None: """The host's *Start*: ≥ 1 ready non-host, or a solo host (PLAN §6), @@ -869,7 +1120,7 @@ class Room: return False recent.append(when) self._emote_times[token] = recent - self.events_since_snapshot.append( + self._buffer_event( Event( kind=EMOTE_EVENT_KIND, payload={"emote": emote_id, "player_id": token}, @@ -923,29 +1174,182 @@ class Room: # -------------------------------------------------------------- input + #: The `stats()` vocabulary — anything else is a programming error. + INPUT_STAT_NAMES: Final[tuple[str, ...]] = ( + "inputs_received", + "inputs_dropped", + "stale_seqs", + "clamped_moves", + "input_gaps", + ) + + #: The per-tick input queue's capacity, per seat (task 03): at 4 + #: seats the queue holds 16 frames; overflow drops the *oldest* + #: entry for the arriving player (the newest intent wins) and bumps + #: `inputs_dropped` — the queue never grows unbounded. + INPUT_QUEUE_PER_SEAT: Final[int] = 4 + + #: A `seq` may jump at most this far ahead of the last accepted one + #: before the jump is counted in `input_gaps` (task 03: "gaps of + #: ≤ 3 are tolerated (UDP-ish loss), larger gaps accepted but logged + #: in room stats"). + TOLERATED_SEQ_GAP: Final[int] = 3 + + #: The event buffer's cap (task 04): the oldest drops first. In a + #: live room the wire's cursor has already consumed everything but + #: the current boundary, so the cap only bounds a room without a + #: wire (a test-built one keeps accumulating; its bound is this). + EVENT_BUFFER_CAP: Final[int] = 1024 + + def stats(self) -> dict[str, object]: + """A fixed-shape census of the room's input discipline (task 03): + `inputs_received`, `inputs_dropped`, `stale_seqs`, `clamped_moves` + and `input_gaps` — plus `events_dropped` (task 04: events lost + to the per-frame cap, `MAX_EVENTS_PER_FRAME`).""" + return {**dict(self._input_stats), "events_dropped": self._events_dropped} + + def note_event_dropped(self, amount: int) -> None: + """Count the events a boundary lost to the per-frame cap + (task 04: `stats().events_dropped`).""" + self._events_dropped += amount + + def _buffer_event(self, event: Event) -> None: + """One event into the buffer, seq-tagged (the wire's cursor + reads the pairs through `event_buffer`); the buffer is capped at + `EVENT_BUFFER_CAP` — the oldest drops first.""" + self._event_seq += 1 + self.events_since_snapshot.append(event) + self._event_seqs.append(self._event_seq) + while len(self.events_since_snapshot) > self.EVENT_BUFFER_CAP: + self.events_since_snapshot.pop(0) + self._event_seqs.pop(0) + + @property + def event_buffer(self) -> list[tuple[int, Event]]: + """The buffer's `(seq, event)` pairs (task 04): the wire's + cursor (`server.ws.SnapshotFanout`) reads through this and never + truncates it — the buffer's cap is the only thing that drops + entries, and only the oldest.""" + return list(zip(self._event_seqs, self.events_since_snapshot, strict=True)) + + def set_broadcaster(self, broadcaster: Broadcaster | None) -> None: + """Install (or remove) the broadcast seam (task 04): the wire + layer's fanout, called from the tick task with the audiences due + (`"player"` / `"spectator"` / `"ev"`) and the state frames to + fan out (the `countdown` / `round` payloads).""" + self._broadcaster = broadcaster + + @property + def broadcaster(self) -> Broadcaster | None: + """The installed broadcast seam, or `None` (a test-built room).""" + return self._broadcaster + + @property + def snapshot_period_ticks(self) -> float: + """Room ticks between the player encode passes (PLAN §4): `tick_hz + / snapshot_hz` (≥ 1); the spectator passes run at `2 x` that. + """ + return self._settings.tick_hz / self._settings.snapshot_hz + + def _snapshot_pacing_tick(self) -> None: + """One PLAYING tick's snapshot pacing (task 04): the player pass + is due every `tick_hz / snapshot_hz` ticks, the spectator pass + every `2 x` that — due passes reach the broadcaster, which owns + the encode (the encoding itself is `server/encoding.py`, the + fan-out `server.ws.SnapshotFanout`).""" + if self._broadcaster is None: + return + self._snap_player_accum += 1.0 + self._snap_spectator_accum += 1.0 + period = self.snapshot_period_ticks + due: set[str] = set() + if self._snap_player_accum >= period - 1e-9: + due.add("player") + self._snap_player_accum = 0.0 + if self._snap_spectator_accum >= 2.0 * period - 1e-9: + due.add("spectator") + self._snap_spectator_accum = 0.0 + if due: + self._broadcaster(due, ()) + + def _broadcast_state(self, payload: dict[str, object]) -> None: + """One state frame (the `countdown` / `round` payload, PLAN §7) + to the wire — to every connection (the fanout fans it out; the + `round` frame's viewer-specific `unlocked` it fills in per + connection).""" + if self._broadcaster is not None: + self._broadcaster(set(), (payload,)) + + def _broadcast_round(self) -> None: + """The phase-06 `round` frame (PLAN §7): `result_payload`'s exact + keys plus `unlocked` — the `None` placeholder the fanout replaces + with the viewer's next level id.""" + if self.last_result is None: + return + payload: dict[str, object] = {"type": "round", "unlocked": None} + payload.update(dict(self.last_result)) + self._broadcast_state(payload) + + def note_input_stat(self, name: str, amount: int = 1) -> None: + """Bump one input-stat counter (a fixed vocabulary — the + discipline's layers in `ws.py` and `queue_input` report through + this, so the counters can never misspell). + + Raises: + KeyError: `name` is not one of `INPUT_STAT_NAMES`. + """ + if name not in self.INPUT_STAT_NAMES: + msg = f"not an input stat: {name!r}" + raise KeyError(msg) + self._input_stats[name] += amount + + def note_stale_seq(self, player_id: str, seq: int) -> None: + """A backwards `seq` refusal (task 03): count it (`stale_seqs`) + and buffer the `resync` event the client hard-snapshots on. + The `err` frame itself (with the offending `seq`) is the + wire layer's; this is the room's half of the contract.""" + self.note_input_stat("stale_seqs") + self._buffer_event( + Event( + kind=EventKind.RESYNC, + payload={"player_id": player_id, "seq": seq}, + ) + ) + def queue_input(self, player_id: str, frame: PlayerInput) -> bool: """Queue one frame of intent for the next tick. Only legal in `PLAYING` (elsewhere the frame is dropped — no error: - a client that sent late is not at fault); an id no seat holds is a - buffered `err` event (`unknown_player`), never an exception. The room - stamps `received_at_tick` with the sim's current tick, so input - ordering never depends on asyncio scheduling or the client's counter. - The queue is per-tick: `tick_once` passes it to `step` and clears it. + a client that sent late is not at fault); a `DISCONNECTED` seat is + silently ignored (task 03 — the connection that dropped is not the + one the frame arrived on, and the sim parks the cook anyway); an id + no seat holds is a buffered `err` event (`unknown_player`), never an + exception. The room stamps `received_at_tick` with the sim's current + tick, so input ordering never depends on asyncio scheduling or the + client's counter. The queue is per-tick with a capacity of + `INPUT_QUEUE_PER_SEAT x len(seats)`: at capacity, the *oldest* entry + for the arriving player is dropped (the newest intent wins, + `inputs_dropped` bumped) — the queue never grows unbounded. + `tick_once` passes it to `step` — on the next tick that advances a + substep (a wall-clock tick too short to fill one `STEP_DT` keeps + the queue, so a frame is never dropped without being applied) — + and clears it. Returns: True when the frame was queued; False when it was dropped - (wrong state) or rejected (`unknown_player`). + (wrong state, a disconnected seat, the overflow) or rejected + (`unknown_player`). """ if self.state is not RoomState.PLAYING: return False - if self.seat_for_token(player_id) is None: + seat = self.seat_for_token(player_id) + if seat is None: reason = ( "not_seated" if self.spectator_for_token(player_id) is not None else "unknown_player" ) - self.events_since_snapshot.append( + self._buffer_event( Event( kind=EventKind.ERROR, payload={ @@ -956,19 +1360,40 @@ class Room: ) ) return False + if not seat.connected: + return False # silently ignored (task 03) + capacity = self.INPUT_QUEUE_PER_SEAT * len(self.seats) + if len(self._queued_inputs) >= capacity: + for index, queued in enumerate(self._queued_inputs): + if queued.player_id == player_id: + del self._queued_inputs[index] + break + else: + # The queue is full and none of it is this player's: + # the new frame is the drop (a player's own entries can + # only ever evict their own oldest — one spammer cannot + # clear another player's tick). + self.note_input_stat("inputs_dropped") + return False + self.note_input_stat("inputs_dropped") tick = self.sim_state.tick if self.sim_state is not None else 0 self._queued_inputs.append(replace(frame, received_at_tick=tick)) + self.note_input_stat("inputs_received") return True def take_events(self) -> tuple[Event, ...]: - """Drain `events_since_snapshot` (the phase-06 `ev`/snapshot boundary). + """Drain `events_since_snapshot` (the phase-06 `ev`/snapshot + boundary). The buffer is drained exactly once per call: what is returned is - everything since the last call, and the next call starts empty. - `snapshot_seq` counts the boundary. + everything since the last call, and the next call starts empty + (the seq list drains with it — the wire's cursor is an absolute + seq, so a drain cannot make it skip or re-send). `snapshot_seq` + counts the boundary. """ events = tuple(self.events_since_snapshot) self.events_since_snapshot.clear() + self._event_seqs.clear() self.snapshot_seq += 1 return events @@ -1003,6 +1428,8 @@ class Room: if self.state is not RoomState.CLOSED: self._fire("close") self.closed_at = self._clock.now() + if self._on_close is not None: + await self._on_close(self) if self._task is not None and not self._task.done(): self._task.cancel() if self._task is not None and asyncio.current_task() is not self._task: @@ -1012,6 +1439,7 @@ class Room: self.spectators.clear() self._color_memory.clear() self._emote_times.clear() + self._parked.clear() async def tick_loop(self) -> None: """The room's one task: one `tick_once` per period until closed. @@ -1057,8 +1485,26 @@ class Room: def _tick(self, dt: float, when: float) -> None: """One tick, by state (the body `tick_once` crash-isolates).""" state = self.state + # The `SeatAbandoned` rule (task 05) runs in every state, before + # the boundary flush — a `GHOST_OFF` that lands this tick rides + # the same boundary. + self._abandon_expired(when) + # The non-`PLAYING` boundary: buffered events flush to the wire + # as the standalone `ev` frame (task 04) — the `PLAYING` events + # ride the snapshot pass instead. A room without a wire keeps + # its buffer (the cap is the bound). + if ( + self._broadcaster is not None + and state is not RoomState.PLAYING + and self.events_since_snapshot + ): + self._broadcaster({"ev"}, ()) if state is RoomState.COUNTDOWN: self.countdown_left = max(0.0, self.countdown_left - dt) + n = math.ceil(self.countdown_left - _EPS_S) + if n >= 1 and n != self._countdown_last_n: + self._countdown_last_n = n + self._broadcast_state({"type": "countdown", "n": n}) if self.countdown_left <= _EPS_S: self._fire("countdown_done") self._build_sim_state() @@ -1067,6 +1513,24 @@ class Room: if self.sim_state is None or self._content is None: msg = f"room {self.code} is PLAYING with no built state (internal invariant)" raise RuntimeError(msg) + self._snapshot_pacing_tick() + # The park deadline (task 05): wall-clock rule, it runs on + # every `PLAYING` tick (a substep-less tick parks too). + self._park_ghosts(when) + sim = self.sim_state + # A room tick whose wall-clock `dt` (clamped exactly as `step` + # clamps it) does not fill one `STEP_DT` substep must not + # consume the per-tick input queue: `step()` would drop the + # frames without applying them, and a 20 Hz wall-clock loop + # jitters (48-51 ms), so a queued frame would be lost on a + # good fraction of ticks. Keeping the queue lets the next + # tick — the one that does advance substeps — apply it (the + # sim's contract: "inputs received since the previous call"). + # A settled state (`result` set) still drains: `step` ignores + # its inputs there by design (the verdict is in). + delta = min(dt if dt >= 0.0 else 0.0, MAX_DT) + if sim.result is None and sim.accumulator + delta < STEP_DT - TICK_EPS: + return queued = tuple(self._queued_inputs) self._queued_inputs = [] stepper = ( @@ -1077,7 +1541,16 @@ class Room: self.sim_state, events = stepper( self.sim_state, dt, queued, self._content.recipe_book ) - self.events_since_snapshot.extend(events) + # Through `_buffer_event`: every buffer entry is seq-tagged in + # `_event_seqs` (the wire's cursor reads the pairs non- + # destructively — an untagged extend would break the parallel + # and the next `event_buffer` read would raise). + # Through `_buffer_event`: every buffer entry is seq-tagged in + # `_event_seqs` (the wire's cursor reads the pairs non- + # destructively — an untagged extend would break the parallel + # and the next `event_buffer` read would raise). + for event in events: + self._buffer_event(event) if self.sim_state.result is not None: # The settle (sim/round_state.SETTLE_S): the first tick the # verdict lands opens the window; it closes SETTLE_S of wall @@ -1090,6 +1563,7 @@ class Room: self.last_result = result_payload(self.sim_state) self._record_round_result() self._fire("round_over") + self._broadcast_round() return # LOBBY or ROUND_END: the idle checks, the same rules and numbers the # supervisor's GC applies externally (the room's own copy, so a room @@ -1182,7 +1656,7 @@ class Room: self.abort_reason = f"round {self.round_no} tick crashed: {type(exc).__name__}" if self.sim_state is not None: self.sim_state = abort(self.sim_state, self.abort_reason) - self.events_since_snapshot.append( + self._buffer_event( Event( kind=EventKind.ERROR, payload={"reason": "tick_crash", "round": self.round_no}, diff --git a/src/undercooked/server/rooms.py b/src/undercooked/server/rooms.py index 10a3a24..31f4e7f 100644 --- a/src/undercooked/server/rooms.py +++ b/src/undercooked/server/rooms.py @@ -18,7 +18,10 @@ loaded). It owns: `settings.lobby_idle_timeout_s` is closed, already-closed rooms are dropped, and stale token-index entries are purged, * **teardown** — `start()` runs the GC loop and every room's tick task; - `shutdown()` cancels them all and awaits them. + a room *created after* `start()` (a live process — the real server's + lifespan) gets its tick task from `create_room` (the task it started + for the rooms present at `start()` time would otherwise be the only + ones that ever tick); `shutdown()` cancels them all and awaits them. * **the session store** — a bounded LRU `SessionStore` (task 04): every token that created or joined a room is remembered there, and the progression (`unlocked_level`, the best scores, the counters) is the @@ -106,6 +109,14 @@ class RoomSupervisor: code_rng: a `random.Random` for deterministic code minting in tests; `None` draws with `secrets.choice`. sleep: the GC loop's sleep; `None` = `asyncio.sleep`. + room_ready: called with every room as it is created (and once per + room at `start()` for the rooms present then — an idempotent + installer): the app uses it to install the phase-06 snapshot + fanout on each room. + room_close: awaited with a room as it closes (the room's + `on_close` hook, task 05): the app uses it to close the + room's sockets with `err room_gone` + close `4401` (the + client's "the room died" signal). """ def __init__( @@ -116,12 +127,16 @@ class RoomSupervisor: *, code_rng: random.Random | None = None, sleep: Callable[[float], Awaitable[None]] | None = None, + room_ready: Callable[[Room], None] | None = None, + room_close: Callable[[Room], Awaitable[None]] | None = None, ) -> None: self._settings = settings self._clock = clock self._content = content self._code_rng = code_rng self._sleep = sleep if sleep is not None else asyncio.sleep + self._room_ready = room_ready + self._room_close = room_close self._rooms: dict[str, Room] = {} self._token_index: dict[str, str] = {} self._sessions = SessionStore( @@ -191,6 +206,7 @@ class RoomSupervisor: content=self._content, sessions=self.session_for, store=self._sessions, + on_close=self._room_close, ) room.add_seat( session.token, @@ -205,6 +221,16 @@ class RoomSupervisor: self._sessions.put(session) self._rooms[code] = room self._token_index[session.token] = code + if self._room_ready is not None: + self._room_ready(room) + if self._gc_task is not None and not self._gc_task.done(): + # The supervisor is live (a started process — the real server's + # lifespan): `start()` only started the tick tasks for the rooms + # that existed *then*, so a room born afterwards would tick + # forever not at all (the countdown would never run and no + # round could start). A not-yet-started supervisor (the + # in-process test pattern) leaves ticking to its caller. + room.start_ticking() return room def join( @@ -238,7 +264,7 @@ class RoomSupervisor: now = self._clock.now() existing = room.seat_for_token(token) if existing is not None: - existing.mark_connected(now) + room.on_reconnect(token, now=now) if display_name is not None: existing.display_name = sanitize_name(display_name, existing.seat_index) room.note_activity(now) @@ -324,7 +350,7 @@ class RoomSupervisor: code = self._lookup(raw_code) room = self._rooms[code] now = self._clock.now() - if room.mark_seat_disconnected(token, now=now): + if room.on_disconnect(token, now=now): return if room.mark_spectator_disconnected(token, now=now): return @@ -439,6 +465,8 @@ class RoomSupervisor: return self._gc_task = asyncio.create_task(self.gc_loop(), name="room-gc") for room in self._rooms.values(): + if self._room_ready is not None: + self._room_ready(room) room.start_ticking() async def shutdown(self) -> None: diff --git a/src/undercooked/server/ws.py b/src/undercooked/server/ws.py new file mode 100644 index 0000000..c2f4ef9 --- /dev/null +++ b/src/undercooked/server/ws.py @@ -0,0 +1,1583 @@ +"""The WebSocket transport (PLAN A3/A14, phase 06 tasks 02/03): `/ws/{code}`. + +This is the socket the phase-05 room core rides on. It owns, in this order: + +* **the guardrails, in order** — the origin check *before* the accept + (against `settings.allowed_origins`; an empty setting is the same-host + rule, a `*` entry is honoured in dev mode only; a disallowed origin is a + pre-accept close, which the server answers with an HTTP `403` — the + client sees a refused handshake, never a close frame that looks like a + network error), then the `accept` (the task's `max_message_bytes` rides on + the inbound loop, not on `accept()`: this stack's `WebSocket.accept()` + takes no `max_size`, and the app-layer check is what produces the + documented `too_big` code — the WebSocket library's 16 MiB default stays + as the backstop), then the room lookup (a missing or `CLOSED` room is an + `err room_gone` + close `4401` — the accept already went out, so a + client sees a real close frame, never a bare network error), +* **the handshake** — a `hello` within `settings.hello_timeout_s` (else close + `4400`); a `p` outside the supported range is `err protocol_mismatch` + + close `4400` before any seat is taken; an absent token is minted + (`sessions.mint_token`), +* **the seat/spectate** — the supervisor's `join` (a token that already holds + a seat rejoins it, a late join spectates, a full room refuses a *new* + token with close `4402`), then the `welcome` + the current `lobby`, +* **the two tasks** — `pump_out` (drains `Connection.send_queue`, ≤ 4 frames + per pass) and `read_in` (the inbound loop: `bytes_in` accounting, the + `max_message_bytes` cap → `too_big`, the token bucket → `rate_limited`, the + shape check, the per-state `MESSAGE_WHITELIST` → `wrong_state`, the room's + verbs with their named refusals), and — since task 05 — a third, the + `heartbeat_watchdog`: any inbound frame (the `ping` is the documented + keep-alive) refreshes `Connection.last_seen`, and a silence of + `HEARTBEAT_DEAD_S` (12 s) is a dead client — the server closes `1000` and + the standard `finally` teardown runs, so the room sees exactly the same + drop as an abrupt close (one `mark_disconnected`, one `GHOST_ON`); this is + what catches a half-open TCP, where the read side would wait forever. The + endpoint races `read_in` against the watchdog: whichever decides first + (an abuse close, or the `1000` liveness close) closes the socket, the other + task is cancelled, and the `finally` marks the disconnect **exactly once**, +* **the input ingestion (task 03)** — an `input` frame goes through the + seat check (no seat — a spectator, or none — is `err not_seated`; a + *disconnected* seat is silently ignored), the `seq` discipline + (`seq_verdict` on `Connection.last_seq`: an equal seq is an idempotent + resend, silently dropped; a backwards one is `err bad_field` with the + frame's `seq` + the room's `resync` event, counted `stale_seqs`; a + jump > `TOLERATED_SEQ_GAP` ahead is accepted and counted `input_gaps`), + the clamp (`to_player_input` — `move` clamped to magnitude ≤ 1 and + quantized to 3 decimals, `face` derived from `move` when it is not a + known value, `act` coerced to bool, `received_at_tick` the server's + own tick for the contested arbitration) and the room's per-tick queue + (capacity `4 x len(seats)`; overflow drops the player's *oldest* + entry, `inputs_dropped` bumped — it never grows unbounded). The room's + `stats()` carries `inputs_received` / `inputs_dropped` / `stale_seqs` / + `clamped_moves` / `input_gaps`, +* **the snapshot stream (task 04)** — the room's tick task paces the + encode passes (players every `tick_hz / snapshot_hz` ticks, spectators + every `2 x` that); `SnapshotFanout` does the encode-once / + fan-out-many: the quantized frame (`server/encoding.py`), the delta + mode (a per-audience `prev`, the `FULL_SNAPSHOT_EVERY_TICKS` full + cadence, the per-connection `needs_full` resync — a dropped snapshot + or a fresh connection gets a full frame on the next tick), the events + on the frame of the boundary they occurred in (capped at + `MAX_EVENTS_PER_FRAME`, the overflow in `Room.stats() + ["events_dropped"]`), the `countdown` / `round` state frames (the + `round` frame's viewer-specific `unlocked` per connection) and the + `UNDERCOOKED_SIM_LATENCY_MS` downlink delay (the pump's per-batch + sleep — the asyncio queue delay netcode tests use to simulate RTT + without a proxy); +* **the abuse closes** — 3 *consecutive* rate/oversize violations inside 10 s + or 5 shape violations inside 10 s close with `4404` (a `note_clean` on an + accepted frame resets the rate run), +* **one socket per token** — a second `hello` for a token evicts the old + connection (`err evicted` + close `4403`, marked dead before the close so + the old pump sends no ghost frames); a *genuine* disconnect is reported + to the room (`supervisor.mark_disconnected`) *exactly once* in the + `finally`, the tasks are cancelled, and the registry slot is released. + An *evicted* socket never reports: its token's seat now belongs to the + new socket, and marking it disconnected would drop the seat — and the + host crown, if the evicted seat held it — from a player who is still + connected. + +**The drop, the ghost, the rejoin (task 05).** The supervisor's +`mark_disconnected` (the `finally`'s call) lands on `Room.on_disconnect` +(mark + one `GHOST_ON` + the sim cook's `DISCONNECTED` switch, the park +three seconds later — `server/room.py`), and a `hello` that reclaims a +disconnected seat goes through `Room.on_reconnect` (mark + one +`GHOST_OFF` + the sim restore) and answers `welcome` with +`rejoined: true`; after `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` the room's +own tick empties the seat (`SeatAbandoned` — the `GHOST_OFF`, the +freed seat, the forced carry release), and a rejoin past that falls +into the spectate/join rules (a `room_gone` close `4401` when the room +has closed in the meantime). + +**Droppability.** `send` is a `put_nowait`: it never blocks the game loop. +When the queue exceeds `MAX_SEND_QUEUE` the *oldest snapshot* is dropped +(snapshots are droppable — a stale position is worse than a missing one); +`lobby` / `round` / `err` are never dropped and keep their order. A duplicate +`rate_limited` `err` that is already queued is coalesced (a flood queues at +most one of them — the client is told once, and the queue cannot grow with +the flood). The `deadlines` map is a reserved pacing seam (the +`last_seq` seam is what task 03's `seq_verdict` reads — above); the +snapshot/heartbeat pacing itself is the `SnapshotFanout` (above), +driven by the room's tick task. + +Every timestamp is the injected `Clock` (the supervisor's); this module never +imports `time` (`tests/unit/test_server_clock_discipline.py` keeps it out). +The wire contract in prose is `docs/PROTOCOL.md`; the message models, the +`ErrorCode` / `CloseCode` constants and `MESSAGE_WHITELIST` live in +`server/protocol.py` and are imported here, never duplicated. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import logging +import math +import re +import resource +from dataclasses import dataclass, field +from typing import Final, Literal, Protocol + +from fastapi import WebSocket, WebSocketDisconnect + +from undercooked.config import Settings +from undercooked.server.clock import Clock +from undercooked.server.codes import normalize_code +from undercooked.server.encoding import ( + FULL_SNAPSHOT_EVERY_TICKS, + MAX_EVENTS_PER_FRAME, + SnapshotMeta, + encode_events, + encode_snapshot, + event_wire_dict, +) +from undercooked.server.protocol import ( + MESSAGE_WHITELIST, + MIN_SUPPORTED_VERSION, + PROTOCOL_VERSION, + CloseCode, + CountdownMsg, + EmoteMsg, + ErrMsg, + ErrorCode, + EvMsg, + HelloMsg, + InputMsg, + LobbyMsg, + NextLevelMsg, + PingMsg, + PongMsg, + ProtocolError, + ReadyMsg, + RoundMsg, + SeatWire, + SetLevelMsg, + SnapMsg, + StartMsg, + ToLobbyMsg, + WelcomeMsg, + YouWire, + _WireModel, + parse_client_message, + wire_dict, +) +from undercooked.server.room import Room, RoomState +from undercooked.server.rooms import ( + PlayerNotInRoom, + RoomClosed, + RoomFull, + RoomNotFound, + RoomSupervisor, +) +from undercooked.server.seats import ( + LevelLocked, + NotHost, + NotInLobby, + UnknownEmote, + UnknownLevel, +) +from undercooked.server.sessions import mint_token +from undercooked.sim.model import Event, Facing, Vec2 +from undercooked.sim.step import PlayerInput + +__all__ = [ + "ABUSE_WINDOW_S", + "HEARTBEAT_DEAD_S", + "HEARTBEAT_POLL_S", + "MAX_SEND_QUEUE", + "PUMP_BATCH", + "RATE_VIOLATIONS_TO_CLOSE", + "SHAPE_VIOLATIONS_TO_CLOSE", + "TOLERATED_SEQ_GAP", + "AbuseTracker", + "Connection", + "ConnectionRegistry", + "SnapshotFanout", + "TokenBucket", + "WsLike", + "evict", + "heartbeat_watchdog", + "origin_allowed", + "pump_out", + "read_in", + "seq_verdict", + "to_player_input", + "websocket_endpoint", +] + +logger = logging.getLogger(__name__) + +#: The send-queue cap (task 02): a snapshot older than this many queued frames +#: is dropped; non-droppable frames are kept in order (the `rate_limited` +#: coalescing bounds the one frame class a flood could otherwise multiply). +MAX_SEND_QUEUE: Final[int] = 8 + +#: `pump_out` moves at most this many frames per pass (the task's "batching ≤ +#: 4 frames per send": one awaited frame, then up to 3 more pulled without an +#: `await`, so a burst that arrived together leaves in one pass). +PUMP_BATCH: Final[int] = 4 + +#: The abuse window (docs/PROTOCOL.md "Limits"): violations are counted inside +#: a sliding `ABUSE_WINDOW_S` window off the injected clock. +ABUSE_WINDOW_S: Final[float] = 10.0 +#: 3 *consecutive* rate/oversize violations inside the window close `4404`. +RATE_VIOLATIONS_TO_CLOSE: Final[int] = 3 +#: 5 shape violations inside the window close `4404`. +SHAPE_VIOLATIONS_TO_CLOSE: Final[int] = 5 +#: The heartbeat dead window (task 05): no inbound traffic for this long +#: is a dead client — the server closes `1000` and the standard +#: disconnect teardown runs (the room sees the same drop as an abrupt +#: close; it also catches a half-open TCP, where the read side would +#: wait forever). A constant, not a setting: 12 s of silence is dead on +#: any honest client (the documented keep-alive is a `ping` at ≥ 5 Hz). +HEARTBEAT_DEAD_S: Final[float] = 12.0 +#: The watchdog's poll period (task 05): one `last_seen` check per second. +HEARTBEAT_POLL_S: Final[float] = 1.0 + +_ROLE = Literal["player", "spectator"] + + +class WsLike(Protocol): + """The only surface of a socket the `Connection`/pump need (the write + side). The real `fastapi.WebSocket` satisfies it structurally; a test + fake records `send_text` calls instead of opening a socket.""" + + async def send_text(self, data: str) -> None: ... + + +class TokenBucket: + """The per-connection inbound rate limit (PLAN §7): `rate` tokens per + second, a burst of `burst` (the task's `rate=max_inputs_per_s, + burst=max_inputs_per_s`). Time is the injected `Clock` — never a wall + clock (`tests/unit/test_server_clock_discipline.py`).""" + + def __init__(self, rate: float, burst: int, clock: Clock) -> None: + if rate <= 0: + msg = f"rate must be > 0 (got {rate})" + raise ValueError(msg) + if burst < 1: + msg = f"burst must be >= 1 (got {burst})" + raise ValueError(msg) + self._rate = float(rate) + self._burst = float(burst) + self._clock = clock + self._tokens = float(burst) + self._last = clock.now() + + @property + def rate(self) -> float: + return self._rate + + @property + def burst(self) -> int: + return int(self._burst) + + def _refill(self) -> None: + now = self._clock.now() + self._tokens = min(self._burst, self._tokens + (now - self._last) * self._rate) + self._last = now + + def take(self) -> bool: + """Refill off the clock, then take one token (or report the bucket + empty — the caller answers `rate_limited` and drops the frame).""" + self._refill() + if self._tokens >= 1.0: + self._tokens -= 1.0 + return True + return False + + def tokens(self) -> float: + """The current token count, refilled to now (diagnostics/tests).""" + self._refill() + return self._tokens + + +class AbuseTracker: + """The `4404` decision (PLAN §7 "disconnect after 5" / the task's + "3 consecutive violations inside 10 s"). Two counters, one sliding + `ABUSE_WINDOW_S` window each, off the injected clock: + + * `rate` — a `rate_limited` or `too_big` frame: **3 consecutive** + (a clean message resets the run, `note_clean`) close the socket; + * `shape` — a rejected frame (bad JSON/field, unknown type, wrong + state): **5 inside the window** close it. + """ + + def __init__(self, clock: Clock) -> None: + self._clock = clock + self._rate_run = 0 + self._rate_stamps: list[float] = [] + self._shape_stamps: list[float] = [] + + def note_clean(self) -> None: + """An accepted frame: the consecutive-rate run restarts.""" + self._rate_run = 0 + + def note_rate_violation(self) -> bool: + """One rate/oversize violation; True when the close is due (3 + consecutive, 3 inside the window).""" + self._rate_run += 1 + self._rate_stamps.append(self._clock.now()) + self._trim(self._rate_stamps) + return self._rate_run >= RATE_VIOLATIONS_TO_CLOSE and len( + self._rate_stamps + ) >= RATE_VIOLATIONS_TO_CLOSE + + def note_shape_violation(self) -> bool: + """One shape violation; True when the close is due (5 in the window).""" + self._shape_stamps.append(self._clock.now()) + self._trim(self._shape_stamps) + return len(self._shape_stamps) >= SHAPE_VIOLATIONS_TO_CLOSE + + def _trim(self, stamps: list[float]) -> None: + cutoff = self._clock.now() - ABUSE_WINDOW_S + while stamps and stamps[0] < cutoff: + stamps.pop(0) + + +@dataclass +class Connection: + """One live socket, as the endpoint, the pump and the registry see it + (the task's field list). + + `player_id` is the sim's identity for the seat (the token — the room + builds the sim from the seats' `player_id`); `seat_index` is `None` + for a spectator (whose `you.seat` in the `welcome` is the index in + the room's `spectators` list); `role` is the discriminator; + `last_seq` is the client's last accepted `input` seq (task 03's + `seq_verdict` decides on it: equal = idempotent resend, backwards = + resync, ahead = accept); `last_seen` is the injected clock's time of + the last *inbound* frame (task 05: the `heartbeat_watchdog` closes + on it after `HEARTBEAT_DEAD_S` of silence); `deadlines` is the + pacing seam phase 04/05 uses (`name -> next due time`, off the + injected clock). + """ + + ws: WsLike + token: str + player_id: str + seat_index: int | None + role: _ROLE + rate: TokenBucket + last_seq: int = 0 + bytes_in: int = 0 + last_seen: float = 0.0 + deadlines: dict[str, float] = field(default_factory=dict) + send_queue: asyncio.Queue = field(default_factory=asyncio.Queue) + alive: bool = True + #: The `on_disconnect` already reached the room (the "exactly once" guard). + disconnect_notified: bool = False + #: Evicted by a second socket for the same token (the teardown must not + #: mark the seat disconnected — the new socket owns it now). + replaced: bool = False + #: The next snapshot for this connection must be a *full* frame + #: (task 04): a fresh connection has not seen this room's last full + #: frame, and a dropped snapshot (below) may have broken its delta + #: chain — the fanout forces a full pass while it is set. + needs_full: bool = True + #: The `UNDERCOOKED_SIM_LATENCY_MS` downlink delay, in seconds + #: (task 04): the pump sleeps it before each batch, so netcode tests + #: simulate RTT without a proxy. + latency_s: float = 0.0 + #: The high-water mark of this queue's size (the registry's census + #: reads it: `send_queue_max`). Non-droppable frames (the `pong`s a + #: ping burst buys) can lawfully push a queue past `MAX_SEND_QUEUE` + #: until the pump drains it — the peak is what a budget test bounds. + queue_peak: int = 0 + + def enqueue(self, model: _WireModel) -> None: + """Queue one outbound frame without blocking (the task's `send`). + + A duplicate `rate_limited` `err` already in the queue is coalesced + (a flood queues at most one). When the queue has reached + `MAX_SEND_QUEUE`, the *oldest snapshot* is dropped before the new + frame is queued — snapshots are droppable, `lobby` / `round` / + `err` (and every other frame) are kept in order. + """ + if _is_rate_limited_err(model) and self._has_queued_rate_limited(): + return + while self.send_queue.qsize() >= MAX_SEND_QUEUE: + if not self._drop_oldest_snap(): + break # no snapshot to drop; the rest is non-droppable + self.send_queue.put_nowait(model) + self.queue_peak = max(self.queue_peak, self.send_queue.qsize()) + + def _drop_oldest_snap(self) -> bool: + """Remove the oldest `snap` from the queue without disturbing the + order of any other frame. + + A FIFO pop-and-re-put cannot do this: the held frames would land + *after* the survivors, re-ordering the non-droppable frames this + policy exists to protect (and eventually filling the queue with + exactly those, so the drop stops finding a snap and the queue + grows without bound). The direct `deque` removal keeps every + other frame exactly where it was. A dropped snapshot may have + broken this connection's delta chain (task 04): `needs_full` + marks the next snapshot for the connection a full one. + """ + for index, item in enumerate(self.send_queue._queue): # type: ignore[reportAttributeAccessIssue] - asyncio.Queue's internal deque + if getattr(item, "type", "") == "snap": + del self.send_queue._queue[index] # type: ignore[reportAttributeAccessIssue] + self.needs_full = True + return True + return False + + def _has_queued_rate_limited(self) -> bool: + return any( + _is_rate_limited_err(item) + for item in self.send_queue._queue # type: ignore[reportAttributeAccessIssue] + ) + + +def _is_rate_limited_err(model: _WireModel) -> bool: + return ( + getattr(model, "type", "") == "err" + and getattr(model, "code", "") == ErrorCode.RATE_LIMITED + ) + + +class ConnectionRegistry: + """The process-wide map of live sockets: by room and by token. + + One socket per token (PLAN §7): `add` hands the token's slot to the new + connection and returns the one it replaces (the evict target); a token + is in one room, so the token map is global while the room map is what + a broadcast (the `lobby` re-send, the phase-04 snapshot fan-out) + iterates. `remove` only drops a connection that still holds its slot — + an evicted connection is never removed twice. + """ + + def __init__(self) -> None: + self._by_room: dict[str, dict[str, Connection]] = {} + self._by_token: dict[str, Connection] = {} + + def add(self, code: str, conn: Connection) -> Connection | None: + """Register `conn` under its token; return the connection it + replaces (the evict target), or `None`.""" + previous = self._by_token.get(conn.token) + self._by_room.setdefault(code, {})[conn.token] = conn + self._by_token[conn.token] = conn + return previous + + def remove(self, code: str, conn: Connection) -> None: + """Release `conn`'s slots (only if it still holds them).""" + room = self._by_room.get(code) + if room is not None and room.get(conn.token) is conn: + del room[conn.token] + if not room: + self._by_room.pop(code, None) + self._by_token.pop(conn.token, None) + + def for_room(self, code: str) -> list[Connection]: + """The room's live connections (a broadcast's fan-out list).""" + return list(self._by_room.get(code, {}).values()) + + @property + def socket_count(self) -> int: + return len(self._by_token) + + @property + def send_queue_max(self) -> int: + """The high-water mark of every live connection's send queue. + + `enqueue` is a `put_nowait` and the drop policy bounds a queue at + `MAX_SEND_QUEUE` for droppable (snapshot) traffic; non-droppable + frames (a `pong` burst, a `lobby` storm) can push a queue past + that until the pump (≤ `PUMP_BATCH` frames per pass) drains it. + The census reads the peak, so a budget test sees the bound even + after the pump has already drained the burst.""" + peak = 0 + for room in self._by_room.values(): + for conn in room.values(): + peak = max(peak, conn.queue_peak) + return peak + + def memory_mb(self) -> float: + """The process's peak RSS in MB (the PLAN §10 stress-test + number, `resource` — no wall clock, no I/O beyond the kernel + counter). A stress test baselines it before the connections and + reads it after: the delta is their footprint.""" + return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024.0 + + def stats(self) -> dict[str, object]: + """The connection census: the socket count, the send-queue + high-water mark and the process RSS (MB) — the numbers the + phase-06 abuse/budget tests bound.""" + return { + "sockets": self.socket_count, + "send_queue_max": self.send_queue_max, + "memory_mb": self.memory_mb(), + } + + +_ORIGIN_RE = re.compile( + r"^(?P[a-z][a-z0-9+.-]*)://(?P[a-z0-9.]+)(?::(?P\d+))?$" +) + + +def origin_allowed( + origin: str | None, host_header: str | None, settings: Settings +) -> bool: + """The handshake origin check (PLAN §11), decided at request time. + + An explicit `allowed_origins` list: an exact, case-insensitive match + against the `Origin` header — a request without an `Origin` is refused, + and a `*` entry is honoured **only in dev mode** (`debugpy=1`); + production never matches `*` (it fails closed). An empty setting is + the same-host rule: the `Origin`'s `host[:port]` must equal the + request's `Host` header (a browser always sends an `Origin` on a + WebSocket upgrade; an *absent* `Origin` is allowed — a non-browser + client, the manual probe, is not a cross-site request). + """ + if settings.allowed_origins: + allowed = {item.lower() for item in settings.allowed_origins} + if "*" in allowed: + allowed.discard("*") # honoured only in dev mode (below) + if settings.debugpy: + return True + if origin is None: + return False + return origin.lower() in allowed + if origin is None: + return True + match = _ORIGIN_RE.match(origin.strip()) + if match is None: + return False + host = match.group("host").lower() + port = match.group("port") + if port is None: + port = "443" if match.group("scheme").lower() == "https" else "80" + target = (host_header or "").lower() + host_part, _, port_part = target.rpartition(":") + if not host_part: + host_part, port_part = target, "" + return host == host_part and ( + port == port_part or (port_part == "" and port in ("80", "443")) + ) + + +def _err(code: str, msg: str, seq: int | None = None) -> ErrMsg: + return ErrMsg(type="err", code=code, msg=msg, seq=seq) + + +def conn_spectator_index(room: Room, token: str) -> int: + """The token's position in `room.spectators` (0 for a token that is + neither seated nor watching — the join just placed it).""" + for index, spec in enumerate(room.spectators): + if spec.token == token: + return index + return 0 + + +def _welcome(room: Room, token: str, rejoin: bool = False) -> WelcomeMsg: + """The handshake answer: the viewer's own seat (`you`), the room code, + the state, the selected level, the seat list — and `rejoined` (task + 05): `true` when this `hello` reclaims a *disconnected* seat (the + rejoin inside the grace; a fresh join and a duplicate `hello` on a + live socket are `false`).""" + seat = room.seat_for_token(token) + if seat is not None: + you = YouWire(token=token, seat=seat.seat_index, host=seat.host) + else: + you = YouWire(token=token, seat=conn_spectator_index(room, token), host=False) + seats = [ + SeatWire( + index=s.seat_index, + name=s.display_name, + color=s.color, + ready=s.ready, + host=s.host, + connected=s.connected, + ) + for s in sorted(room.seats, key=lambda item: (item.seat_index, item.token)) + ] + return WelcomeMsg( + you=you, + room=room.code, + state=room.state.value, + level=room.level_id, + seats=seats, + rejoined=rejoin, + ) + + +def _broadcast_lobby(room: Room, registry: ConnectionRegistry) -> None: + """The `lobby` re-send: one frame per live connection, each built for + *that* viewer (the `unlocked` field is viewer-specific).""" + for conn in registry.for_room(room.code): + conn.enqueue(LobbyMsg.model_validate(room.lobby_payload(conn.token))) + + +async def evict(old: Connection) -> None: + """One socket per token: `err evicted` + close `4403` to the + *replaced* connection, marked dead **before** the close (its pump + sends no ghost frames after the mark — the at-most-one in-flight + frame is the documented bound). A connection that is already dead + (its socket already closed) is left alone.""" + if not old.alive: + return + old.alive = False + old.replaced = True + try: + await old.ws.send_text(json.dumps(wire_dict(_err( + ErrorCode.EVICTED, + "a second socket for this token replaced this one", + )))) + close = getattr(old.ws, "close", None) + if close is not None: + await close(CloseCode.EVICTED) + except Exception: + # A dead socket is the eviction's end state: the mark stands. + logger.debug("evicted socket already closed", exc_info=True) + + +#: The state-frame models (the room's `countdown` / `round` payloads, +#: PLAN §7) — the fanout validates the room's plain payloads against +#: these, so the wire stays typed. +_STATE_MODELS: Final[dict[str, type[_WireModel]]] = { + "countdown": CountdownMsg, +} + + +class SnapshotFanout: + """One room's snapshot stream (task 04): the encode-once / + fan-out-many the room's tick task drives (`Room` paces the passes; + this owns the encoding and the fan-out). + + Per audience (`player` / `spectator`) it keeps the stream's `prev` + (the last *encoded* frame — a delta's base), the full cadence + (`FULL_SNAPSHOT_EVERY_TICKS`) and the per-connection `needs_full` + resync (a fresh connection's first frame is a full one; a dropped + snapshot — its queue's drop policy marks `needs_full` — forces the + next one full, so a client that has missed a frame is re-sent a + full one on the next tick). Events ride the frame of the boundary + they occur in (the `Room` paces a pass every `tick_hz / + snapshot_hz` ticks for players, `2 x` for spectators): the + per-room cursor (`_events_seen_seq`) reads `Room.event_buffer` + non-destructively, the cap is `MAX_EVENTS_PER_FRAME` and the + overflow counts in `Room.stats()["events_dropped"]`. + + The state frames (`countdown` / `round`) reach *all* connections; + the `round` frame's viewer-specific `unlocked` is filled in per + connection from `Room.next_level_id`. A pass with no audience + encodes nothing (encode on demand) and leaves the stream alone — a + later connection still gets a full frame first (its `needs_full` + default), and a delta is always a diff against the last encoded + frame, so the chain a client applies cannot skip. + """ + + def __init__(self, room: Room, registry: ConnectionRegistry) -> None: + self._room = room + self._registry = registry + self._prev: dict[str, SnapshotMeta | None] = {"player": None, "spectator": None} + self._passes_since_full: dict[str, int] = {"player": 0, "spectator": 0} + self._events_seen_seq = 0 + + def pass_( + self, + audiences: set[str], + state_payloads: tuple[dict[str, object], ...], + ) -> None: + """One tick's boundary (the `Room` broadcast seam). The state + frames fan out first; the due snapshot audiences encode (at most + one frame per audience — reused across the audience's + connections); the `ev` flush serves the non-`PLAYING` states. + """ + for payload in state_payloads: + self._send_state(payload) + if "player" in audiences or "spectator" in audiences: + encoded = self._capped_events(self._new_events()) + if "player" in audiences: + self._snapshot_pass("player", encoded) + if "spectator" in audiences: + self._snapshot_pass("spectator", encoded) + if "ev" in audiences: + self._ev_flush() + + def _new_events(self) -> list[Event]: + """The buffer's events since the last consumed seq. The buffer + is never truncated by this (its cap only evicts the *oldest* — + and a live room's cursor is at most one boundary behind the + buffer's tail, so an eviction cannot skip an unsent event).""" + pairs = self._room.event_buffer + fresh = [event for seq, event in pairs if seq > self._events_seen_seq] + if fresh: + self._events_seen_seq = max(seq for seq, _ in pairs) + return fresh + + def _capped_events(self, events: list[Event]) -> list[dict[str, object]]: + """The boundary's events onto the frame, capped (task 04): at + most `MAX_EVENTS_PER_FRAME`; the overflow counts in + `Room.stats()["events_dropped"]`.""" + encoded = encode_events(events) + if len(encoded) > MAX_EVENTS_PER_FRAME: + self._room.note_event_dropped(len(encoded) - MAX_EVENTS_PER_FRAME) + encoded = encoded[:MAX_EVENTS_PER_FRAME] + return encoded + + def _snapshot_pass( + self, audience: str, events: list[dict[str, object]] + ) -> None: + """One audience's encode pass (encode-once, fan-out-many): a + full frame when the cadence is due, when any of the audience's + connections needs a resync (or has not seen a full yet), else + the delta against the stream's `prev`.""" + room = self._room + state = room.sim_state + if state is None: + return # not built yet (the first `PLAYING` tick) + role: _ROLE = "player" if audience == "player" else "spectator" + conns = [ + conn + for conn in self._registry.for_room(room.code) + if conn.role == role + ] + if not conns: + return # no audience: encode on demand, the stream waits + self._passes_since_full[audience] += 1 + full = ( + self._passes_since_full[audience] >= FULL_SNAPSHOT_EVERY_TICKS + or any(conn.needs_full for conn in conns) + ) + prev = None if full else self._prev[audience] + frame = encode_snapshot(state, prev, event_entries=events) + msg = SnapMsg(raw=frame) + for conn in conns: + conn.enqueue(msg) + if full: + conn.needs_full = False + self._prev[audience] = frame + if full: + self._passes_since_full[audience] = 0 + + def _ev_flush(self) -> None: + """The non-`PLAYING` boundary (task 04): the buffer's new events + fan out to *all* the room's connections as the standalone `ev` + frame (PLAN §7 — a lobby emote rides here), capped the same + way.""" + events = self._new_events() + if not events: + return + encoded = self._capped_events(events) + msg = EvMsg.model_validate( + { + "type": "ev", + "events": [ + event_wire_dict(event) for event in events[: len(encoded)] + ], + } + ) + for conn in self._registry.for_room(self._room.code): + conn.enqueue(msg) + + def _send_state(self, payload: dict[str, object]) -> None: + """A state frame (the room's `countdown` / `round` payload, + PLAN §7) to every connection in the room: validated against the + protocol model (the wire stays typed), the `round` frame's + viewer-specific `unlocked` filled in per connection.""" + room = self._room + conns = self._registry.for_room(room.code) + if not conns: + return + msg_type = payload.get("type") + if not isinstance(msg_type, str): + logger.warning( + "room %s: a state payload without a string `type` was dropped", + room.code, + ) + return + if msg_type == "round": + for conn in conns: + per = dict(payload) + per["unlocked"] = room.next_level_id(conn.token) + conn.enqueue(RoundMsg.model_validate(per)) + return + model_cls = _STATE_MODELS.get(msg_type) + if model_cls is None: + logger.warning( + "room %s: no state model for payload type %r", + room.code, + msg_type, + ) + return + msg = model_cls.model_validate(payload) + for conn in conns: + conn.enqueue(msg) + + +async def heartbeat_watchdog( + conn: Connection, + clock: Clock, + poll_s: float = HEARTBEAT_POLL_S, +) -> int | None: + """The server-side liveness (task 05): one `last_seen` check per + `poll_s`; a silence of `HEARTBEAT_DEAD_S` returns `CloseCode.NORMAL` + (the endpoint closes `1000` and runs the standard `finally` teardown + — the room sees exactly the same drop as an abrupt close, one + `mark_disconnected`, one `GHOST_ON`). A dead `conn` (an eviction, the + pump's socket dying) ends the watch quietly.""" + while conn.alive: + await asyncio.sleep(poll_s) + if clock.now() - conn.last_seen >= HEARTBEAT_DEAD_S: + return CloseCode.NORMAL + return None + + +async def pump_out(ws: WsLike, conn: Connection) -> None: + """Drain `conn.send_queue` onto the socket (the task's `pump_out`). + + One pass moves at most `PUMP_BATCH` frames: the first is awaited, then + up to `PUMP_BATCH - 1` more are pulled without an `await`, so a burst + that arrived together leaves in one pass. The pump is the only + awaiting consumer — `Connection.enqueue` is a `put_nowait`, so the + game loop never blocks on a slow reader (the queue's drop policy + bounds it). A dead `conn` (evicted) ends the pump before the next + frame is written; a socket error ends it the same way (the + endpoint's `finally` cancels and cleans up). + """ + while conn.alive: + frame = await conn.send_queue.get() + if not conn.alive: + return # evicted while waiting: no ghost frame + batch: list[_WireModel] = [frame] + for _ in range(PUMP_BATCH - 1): + try: + batch.append(conn.send_queue.get_nowait()) + except asyncio.QueueEmpty: + break + # The `UNDERCOOKED_SIM_LATENCY_MS` delay (task 04): one sleep per + # batch — the asyncio queue delay netcode tests use to simulate + # RTT without a proxy (the default 0 never sleeps). + if conn.latency_s > 0.0: + await asyncio.sleep(conn.latency_s) + for model in batch: + if not conn.alive: + return + try: + await ws.send_text(json.dumps(wire_dict(model))) + except Exception: + # A dead socket ends the pump; the endpoint's `finally` + # cancels and cleans up. + logger.debug("the pump's socket died; ending the pump", exc_info=True) + conn.alive = False + return + + +class _HandshakeTimeoutError(Exception): + """No `hello` within the window (the endpoint closes `4400`).""" + + +class _ProtocolMismatchError(Exception): + """A `hello` whose `p` is outside the supported range (close `4400`, + before any seat is taken — PLAN §7 / the task's `4400` gate).""" + + def __init__(self, err: ErrMsg) -> None: + super().__init__(str(err.msg)) + self.err = err + + +class _AbuseCloseError(Exception): + """5 shape violations inside the window during the handshake (close `4404`).""" + + +class _RoomGoneError(Exception): + """The room vanished or closed before the seat could be taken (`4401`).""" + + +class _RoomFullError(Exception): + """The room is full and the token is new (`4402`).""" + + +async def _receive_frame_bytes(ws: WebSocket) -> bytes | None: + """One inbound frame as raw bytes (the `read_in`'s / the + handshake's read, with the binary blind spot closed): a + `websocket.receive` with `text` is UTF-8 encoded back to the wire + bytes, one with `bytes` is the frame as received — a binary frame + is a frame the `max_message_bytes` cap and the `parse_client_message` + shape check apply to (a `too_big` / a `bad_json`), never a server + exception (the stack's `receive_text()` used to `KeyError` on one, + skipping the guardrails entirely). `websocket.disconnect` is the + loop's end (`None`).""" + message = await ws.receive() + if message["type"] == "websocket.disconnect": + return None + if "text" in message: + return message["text"].encode("utf-8") + if "bytes" in message: + return message["bytes"] + msg = f"an unexpected ASGI message reached the endpoint: {message}" + raise RuntimeError(msg) + + +async def _handshake( + ws: WebSocket, + conn: Connection, + tracker: AbuseTracker, + settings: Settings, + clock: Clock, +) -> HelloMsg: + """The handshake window: the first `hello`, within + `settings.hello_timeout_s` (else `_HandshakeTimeoutError`). + + A `ping` in the window gets a `pong` (the keep-alive is legal + "always" — `protocol.PingMsg`); a malformed frame is an `err` + a + shape violation (5 inside the window → `_AbuseCloseError`); a + well-formed message that is neither `hello` nor `ping` is + `err wrong_state` (the identity is unknown — hello first). + """ + while True: + try: + raw = await asyncio.wait_for( + _receive_frame_bytes(ws), settings.hello_timeout_s + ) + except TimeoutError: + raise _HandshakeTimeoutError from None + if raw is None: + raise WebSocketDisconnect(code=1006) + parsed = parse_client_message(raw) + if isinstance(parsed, HelloMsg): + return parsed + if isinstance(parsed, ProtocolError): + if parsed.code == ErrorCode.PROTOCOL_MISMATCH: + # An unsupported `p` is a version refusal, not a shape + # violation: the documented close is `4400`, on the spot, + # before any seat is taken (PLAN §7; the parser's `p` gate + # is what lands the frame here, so this fires on the first + # bad frame — no waiting out the abuse window). + raise _ProtocolMismatchError(parsed.to_err()) + conn.enqueue(parsed.to_err()) + if tracker.note_shape_violation(): + raise _AbuseCloseError + continue + if isinstance(parsed, PingMsg): + await ws.send_text(json.dumps(wire_dict(PongMsg(t=parsed.t, now=clock.now())))) + continue + conn.enqueue(_err( + ErrorCode.WRONG_STATE, + f"{type(parsed).__name__} is not legal before the hello — send it first", + )) + if tracker.note_shape_violation(): + raise _AbuseCloseError + + +async def _seat_or_spectate( + conn: Connection, + room: Room, + hello: HelloMsg, + supervisor: RoomSupervisor, + registry: ConnectionRegistry, +) -> None: + """The supervisor's `join` (the rejoin reclaims the seat, a late join + spectates, a full room refuses a new token with `_RoomFullError`), + the one-socket-per-token registry swap (a replaced connection is + evicted), and the `welcome` + the current `lobby`. + + Refusals raise the close decision: `_RoomGoneError` (`4401`, the room + vanished or closed in the meantime), `_RoomFullError` (`4402`). A `p` + mismatch is refused by the endpoint before this runs (no seat is + taken on a `protocol_mismatch`). + """ + token = hello.token if hello.token is not None else mint_token() + conn.token = token + # The `rejoined` flag (task 05): judged *before* the `join` — a + # token that already holds a *disconnected* seat in this room is a + # rejoin inside the grace (a fresh token, or a live seat, is not). + previous_seat = room.seat_for_token(token) if hello.token is not None else None + was_ghost = previous_seat is not None and not previous_seat.connected + try: + supervisor.join(room.code, token, hello.name) + except (RoomClosed, RoomNotFound) as exc: + raise _RoomGoneError from exc + except RoomFull as exc: + raise _RoomFullError from exc + seat = room.seat_for_token(token) + if seat is not None: + conn.role = "player" + conn.seat_index = seat.seat_index + conn.player_id = seat.player_id + else: + conn.role = "spectator" + conn.seat_index = None + conn.player_id = token + previous = registry.add(room.code, conn) + if previous is not None and previous is not conn: + await evict(previous) + conn.enqueue(_welcome(room, token, rejoin=was_ghost)) + conn.enqueue(LobbyMsg.model_validate(room.lobby_payload(token))) + + +async def _reseat( + conn: Connection, + room: Room, + hello: HelloMsg, + supervisor: RoomSupervisor, + registry: ConnectionRegistry, +) -> None: + """A `hello` on an already-handshaken socket (a rejoin on the same + connection): re-run the seat/spectate (the supervisor's rejoin marks + the seat connected again), refresh the identity, re-send the + `welcome` + `lobby`. A room that vanished or filled up under a live + connection is not fatal here: the next frame's checks answer it.""" + with contextlib.suppress(_RoomGoneError, _RoomFullError): + await _seat_or_spectate(conn, room, hello, supervisor, registry) + + +def _refuse_not_seated(conn: Connection) -> None: + conn.enqueue(_err(ErrorCode.NOT_SEATED, "the token holds no seat in this room")) + + +async def _dispatch( + conn: Connection, + room: Room, + message: object, + supervisor: RoomSupervisor, + registry: ConnectionRegistry, +) -> None: + """One accepted frame → the room's verb (a named refusal → `err`; a + `LOBBY`-state change → the `lobby` re-broadcast). An `input` frame + goes to `_dispatch_input` (task 03: the seat check, the seq + discipline, the clamp and the per-tick queue).""" + if isinstance(message, PingMsg): + conn.enqueue(PongMsg(t=message.t, now=supervisor.clock.now())) + elif isinstance(message, ReadyMsg): + _dispatch_ready(conn, room, registry) + elif isinstance(message, StartMsg): + _dispatch_start(conn, room) + elif isinstance(message, SetLevelMsg): + _dispatch_set_level(conn, room, message, registry) + elif isinstance(message, EmoteMsg): + _dispatch_emote(conn, room, message) + elif isinstance(message, ToLobbyMsg): + room.to_lobby() + _broadcast_lobby(room, registry) + elif isinstance(message, NextLevelMsg): + _dispatch_next_level(conn, room) + elif isinstance(message, InputMsg): + _dispatch_input(conn, room, message) + else: + # A type that is in the whitelist but has no verb yet (there is + # none in v1): a silent no-op would hide a protocol drift. + conn.enqueue(_err(ErrorCode.UNKNOWN_TYPE, f"no handler for {type(message).__name__}")) + + +def _dispatch_ready( + conn: Connection, room: Room, registry: ConnectionRegistry +) -> None: + """The ready-up (a toggle — `Seat.toggle_ready`); the `lobby` + re-broadcast carries the flag.""" + seat = room.seat_for_token(conn.token) + if seat is None: + _refuse_not_seated(conn) + return + seat.toggle_ready() + _broadcast_lobby(room, registry) + + +def _dispatch_start(conn: Connection, room: Room) -> None: + """The host's *Start*; the `StartRefused` family's reason rides in + the `err` text (`no_level` / `no_ready_players`).""" + try: + room.start(conn.token) + except NotInLobby: + conn.enqueue(_err(ErrorCode.WRONG_STATE, "the room is not in the lobby")) + except NotHost: + conn.enqueue(_err(ErrorCode.NOT_HOST, "only the host can start the room")) + except Exception as exc: + reason = getattr(exc, "reason", None) or type(exc).__name__ + conn.enqueue(_err(ErrorCode.WRONG_STATE, f"the start was refused: {reason}")) + + +def _dispatch_set_level( + conn: Connection, room: Room, message: SetLevelMsg, registry: ConnectionRegistry +) -> None: + """The host's level pick; the named refusals map onto the wire codes + (`UnknownLevel` → `bad_field`, `LevelLocked` → `level_locked`); the + accepted pick re-broadcasts the `lobby`.""" + try: + room.set_level(conn.token, message.level) + except NotInLobby: + conn.enqueue(_err(ErrorCode.WRONG_STATE, "the room is not in the lobby")) + except NotHost: + conn.enqueue(_err(ErrorCode.NOT_HOST, "only the host can pick the level")) + except UnknownLevel as exc: + conn.enqueue(_err(ErrorCode.BAD_FIELD, str(exc))) + except LevelLocked as exc: + conn.enqueue(_err(ErrorCode.LEVEL_LOCKED, str(exc))) + _broadcast_lobby(room, registry) + + +def _dispatch_emote( + conn: Connection, room: Room, message: EmoteMsg +) -> None: + """The emote (the room validates against the whitelist, throttles and + re-broadcasts as an `ev` event; an `UnknownEmote` is `bad_field` with + the whitelist in the text).""" + try: + room.emote(conn.token, message.id) + except UnknownEmote as exc: + conn.enqueue(_err(ErrorCode.BAD_FIELD, str(exc))) + + +def _dispatch_next_level(conn: Connection, room: Room) -> None: + """`next_level`: the level id is resolved server-side from the + viewer's session (`Room.next_level_id`); a `None` resolution is the + documented `bad_field`.""" + level_id = room.next_level_id(conn.token) + if level_id is None: + conn.enqueue(_err( + ErrorCode.BAD_FIELD, + "this session has no next level to start (the campaign is done or unknown)", + )) + return + room.next_level(level_id) + + +TOLERATED_SEQ_GAP: Final[int] = 3 +"""A `seq` may jump at most this far ahead of the last accepted one +without being counted in the room's `input_gaps` (task 03: "gaps of +≤ 3 are tolerated (UDP-ish loss), larger gaps accepted but logged in +room stats"). Mirrors `Room.TOLERATED_SEQ_GAP` (one rule, named in +both layers — the room's copy is the one `queue_input`'s docstring +points at).""" + + +def seq_verdict(last_seq: int, seq: int) -> Literal["accept", "duplicate", "stale"]: + """The `seq` discipline's verdict for one accepted frame (task 03): + + * `seq == last_seq` → `"duplicate"` — an idempotent resend, + silently dropped (the frame's effect already landed); + * `seq < last_seq` → `"stale"` — a replay the client was already + answered: `err bad_field` (carrying the frame's `seq`) + the + room's `resync` event (the client hard-snapshots), counted + `stale_seqs`; `last_seq` is never lowered; + * `seq > last_seq` → `"accept"` — a jump of ≤ `TOLERATED_SEQ_GAP` + is tolerated silently (UDP-ish loss), a larger one is accepted + and counted `input_gaps`. + """ + if seq == last_seq: + return "duplicate" + if seq < last_seq: + return "stale" + return "accept" + + +def _derive_face(face: str | None, direction: Vec2) -> Facing | None: + """The `PlayerInput`'s facing from the wire's `face` (task 03): + a known value wins; `None` (or an unknown one — unreachable over + the wire, `InputMsg` rejects it as `bad_field` first, but the + function is the clamp layer's and is also called on directly + constructed messages) is *derived from `move`*: the dominant axis + wins (a perfect 45° press resolves to the horizontal axis, the + sim's `movement.update_facing` rule); a zero `move` derives `None` + ("hold").""" + if face is not None: + try: + return Facing(face) + except ValueError: + pass # derive (defensive; the model already rejected it) + if direction.x == 0.0 and direction.y == 0.0: + return None + if abs(direction.x) >= abs(direction.y): + return Facing.RIGHT if direction.x > 0 else Facing.LEFT + return Facing.DOWN if direction.y > 0 else Facing.UP + + +def to_player_input(conn: Connection, msg: InputMsg, tick: int) -> PlayerInput: + """One accepted `input` frame → the sim's `PlayerInput` (task 03). + + * `move` is **clamped** to magnitude ≤ 1 (the wire's `Vec2Msg` + already rejected a magnitude > 1 as `bad_field` — this is the + second, sim-side half of the rule, the one that covers + directly-constructed frames: a `(1000, 1000)` intent becomes a + unit diagonal, never a speed boost — the sim's + `movement.intent_direction()` is the backstop behind this one) + and **quantized to 3 decimals** (the wire's own precision + floor; a replay must reproduce a frame's effect, and a float + past 3 decimals is client noise, not intent); + * `face` must be a known value, else it is derived from `move` + (`_derive_face`); + * `act` is **coerced to bool** (defensive — the model is strict + bool; a directly-constructed truthy int becomes a press); + * `received_at_tick` is the **server's own tick** — the contested + arbitration's ordering (the sim's `_is_earlier`) never depends + on asyncio scheduling or the client's clock. + """ + direction = Vec2(msg.move.x, msg.move.y) + length = direction.length + if length > 1.0: + direction = direction.scale(1.0 / length) + move = direction.quantized(3) + return PlayerInput( + player_id=conn.token, + seq=msg.seq, + move=move, + action=bool(msg.act), + face=_derive_face(msg.face, direction), + received_at_tick=tick, + ) + + +def _dispatch_input( + conn: Connection, room: Room, message: InputMsg +) -> None: + """One `input` frame → the seq discipline (task 03) → the room's + per-tick input queue. + + In order: 1. the seat check — no seat (a spectator, or none) is + `err not_seated`; a *disconnected* seat is silently ignored (the + frame arrived on a socket that is not the one that dropped, and + the sim parks the cook anyway — `Room.queue_input` repeats the + guard as the sim-side backstop); 2. the seq discipline — + `seq_verdict` on `conn.last_seq` (a duplicate is silently dropped; + a stale one is `err bad_field` *with the frame's `seq* + the + room's `resync` event, counted `stale_seqs`; an accepted one + updates `last_seq`, counting a jump > `TOLERATED_SEQ_GAP` in + `input_gaps`); 3. the clamp — a `move` magnitude > 1 (impossible + over the wire, possible on a directly-constructed frame) is + counted `clamped_moves` before `to_player_input` clamps it; + 4. `to_player_input` (clamp, quantize, `face` derivation, `act` + coercion, `received_at_tick`) → `room.queue_input` (capacity + `4 x len(seats)`, overflow drops the player's oldest entry). + """ + seat = room.seat_for_token(conn.token) + if seat is None: + _refuse_not_seated(conn) + return + if not seat.connected: + return # a disconnected seat's actions are ignored (task 03) + verdict = seq_verdict(conn.last_seq, message.seq) + if verdict == "duplicate": + return # an idempotent resend: silently dropped + if verdict == "stale": + conn.enqueue(_err( + ErrorCode.BAD_FIELD, + f"input seq {message.seq} is behind the last accepted " + f"({conn.last_seq}); the client must resync", + seq=message.seq, + )) + room.note_stale_seq(conn.token, message.seq) + return + if message.seq - conn.last_seq > TOLERATED_SEQ_GAP: + room.note_input_stat("input_gaps") + logger.debug( + "room %s: an input jumped %d seq ahead (last %d, now %d)", + room.code, + message.seq - conn.last_seq, + conn.last_seq, + message.seq, + ) + conn.last_seq = message.seq + if math.hypot(message.move.x, message.move.y) > 1.0: + room.note_input_stat("clamped_moves") + tick = room.sim_state.tick if room.sim_state is not None else 0 + room.queue_input(conn.token, to_player_input(conn, message, tick)) + + +@dataclass(frozen=True) +class _Inbound: + """The per-connection context of the inbound loop (keeps `read_in` + three-arg and shares the same data with the per-frame handler).""" + + room: Room + supervisor: RoomSupervisor + settings: Settings + registry: ConnectionRegistry + tracker: AbuseTracker + + +async def _negotiate( + ws: WebSocket, + conn: Connection, + tracker: AbuseTracker, + settings: Settings, + clock: Clock, +) -> HelloMsg | None: + """The handshake window plus the shared refusals: returns the + `hello`, or `None` after a refusal (the timeout → `4400`, the + abuse → `4404`; both already closed).""" + try: + return await _handshake(ws, conn, tracker, settings, clock) + except _HandshakeTimeoutError: + await _refuse(ws, CloseCode.PROTOCOL_MISMATCH, None) + return None + except _ProtocolMismatchError as exc: + await _refuse(ws, CloseCode.PROTOCOL_MISMATCH, exc.err) + return None + except _AbuseCloseError: + await _refuse(ws, CloseCode.ABUSE, None) + return None + + +async def _process_frame( + conn: Connection, inbound: _Inbound, raw: bytes +) -> int | None: + """One frame, in order: the size cap (→ `too_big`, a rate + violation); the token bucket (→ `rate_limited`, a rate violation — + the frame is dropped without being parsed); the shape check (→ + `err`, a shape violation); the per-state whitelist (→ `wrong_state`, + a shape violation); the semantics (a `hello` re-runs the + seat/spectate; the room's verbs, a named refusal → `err`, a lobby + change → the re-broadcast). + + Returns the close code when the frame tripped an abuse close, else + `None`.""" + settings = inbound.settings + if len(raw) > settings.max_message_bytes: + conn.enqueue(_err( + ErrorCode.TOO_BIG, + f"a frame of {len(raw)} bytes exceeds max_message_bytes " + f"({settings.max_message_bytes})", + )) + return CloseCode.ABUSE if inbound.tracker.note_rate_violation() else None + if not conn.rate.take(): + conn.enqueue(_err( + ErrorCode.RATE_LIMITED, + "the per-connection message rate limit is exceeded " + "(token bucket empty)", + )) + return CloseCode.ABUSE if inbound.tracker.note_rate_violation() else None + inbound.tracker.note_clean() + parsed = parse_client_message(raw) + if isinstance(parsed, ProtocolError): + conn.enqueue(parsed.to_err()) + return CloseCode.ABUSE if inbound.tracker.note_shape_violation() else None + if isinstance(parsed, HelloMsg): + await _reseat(conn, inbound.room, parsed, inbound.supervisor, inbound.registry) + return None + if parsed.type not in MESSAGE_WHITELIST[inbound.room.state]: + conn.enqueue(_err( + ErrorCode.WRONG_STATE, + f"{parsed.type} is not legal in state {inbound.room.state.value}", + )) + return CloseCode.ABUSE if inbound.tracker.note_shape_violation() else None + await _dispatch(conn, inbound.room, parsed, inbound.supervisor, inbound.registry) + return None + + +async def read_in(ws: WebSocket, conn: Connection, inbound: _Inbound) -> int | None: + """The inbound loop (the task's step 4): the `bytes_in` accounting + and `_process_frame`'s ordered checks, per frame. + + Returns the close code the server should send (a `4404` abuse + close), or `None` when the client hung up (nothing to close). + """ + while conn.alive: + raw = await _receive_frame_bytes(ws) + if raw is None: + return None + conn.bytes_in += len(raw) + conn.last_seen = inbound.supervisor.clock.now() + code = await _process_frame(conn, inbound, raw) + if code is not None: + return code + return None + + +async def _refuse(ws: WebSocket, close_code: int, err: ErrMsg | None) -> None: + """The shared refusal (a `44xx` path): the `err` (when given), then + the close with the documented code (a dead socket is a no-op).""" + if err is not None: + with contextlib.suppress(Exception): + await ws.send_text(json.dumps(wire_dict(err))) + with contextlib.suppress(Exception): + await ws.close(code=close_code) + + +async def _open_connection( + ws: WebSocket, + code: str, + supervisor: RoomSupervisor, + settings: Settings, + registry: ConnectionRegistry, +) -> tuple[Connection | None, str | None, _Inbound | None]: + """The pre-task steps (2-4): the room lookup (a `4401` refusal, the + accept first), the connection, the handshake window (a `4400` / + `4404` refusal), the `p` gate (a `4400` refusal before any seat is + taken) and the seat/spectate (a `4401` / `4402` refusal). Each + refusal sends its `err`, closes with the documented code, and + returns the `(None, None, None)` triple.""" + # The accept already went out (the endpoint, after the origin check), + # so every refusal below is a real close frame, never a network error. + room = supervisor.get(code) + if room is None or room.state is RoomState.CLOSED: + await _refuse(ws, CloseCode.ROOM_GONE, _err( + ErrorCode.ROOM_GONE, "no live room under that code", + )) + return None, None, None + + conn = Connection( + ws=ws, + token="", + player_id="", + seat_index=None, + role="player", + rate=TokenBucket( + settings.max_inputs_per_s, settings.max_inputs_per_s, supervisor.clock + ), + last_seen=supervisor.clock.now(), + latency_s=settings.sim_latency_ms / 1000.0, + ) + tracker = AbuseTracker(supervisor.clock) + hello = await _negotiate(ws, conn, tracker, settings, supervisor.clock) + if hello is None: + return None, None, None + # The `p` gate itself is the parser's (a `HelloMsg` that survives the + # parse carries a supported `p`; an unsupported one is a + # `protocol_mismatch` `ProtocolError` the handshake refuses with `4400` + # — see `_ProtocolMismatchError`). The range check below is the + # defence-in-depth for a parser change. + if not MIN_SUPPORTED_VERSION <= hello.p <= PROTOCOL_VERSION: + await _refuse(ws, CloseCode.PROTOCOL_MISMATCH, _err( + ErrorCode.PROTOCOL_MISMATCH, + f"unsupported protocol version {hello.p} " + f"(server speaks {MIN_SUPPORTED_VERSION}..{PROTOCOL_VERSION})", + )) + return None, None, None + try: + await _seat_or_spectate(conn, room, hello, supervisor, registry) + except _RoomGoneError: + await _refuse(ws, CloseCode.ROOM_GONE, _err( + ErrorCode.ROOM_GONE, "the room is closed or gone", + )) + return None, None, None + except _RoomFullError: + await _refuse(ws, CloseCode.ROOM_FULL, _err( + ErrorCode.ROOM_FULL, + f"room {room.code} is full ({room.max_players}/{room.max_players})", + )) + return None, None, None + return conn, room.code, _Inbound( + room=room, + supervisor=supervisor, + settings=settings, + registry=registry, + tracker=tracker, + ) + + +@dataclass(frozen=True) +class _TeardownCtx: + """The `finally` context: the supervisor (the `on_disconnect`), the + registry (the release) and the two code spellings.""" + + supervisor: RoomSupervisor + registry: ConnectionRegistry + room_code: str + raw_code: str + + +async def _teardown( + conn: Connection, + ctx: _TeardownCtx, + tasks: tuple[ + asyncio.Task[None] | None, + asyncio.Task[int | None] | None, + asyncio.Task[int | None] | None, + ], +) -> None: + """Step 5 (the `finally`): the task cancellation, the + `on_disconnect` to the room exactly once, the registry release. A + `mark_disconnected` refusal (a token that never got a seat) is the + expected no-op.""" + for task in tasks: + if task is not None and not task.done(): + task.cancel() + for task in tasks: + if task is not None: + # `CancelledError` is a `BaseException`: a cancelled pump is the + # *expected* end state, so it is suppressed explicitly (a bare + # `suppress(Exception)` would let it escape the endpoint and + # surface as an ASGI exception). + with contextlib.suppress(Exception, asyncio.CancelledError): + await task + if not conn.disconnect_notified and not conn.replaced: + conn.disconnect_notified = True + with contextlib.suppress(PlayerNotInRoom, RoomNotFound, RoomClosed): + ctx.supervisor.mark_disconnected(ctx.raw_code, conn.token) + ctx.registry.remove(ctx.room_code, conn) + + +async def websocket_endpoint(ws: WebSocket, code: str) -> None: + """`/ws/{code}` — the whole connection lifecycle (the task's + endpoint): 1. the origin check **before** the accept (a disallowed + origin is refused at the HTTP level — the client sees a 403, never + a close frame that looks like a network error); 2-4. the room + lookup, the handshake window, the `p` gate and the seat/spectate + (each refusal via `_open_connection`); 5. the two tasks + (`pump_out`, `read_in`) and the `finally` cleanup (via + `_teardown`).""" + settings: Settings = ws.app.state.settings + supervisor: RoomSupervisor = ws.app.state.supervisor + registry: ConnectionRegistry = ws.app.state.ws_registry + code = normalize_code(code) or code + + if not origin_allowed(ws.headers.get("origin"), ws.headers.get("host"), settings): + logger.info( + "ws handshake refused: disallowed origin %r for /ws/%s", + ws.headers.get("origin"), + code, + ) + with contextlib.suppress(Exception): + # Pre-accept: the server answers the upgrade with an HTTP 403 — + # the client sees a refused handshake, never a close frame. + await ws.close(code=CloseCode.ABUSE) + return + + # The accept goes out only after the origin check, and only once: from + # here on every refusal is a real close frame. The inbound size cap + # (`max_message_bytes`) is enforced in `_process_frame` — this stack's + # `accept()` takes no `max_size`, and the app-layer check is what + # answers with the documented `too_big` code. + await ws.accept() + + conn, room_code, inbound = await _open_connection( + ws, code, supervisor, settings, registry + ) + if conn is None or room_code is None or inbound is None: + return + pump_task: asyncio.Task[None] | None = None + read_task: asyncio.Task[int | None] | None = None + hb_task: asyncio.Task[int | None] | None = None + try: + pump_task = asyncio.create_task(pump_out(ws, conn), name=f"ws-pump-{room_code}") + read_task = asyncio.create_task( + read_in(ws, conn, inbound), name=f"ws-read-{room_code}" + ) + # The liveness watchdog (task 05): a `HEARTBEAT_DEAD_S` silence is + # a dead client — close `1000`, and the `finally` teardown below + # marks the disconnect exactly once (the `disconnect_notified` + # guard makes the read-side and the watchdog's paths converge). + hb_task = asyncio.create_task( + heartbeat_watchdog(conn, supervisor.clock), name=f"ws-hb-{room_code}" + ) + done, pending = await asyncio.wait( + {read_task, hb_task}, return_when=asyncio.FIRST_COMPLETED + ) + close_code: int | None = None + for task in done: + value = task.result() + if value is not None: + close_code = value + break + for task in pending: + task.cancel() + if close_code is not None: + await _drain_pending(conn) + with contextlib.suppress(Exception): + await ws.close(code=close_code) + finally: + await _teardown( + conn, + _TeardownCtx(supervisor, registry, room_code, code), + (pump_task, read_task, hb_task), + ) + + +async def _drain_pending(conn: Connection) -> None: + """Give the pump a short grace to flush the last `err` before the + close frame (a bounded wait: at most ~20 ms).""" + for _ in range(20): + if conn.send_queue.empty(): + return + await asyncio.sleep(0.001) diff --git a/src/undercooked/sim/ghost.py b/src/undercooked/sim/ghost.py new file mode 100644 index 0000000..349f169 --- /dev/null +++ b/src/undercooked/sim/ghost.py @@ -0,0 +1,156 @@ +"""The ghost: what a dropped cook's state and hands do (phase 06 task 05, US-09). + +Purity contract (PLAN.md A5, enforced by `undercooked.sim.purity`): standard library +and `undercooked.sim` imports only — no I/O, no asyncio, no wall clock. + +The wall-clock grace is the *room's* (`UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` for the +seat, `PARK_AFTER_S` for the park, both measured from `seat.last_seen`); this +module is the sim's half, pure and deterministic: + +* **`mark_ghost`** — the sim-side half of a socket drop: `connected=False`, + `state=DISCONNECTED` (idempotent — a second call changes nothing). The + `GHOST_ON` event is the room's, the presentation label the client's; the sim + only owns the cook's state and the `connected` flag. +* **`restore_player`** — the sim-side half of a rejoin inside the grace: + `connected=True`, `state=IDLE` (idempotent). A still-carried item is kept — + the cook comes back holding what they held; an already-parked cook (the item + released, the hand empty) comes back empty-handed. +* **`release_ghost_carry`** — the end of the park deadline (`PARK_AFTER_S` of + disconnection, or the seat's abandonment): the carried item is released onto + the station on the cook's tile — a marked set-down, grabbable, + `sim/carry.py`'s `place_item` (one `DROPPED` event; the hand-off window + applies, so a released item can be passed) — or into + `counters["lost_items"]` (one `GHOST_PARK` event) when no station sits under + the tile. An item is **never deleted silently**: the station or the counter + is its tombstone. Idempotent: an already-empty hand releases nothing, so a + double call cannot double-release. +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import Final + +from undercooked.sim.carry import place_item +from undercooked.sim.model import ( + Event, + EventKind, + Item, + KitchenState, + Player, + PlayerState, + Station, + Vec2, +) + +__all__ = [ + "PARK_AFTER_S", + "mark_ghost", + "release_ghost_carry", + "restore_player", + "station_at", +] + +#: Seconds of disconnection after which the cook parks: the carried item is +#: released (US-09 criterion 1: "after 3 s idle, parks and drops what they +#: carried"). The room measures the clock (`seat.last_seen`); this module only +#: knows the released state. +PARK_AFTER_S: Final[float] = 3.0 + + +def _with_player(players: tuple[Player, ...], updated: Player) -> tuple[Player, ...]: + """`players` with `updated` swapped in by id, order untouched.""" + return tuple(updated if player.id == updated.id else player for player in players) + + +def station_at(state: KitchenState, pos: Vec2) -> Station | None: + """The station on the cook's tile (the cell under the position), or `None`. + + Positions are tile centres (`x + 0.5, y + 0.5`) and stations carry their + cell address, so "the current tile" is the position's floor. + """ + cell = (int(pos.x), int(pos.y)) + for station in state.stations: + if (station.x, station.y) == cell: + return station + return None + + +def mark_ghost(state: KitchenState, player: Player) -> KitchenState: + """The sim-side half of a socket drop: `connected=False`, the state + switches to `DISCONNECTED` (the presentation until the park). Idempotent: + an already-ghosted cook is left exactly as is.""" + if not player.connected and player.state is PlayerState.DISCONNECTED: + return state + return replace( + state, + players=_with_player( + state.players, replace(player, connected=False, state=PlayerState.DISCONNECTED) + ), + ) + + +def restore_player(state: KitchenState, player: Player) -> KitchenState: + """The sim-side half of a rejoin inside the grace: `connected=True`, the + state back to `IDLE`. A still-carried item is kept (the `replace` touches + only the flag and the state); an already-restored cook is left as is.""" + if player.connected and player.state is PlayerState.IDLE: + return state + return replace( + state, + players=_with_player( + state.players, replace(player, connected=True, state=PlayerState.IDLE) + ), + ) + + +def release_ghost_carry( + state: KitchenState, player: Player +) -> tuple[KitchenState, tuple[Event, ...]]: + """The park's release (one call per token per round; idempotent). + + Ensures the ghost switch first (a caller may park before the drop ever + touched the sim), then: a carried item goes to the station on the cook's + tile (`place_item` — a marked set-down, one `DROPPED`), or into + `counters["lost_items"]` (one `GHOST_PARK`) when the tile has no + station. An empty hand releases nothing — a second call, or a park of a + cook that never carried, is a silent no-op. + """ + if ( + not player.connected + and player.state is PlayerState.DISCONNECTED + and player.carrying is None + ): + return state, () # already parked: nothing to release + if player.connected or player.state is not PlayerState.DISCONNECTED: + state = mark_ghost(state, player) + player = next(p for p in state.players if p.id == player.id) + if player.carrying is None: + return state, () + item: Item = player.carrying + station = station_at(state, player.pos) + if station is not None: + return place_item(state, player, station, item) + released = replace(player, carrying=None) + return ( + replace( + state, + players=_with_player(state.players, released), + counters={ + **state.counters, + "lost_items": state.counters.get("lost_items", 0) + 1, + }, + ), + ( + Event( + kind=EventKind.GHOST_PARK, + x=player.pos.x, + y=player.pos.y, + payload={ + "player_id": player.id, + "item": str(item.kind), + "form": str(item.form), + }, + ), + ), + ) diff --git a/src/undercooked/sim/model.py b/src/undercooked/sim/model.py index d40fe92..91cd09a 100644 --- a/src/undercooked/sim/model.py +++ b/src/undercooked/sim/model.py @@ -362,6 +362,10 @@ class EventKind(StrEnum): TRASHED_ALREADY_EMPTY = "trashed_already_empty" # an empty hand at threshold (sim/carry.py) TICK_SLOW = "tick_slow" ERROR = "err" # a malformed or unattributable input, never an exception + RESYNC = "resync" # a stale input's resync nudge (server layer, phase 06 task 03) + GHOST_ON = "ghost_on" # a dropped socket: the seat is kept, the label goes out (task 05) + GHOST_OFF = "ghost_off" # the rejoin inside the grace, or the seat's abandonment (task 05) + GHOST_PARK = "ghost_park" # a park release with no station: `lost_items` (sim/ghost.py) @dataclass(frozen=True) diff --git a/src/undercooked/sim/movement.py b/src/undercooked/sim/movement.py index a2dfd72..dce0d56 100644 --- a/src/undercooked/sim/movement.py +++ b/src/undercooked/sim/movement.py @@ -234,7 +234,14 @@ def apply_stun( updated.append( player if player.state is not PlayerState.STUNNED - else replace(player, state=PlayerState.IDLE) + else replace( + player, + state=( + PlayerState.DISCONNECTED + if not player.connected + else PlayerState.IDLE + ), + ) ) continue if player.state is not PlayerState.STUNNED: @@ -253,7 +260,12 @@ def apply_stun( player, stun=0.0 if revived else remaining, vel=Vec2(), - state=PlayerState.IDLE if revived else PlayerState.STUNNED, + state=( + PlayerState.DISCONNECTED + if revived and not player.connected + else PlayerState.IDLE if revived + else PlayerState.STUNNED + ), ) ) return tuple(updated), tuple(events) @@ -428,6 +440,11 @@ def resolve_moves( if player.stun > 0.0: moved.append(player) continue + if not player.connected: + # A ghost parks: a disconnected cook's (stale) input does not move + # it — the sim never walks a body whose connection is gone (task 05). + moved.append(replace(player, vel=Vec2(), state=PlayerState.DISCONNECTED)) + continue vel = desired_velocity(player, want, dt, tractions.get(player.id)) moved.append( replace( diff --git a/tests/fixtures/levels_playable/01_first_kitchen.yaml b/tests/fixtures/levels_playable/01_first_kitchen.yaml new file mode 100644 index 0000000..4a0e140 --- /dev/null +++ b/tests/fixtures/levels_playable/01_first_kitchen.yaml @@ -0,0 +1,45 @@ +# levels/01_first_kitchen.yaml — campaign level 1, the tutorial kitchen. +# Schema: docs/LEVELS.md · validated at startup and by `undercooked-levels +# validate`. Every '#' is wall, every station stands on walkable floor. + +id: 01_first_kitchen +name: First Kitchen +order: 1 +difficulty: 1 +time_limit: 150 +target_score: 45 +blurb: >- + Your first shift: grab a tomato and an onion from the shelves, chop them, + cook the soup, plate it, and run it to the serve window before the clock + runs out. One dish, one of everything, nowhere to bump into each other. +grid: + - "############" + - "#T..C...S..#" + - "#..........#" + - "#.P......X.#" + - "#..........#" + - "#..K....O..#" + - "#..........#" + - "#..........#" + - "############" +tiles: + "#": {kind: wall} + ".": {kind: floor} + "T": {kind: floor, station: spawn_shelf, item: tomato} + "O": {kind: floor, station: spawn_shelf, item: onion} + "C": {kind: floor, station: chop_board} + "S": {kind: floor, station: stove} + "P": {kind: floor, station: prep} + "X": {kind: floor, station: serve} + "K": {kind: floor, station: sink} +spawns: [[2, 7], [9, 7]] +orders: + pool: [soup] + concurrent: 1 + spawn_interval: [16, 20] + deadline: 60 +hazards: [] +escalation_notes: >- + Tutorial baseline (PLAN §8 campaign plan): one recipe, one order at a time, + slowest spawn interval, longest time budget, open floor, no hazards. Every + later level moves at least two axes up from these numbers. diff --git a/tests/fixtures/levels_playable/recipes.yaml b/tests/fixtures/levels_playable/recipes.yaml new file mode 100644 index 0000000..2040a28 --- /dev/null +++ b/tests/fixtures/levels_playable/recipes.yaml @@ -0,0 +1,80 @@ +# levels/recipes.yaml — the campaign recipe book (PLAN §5/§8, A8). +# +# Every dish the game knows lives here; no Python enumerates recipes. The +# schema is `src/undercooked/sim/recipe.py` and the reference is +# docs/LEVELS.md — every model is strict (`extra="forbid"`), so a typo here +# fails the load loudly instead of silently defaulting. +# +# Step lists mirror PLAN §5's station pipelines: pick from the spawn shelf, +# chop (~0.9 s), cook on the stove, plate at the prep station, serve at the +# window. `seconds` feeds only the validator's greedy target-plausibility +# bound; the simulation (phase 04) re-enacts these steps per tick. + +# Catalog of the item kinds the campaign's dishes consume (documentation of +# intent; spawn shelves are what actually put items on the floor). +ingredients: [tomato, onion, lettuce, dough, patty, rice, fish, plate] + +recipes: + # tomato + onion -> chop -> pot on the stove -> plate + soup: + id: soup + name: Tomato Soup + base_score: 40 + ingredients: + - {kind: tomato, form: raw} + - {kind: onion, form: raw} + steps: + - {kind: pick, station: spawn_shelf} + - {kind: chop, station: chop_board, seconds: 0.9} + - {kind: cook, station: stove, seconds: 5.0} + - {kind: plate, station: prep, seconds: 0.4} + - {kind: serve, station: serve} + tags: [hot, soup] + + # lettuce + tomato -> chop -> plate; the fast, no-fire dish + salad: + id: salad + name: Garden Salad + base_score: 30 + ingredients: + - {kind: lettuce, form: raw} + - {kind: tomato, form: raw} + steps: + - {kind: pick, station: spawn_shelf} + - {kind: chop, station: chop_board, seconds: 0.9} + - {kind: plate, station: prep, seconds: 0.4} + - {kind: serve, station: serve} + tags: [cold, quick] + + # rice + onion straight onto the stove (PLAN §5: raw rice needs no board) + fried_rice: + id: fried_rice + name: Fried Rice + base_score: 50 + ingredients: + - {kind: rice, form: raw} + - {kind: onion, form: raw} + steps: + - {kind: pick, station: spawn_shelf} + - {kind: cook, station: stove, seconds: 6.0} + - {kind: plate, station: prep, seconds: 0.4} + - {kind: serve, station: serve} + tags: [hot, grain] + + # patty + dough + lettuce -> chop + pan on the stove -> plate. Defined now; + # later levels (phase 11) put it in an order pool. + burger: + id: burger + name: Chef's Burger + base_score: 70 + ingredients: + - {kind: patty, form: raw} + - {kind: dough, form: raw} + - {kind: lettuce, form: raw} + steps: + - {kind: pick, station: spawn_shelf} + - {kind: chop, station: chop_board, seconds: 0.9} + - {kind: cook, station: stove, seconds: 5.0} + - {kind: plate, station: prep, seconds: 0.5} + - {kind: serve, station: serve} + tags: [hot, stacked] diff --git a/tests/fixtures/levels_round/01_first_kitchen.yaml b/tests/fixtures/levels_round/01_first_kitchen.yaml new file mode 100644 index 0000000..6fe898d --- /dev/null +++ b/tests/fixtures/levels_round/01_first_kitchen.yaml @@ -0,0 +1,59 @@ +# tests/fixtures/levels_round/01_first_kitchen.yaml — the shipped campaign +# level 1, verbatim in layout and content, with two numbers tuned for the +# phase-06 two-client round suite (tests/integration/test_ws_round_two_clients.py). +# +# `target_score` is 20 (the shipped 45): one served soup scores 44-66 +# (`round(40 x 1.0 x 1.1 x (1 + 0.5 x fraction))` — sim/scoring.py), so the +# first serve always crosses the target and the round ends `WON` on that +# substep, whatever the wall-clock jitter of a live 20 Hz tick. The shipped +# 150 s / 45 target would make a live-suite win depend on choreography timing +# (a second serve within the 60 s order deadline); the wire contract the +# suite locks (the round's events, payload and lobby return) is the same. +# +# `time_limit` is 60 s (the shipped 150): a safety margin around the ~25 s +# scripted soup. If the choreography ever stalls, the round ends `LOST` at the +# buzzer instead of running a two-and-a-half-minute test. +# +# Schema: docs/LEVELS.md — validated by the server's content gate at boot and +# by `undercooked-levels validate` on a copy. + +id: 01_first_kitchen +name: First Kitchen +order: 1 +difficulty: 1 +time_limit: 60 +target_score: 20 +blurb: >- + Your first shift: grab a tomato and an onion from the shelves, chop them, + cook the soup, plate it, and run it to the serve window before the clock + runs out. One dish, one of everything, nowhere to bump into each other. +grid: + - "############" + - "#T..C...S..#" + - "#..........#" + - "#.P......X.#" + - "#..........#" + - "#..K....O..#" + - "#..........#" + - "#..........#" + - "############" +tiles: + "#": {kind: wall} + ".": {kind: floor} + "T": {kind: floor, station: spawn_shelf, item: tomato} + "O": {kind: floor, station: spawn_shelf, item: onion} + "C": {kind: floor, station: chop_board} + "S": {kind: floor, station: stove} + "P": {kind: floor, station: prep} + "X": {kind: floor, station: serve} + "K": {kind: floor, station: sink} +spawns: [[2, 7], [9, 7]] +orders: + pool: [soup] + concurrent: 1 + spawn_interval: [16, 20] + deadline: 60 +hazards: [] +escalation_notes: >- + Integration-suite copy of the tutorial baseline (PLAN §8 campaign plan): + one recipe, one order at a time, open floor, no hazards. diff --git a/tests/fixtures/levels_round/recipes.yaml b/tests/fixtures/levels_round/recipes.yaml new file mode 100644 index 0000000..2040a28 --- /dev/null +++ b/tests/fixtures/levels_round/recipes.yaml @@ -0,0 +1,80 @@ +# levels/recipes.yaml — the campaign recipe book (PLAN §5/§8, A8). +# +# Every dish the game knows lives here; no Python enumerates recipes. The +# schema is `src/undercooked/sim/recipe.py` and the reference is +# docs/LEVELS.md — every model is strict (`extra="forbid"`), so a typo here +# fails the load loudly instead of silently defaulting. +# +# Step lists mirror PLAN §5's station pipelines: pick from the spawn shelf, +# chop (~0.9 s), cook on the stove, plate at the prep station, serve at the +# window. `seconds` feeds only the validator's greedy target-plausibility +# bound; the simulation (phase 04) re-enacts these steps per tick. + +# Catalog of the item kinds the campaign's dishes consume (documentation of +# intent; spawn shelves are what actually put items on the floor). +ingredients: [tomato, onion, lettuce, dough, patty, rice, fish, plate] + +recipes: + # tomato + onion -> chop -> pot on the stove -> plate + soup: + id: soup + name: Tomato Soup + base_score: 40 + ingredients: + - {kind: tomato, form: raw} + - {kind: onion, form: raw} + steps: + - {kind: pick, station: spawn_shelf} + - {kind: chop, station: chop_board, seconds: 0.9} + - {kind: cook, station: stove, seconds: 5.0} + - {kind: plate, station: prep, seconds: 0.4} + - {kind: serve, station: serve} + tags: [hot, soup] + + # lettuce + tomato -> chop -> plate; the fast, no-fire dish + salad: + id: salad + name: Garden Salad + base_score: 30 + ingredients: + - {kind: lettuce, form: raw} + - {kind: tomato, form: raw} + steps: + - {kind: pick, station: spawn_shelf} + - {kind: chop, station: chop_board, seconds: 0.9} + - {kind: plate, station: prep, seconds: 0.4} + - {kind: serve, station: serve} + tags: [cold, quick] + + # rice + onion straight onto the stove (PLAN §5: raw rice needs no board) + fried_rice: + id: fried_rice + name: Fried Rice + base_score: 50 + ingredients: + - {kind: rice, form: raw} + - {kind: onion, form: raw} + steps: + - {kind: pick, station: spawn_shelf} + - {kind: cook, station: stove, seconds: 6.0} + - {kind: plate, station: prep, seconds: 0.4} + - {kind: serve, station: serve} + tags: [hot, grain] + + # patty + dough + lettuce -> chop + pan on the stove -> plate. Defined now; + # later levels (phase 11) put it in an order pool. + burger: + id: burger + name: Chef's Burger + base_score: 70 + ingredients: + - {kind: patty, form: raw} + - {kind: dough, form: raw} + - {kind: lettuce, form: raw} + steps: + - {kind: pick, station: spawn_shelf} + - {kind: chop, station: chop_board, seconds: 0.9} + - {kind: cook, station: stove, seconds: 5.0} + - {kind: plate, station: prep, seconds: 0.5} + - {kind: serve, station: serve} + tags: [hot, stacked] diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..2d454c6 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,443 @@ +"""Fixtures for the live-socket integration tests (phase 06). + +`ws_server` is a **session-scoped factory**: one call spins up a real +`uvicorn.Server` on an ephemeral `127.0.0.1` port (never a routable +interface, the `tests/conftest.py` rule) with a `Settings` built from +explicit overrides, and the returned context manager tears the server down +when the test ends (a second safety close runs at session end). The app +built here is yielded too, so a test can reach +`app.state.supervisor` / `app.state.ws_registry` to assert on room state +(the seat's `connected` flag, the disconnect count, the socket census) +without a second client. + +`live_server` is the same machine with one difference (phase 06 task 06): +`UNDERCOOKED_LEVELS_DIR` defaults to the **shipped** `levels/` campaign +(so a live server carries the real content the content gate validated), +and any `Settings` field is overridable (`tick_hz`, `snapshot_hz`, …) +the same way. A test that needs a playable, tunable round passes +`levels_dir=` explicitly (the task-03 `levels_playable` precedent, and +the task-06 `levels_round` campaign for the two-client round). + +`ws_client` is a factory for the **scripted client** the round/abuse/ +budget suites drive: `ws_client(url, code, token=None, name=None)` returns +a `WsClient` — a connected `websockets` socket with the `hello` already +sent and a background reader that keeps a merged snapshot view +(`client.view`: full frames reset, delta frames patch, the frame's `e` +events accumulated) and a frame queue the client's three verbs consume: + +* `send_model(model)` — queue a JSON frame out (fire-and-forget; the + `input` choreography cadence never blocks on the socket), +* `expect(type, wait_s)` — the next frame of `type`, the ones before it + buffered and re-queued for a later verb, +* `collect(type, n, wait_s)` — `n` frames of `type`, the same buffering. + +Settings are built the same way `tests/conftest.py` `make_settings` does +(the authoritative `config.ENV_KEYS` contract, ambient `UNDERCOOKED_*` / +`DEBUGPY*` stripped), with one deliberate difference: `UNDERCOOKED_LEVELS_DIR` +is *kept*, pointed at the campaign the factory names (the `ws_server` +default is the fixture-isolated campaign with a recipe book — `create_app`'s +content gate passes on it; the `live_server` default is the shipped +`levels/`), so a live server boots without the ambient environment leaking. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import importlib +import json +import os +import threading +import time +from collections.abc import Callable, Iterator +from pathlib import Path +from typing import Any + +import httpx +import pytest +import websockets +from websockets.exceptions import ConnectionClosed + +LIVE_HOST = "127.0.0.1" # loopback only: tests never listen on a routable interface +FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels" +#: The shipped campaign (phase 06 task 06: `live_server` boots on it). +SHIPPED_LEVELS_DIR = Path(__file__).resolve().parents[2] / "levels" + +config = importlib.import_module("undercooked.config") +app_module = importlib.import_module("undercooked.server.app") +uvicorn = importlib.import_module("uvicorn") + + +def _as_env_value(value: object) -> str: + """Render a Python override value the way `load_settings` reads it.""" + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, (str, os.PathLike)): + return str(value) + if isinstance(value, (list, tuple)): + return ",".join(str(item) for item in value) + return str(value) + + +def _wait_for_health(url: str, thread: threading.Thread) -> None: + """Poll `/healthz` until it answers 200, failing fast if the server died.""" + timeout = float(os.environ.get("UNDERCOOKED_TEST_SOCKET_TIMEOUT", "30")) + deadline = time.monotonic() + timeout + probe = httpx.Client(timeout=1.0, trust_env=False) + try: + while time.monotonic() < deadline: + try: + if probe.get(f"{url}/healthz").status_code == 200: + return + except httpx.HTTPError: + pass + thread.join(timeout=0.05) + if not thread.is_alive(): + raise RuntimeError(f"live server on {url} died during startup") + finally: + probe.close() + raise RuntimeError(f"live server on {url} never became healthy") + + +def _start_live_server( + overrides: dict[str, object], default_levels_dir: Path +) -> tuple[str, Any, Any, threading.Thread]: + """One live uvicorn server on an ephemeral loopback port. + + Returns `(url, application, server, thread)`; the caller wraps them in + a `LiveServer`. `overrides` are `Settings` field names (validated + against `config.ENV_KEYS`) — `levels_dir` falls back to + `default_levels_dir` when the caller did not name it. + """ + env = { + key: value + for key, value in os.environ.items() + if not key.startswith(("UNDERCOOKED_", "DEBUGPY")) + } + levels_dir = overrides.get("levels_dir", str(default_levels_dir)) + env["UNDERCOOKED_LEVELS_DIR"] = str(levels_dir) + for name, value in overrides.items(): + env[config.ENV_KEYS[name]] = _as_env_value(value) + settings = config.load_settings(env) + application = app_module.create_app(settings) + uv_config = uvicorn.Config( + application, + host=LIVE_HOST, + port=0, + log_level="warning", + lifespan="on", + ) + sock = uv_config.bind_socket() + url = f"http://{LIVE_HOST}:{sock.getsockname()[1]}" + server = uvicorn.Server(uv_config) + thread = threading.Thread( + target=server.run, args=([sock],), name="undercooked-live-ws", daemon=True + ) + thread.start() + _wait_for_health(url, thread) + return url, application, server, thread + + +class LiveServer: + """One live uvicorn server + its app (a context manager).""" + + def __init__( + self, + url: str, + app: Any, + server: Any, + thread: threading.Thread, + ) -> None: + self.url = url + self.app = app + self._server = server + self._thread = thread + self._closed = False + + def __enter__(self) -> tuple[str, Any]: + return self.url, self.app + + def __exit__(self, *exc_info: object) -> None: + self.close() + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._server.should_exit = True + self._thread.join(timeout=15) + if self._thread.is_alive(): # pragma: no cover - only if uvicorn stops cooperating + pytest.fail("live server did not shut down within 15 s") + + +def _make_live_server_factory(default_levels_dir: Path) -> Callable[..., LiveServer]: + started: list[LiveServer] = [] + + def _make(**overrides: object) -> LiveServer: + if any(name not in config.ENV_KEYS for name in overrides): + unknown = sorted(name for name in overrides if name not in config.ENV_KEYS) + pytest.fail(f"unknown settings override(s): {unknown}") + url, application, server, thread = _start_live_server( + dict(overrides), default_levels_dir + ) + live = LiveServer(url, application, server, thread) + started.append(live) + return live + + # The session-end safety close (the context managers already closed + # each server the test used; a leaked one is closed here). + def _close_all() -> None: + for live in started: + live.close() + + _make.close_all = _close_all # type: ignore[attr-defined] + return _make + + +@pytest.fixture(scope="session") +def ws_server() -> Iterator[Any]: + """Factory: `with ws_server(max_players=2) as (url, app): …` + + Each call starts a fresh `uvicorn.Server` on an ephemeral loopback + port (bound before the serving thread starts, so the port is known + exactly — no log scraping, no collisions between concurrent runs) + with a `Settings` resolved from `**overrides` (field name, the same + names `make_settings` takes) over the fixture-isolated content. + Shutdown is requested through `should_exit` so the lifespan stops + cleanly. + """ + factory = _make_live_server_factory(FIXTURE_LEVELS_DIR) + yield factory + factory.close_all() # type: ignore[attr-defined] + + +@pytest.fixture(scope="session") +def live_server() -> Iterator[Any]: + """Factory: `with live_server(tick_hz=20, snapshot_hz=20) as (url, app): …` + + The task-06 live server: identical to `ws_server` except that + `UNDERCOOKED_LEVELS_DIR` points at the **shipped** `levels/` campaign + by default (override with `levels_dir=…`), so the server boots on the + real, gate-validated content; `tick_hz` / `snapshot_hz` (and every + other `Settings` field) are overridable the same way. + """ + factory = _make_live_server_factory(SHIPPED_LEVELS_DIR) + yield factory + factory.close_all() # type: ignore[attr-defined] + + +def _uri(url: str, code: str) -> str: + return url.replace("http://", "ws://", 1) + f"/ws/{code}" + + +class View: + """The merged snapshot state a scripted client reasons about. + + Full frames (`full: true`, the `FULL_SNAPSHOT_EVERY_TICKS` cadence and + the `needs_full` resync) reset the entity maps; delta frames patch + them (a `{"gone": true}` tombstone removes). The frame's `e` events + accumulate in order (a boundary's events ride that one frame — the + encoder's contract), and the scalars (`t` / `s` / `tgt` / `tl`) are + the latest frame's.""" + + def __init__(self) -> None: + self.players: dict[str, dict[str, Any]] = {} + self.stations: dict[tuple[Any, Any, Any], dict[str, Any]] = {} + self.orders: dict[Any, dict[str, Any]] = {} + self.events: list[dict[str, Any]] = [] + self.tick = 0 + self.score = 0 + self.target = 0 + self.time_left = 0.0 + + def apply(self, frame: dict[str, Any]) -> None: + # The wire's `snap` message nests the encoder's frame under + # `raw` (`SnapMsg.raw` — the message's own `p` is the protocol + # version, the frame's own `p` is its players array). + raw = frame.get("raw") + if not isinstance(raw, dict): + raw = frame + if raw.get("full"): + self.players = { + entry["i"]: entry for entry in raw.get("p", []) if not entry.get("gone") + } + self.stations = { + (entry["k"], entry["x"], entry["y"]): entry + for entry in raw.get("st", []) + if not entry.get("gone") + } + self.orders = { + entry["id"]: entry for entry in raw.get("o", []) if not entry.get("gone") + } + else: + delta = raw.get("d", {}) + for entry in delta.get("p", []): + if entry.get("gone"): + self.players.pop(entry["i"], None) + else: + self.players[entry["i"]] = entry + for entry in delta.get("st", []): + key = (entry["k"], entry["x"], entry["y"]) + if entry.get("gone"): + self.stations.pop(key, None) + else: + self.stations[key] = entry + for entry in delta.get("o", []): + if entry.get("gone"): + self.orders.pop(entry["id"], None) + else: + self.orders[entry["id"]] = entry + self.events.extend(raw.get("e", [])) + self.tick = raw.get("t", self.tick) + self.score = raw.get("s", self.score) + self.target = raw.get("tgt", self.target) + self.time_left = raw.get("tl", self.time_left) + + def player(self, index: str) -> dict[str, Any] | None: + return self.players.get(index) + + def station(self, kind: str) -> dict[str, Any] | None: + """The first station of `kind` (the wire's identity is `k` + + coordinates; a level's stations of a kind sit on distinct cells).""" + for (station_kind, _, _), entry in self.stations.items(): + if station_kind == kind: + return entry + return None + + def station_at(self, kind: str, x: float, y: float) -> dict[str, Any] | None: + return self.stations.get((kind, x, y)) + + def event(self, kind: str) -> dict[str, Any] | None: + for entry in self.events: + if entry.get("k") == kind: + return entry + return None + + +class WsClient: + """The scripted client (phase 06 task 06): a connected socket with the + `hello` already sent, a background reader (the merged `view` + the + frame queue), and the three verbs the suites name. + + `send_model` is fire-and-forget by design: the choreography feeds + inputs on a cadence (~10 Hz) faster than the socket's round trip, and + the room's per-tick queue is what serialises the intent — a blocking + send would add latency the sim's queue discipline is meant to absorb. + `raw` is the malformed-frame half (the abuse suite's `not json`). + """ + + def __init__(self, ws: Any) -> None: + self.ws = ws + self.view = View() + self._frames: asyncio.Queue[dict[str, Any]] = asyncio.Queue() + self._reader: asyncio.Task[None] | None = None + self._closed = False + + def start(self) -> None: + self._reader = asyncio.get_running_loop().create_task(self._read_loop()) + + async def _read_loop(self) -> None: + try: + while not self._closed: + raw = await self.ws.recv() + frame = json.loads(raw) + if frame.get("type") == "snap": + self.view.apply(frame) + self._frames.put_nowait(frame) + except (ConnectionClosed, asyncio.CancelledError): + pass + + def send_model(self, model: dict[str, Any]) -> None: + """Queue one JSON frame out (the task's `send_model`).""" + asyncio.get_running_loop().create_task(self.ws.send(json.dumps(model))) + + async def send(self, model: dict[str, Any]) -> None: + await self.ws.send(json.dumps(model)) + + def raw(self, text: str) -> None: + """One raw frame out (the malformed half: `not json`, …).""" + asyncio.get_running_loop().create_task(self.ws.send(text)) + + async def expect(self, type: str, wait_s: float = 10.0) -> dict[str, Any]: + """The next frame of `type`; the frames before it are buffered and + re-queued (a later verb sees them, in order).""" + loop = asyncio.get_running_loop() + deadline = loop.time() + wait_s + held: list[dict[str, Any]] = [] + while True: + remaining = deadline - loop.time() + if remaining <= 0: + msg = f"timed out waiting for a {type!r} frame" + raise TimeoutError(msg) + frame = await asyncio.wait_for(self._frames.get(), remaining) + if frame.get("type") == type: + for buffered in held: + self._frames.put_nowait(buffered) + return frame + held.append(frame) + + async def collect( + self, type: str, n: int, wait_s: float = 30.0 + ) -> list[dict[str, Any]]: + """`n` frames of `type` (in arrival order); the frames in between + are buffered and re-queued.""" + loop = asyncio.get_running_loop() + deadline = loop.time() + wait_s + held: list[dict[str, Any]] = [] + out: list[dict[str, Any]] = [] + while len(out) < n: + remaining = deadline - loop.time() + if remaining <= 0: + msg = f"timed out after {len(out)}/{n} {type!r} frames" + raise TimeoutError(msg) + frame = await asyncio.wait_for(self._frames.get(), remaining) + if frame.get("type") == type: + out.append(frame) + else: + held.append(frame) + for buffered in held: + self._frames.put_nowait(buffered) + return out + + async def close(self) -> None: + if self._closed: + return + self._closed = True + if self._reader is not None: + self._reader.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._reader + with contextlib.suppress(Exception): + await self.ws.close() + + +@pytest.fixture(scope="session") +def ws_client() -> Callable[..., Any]: + """Factory: `client = await ws_client(url, code, token, name=…)`. + + Connects to `/ws/{code}` on the named server, sends the `hello` + (the `token` joins or rejoins; absent, the server mints one and the + `welcome` echoes it) and returns the scripted `WsClient` (its reader + already running, its `view` merging snapshots). + """ + + async def _connect( + url: str, + code: str, + token: str | None = None, + *, + name: str | None = None, + p: int = 1, + ) -> WsClient: + ws = await websockets.connect(_uri(url, code), open_timeout=10.0) + hello: dict[str, Any] = {"type": "hello", "p": p} + if token is not None: + hello["token"] = token + if name is not None: + hello["name"] = name + await ws.send(json.dumps(hello)) + client = WsClient(ws) + client.start() + return client + + return _connect diff --git a/tests/integration/test_encoding.py b/tests/integration/test_encoding.py new file mode 100644 index 0000000..83abb44 --- /dev/null +++ b/tests/integration/test_encoding.py @@ -0,0 +1,870 @@ +"""Integration: the snapshot stream over live sockets + the deterministic +fan-out drive (phase 06, task 04). + +Live half (a real uvicorn on an ephemeral loopback port): the first +`PLAYING` frame's keysets (the shape `docs/PROTOCOL.md` documents — +the captured frame in the docs carries exactly these), the size +budgets (4 players / 4 orders / 3 hazards ≤ 1.5 KB on the budget +kitchen; a 64x64 level ≤ 4 KB), the ≥ 40% delta shrink, the spectator +half-rate pacing, the `UNDERCOOKED_SIM_LATENCY_MS` downlink delay, and +the 100-tick downlink bound (≤ 60 KB/s). + +Deterministic half (a `FakeClock`-driven room + the `SnapshotFanout` +directly, no wall clock): the player/spectator pass pacing, the full +cadence, the delta decode-equivalence (the applied chain lands on the +server's own re-encode), the `MAX_EVENTS_PER_FRAME` cap's +`events_dropped` count, and the `needs_full` resync forcing a full. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import shutil +import time +from collections.abc import Callable +from pathlib import Path +from typing import Any, cast + +import httpx +import pytest +import websockets + +from undercooked.config import Settings +from undercooked.server.clock import FakeClock +from undercooked.server.encoding import ( + MAX_EVENTS_PER_FRAME, + encode_snapshot, + snapshot_size, +) +from undercooked.server.room import Room, RoomState +from undercooked.server.ws import ConnectionRegistry, SnapshotFanout +from undercooked.sim.content import ContentBundle +from undercooked.sim.level import Level, LevelDef +from undercooked.sim.model import ( + Event, + ItemForm, + ItemKind, + StationKind, +) +from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step + +pytestmark = pytest.mark.integration + +#: The playable campaign (the shipped level 1, validated by the content +#: gate at server boot) — a room built from it can start a round. +PLAYABLE_LEVELS_DIR = ( + Path(__file__).resolve().parents[1] / "fixtures" / "levels_playable" +) + +# The keysets the wire contract promises (docs/PROTOCOL.md "The `snap` +# frame" — the captured frame there carries exactly these; on the wire +# the frame rides under `raw`, beside the message's `type` and `p`). +FULL_TOP_KEYS: frozenset[str] = frozenset( + {"t", "s", "tgt", "tl", "full", "p", "st", "o", "hz", "e"} +) +PLAYER_KEYS: frozenset[str] = frozenset({"i", "x", "y", "f", "h", "s", "pi"}) +STATION_KEYS: frozenset[str] = frozenset({"k", "x", "y", "g", "q", "p", "s"}) +ORDER_KEYS: frozenset[str] = frozenset({"id", "r", "l", "pts"}) +HAZARD_KEYS: frozenset[str] = frozenset({"t", "x", "y", "l"}) +STATION_KINDS: frozenset[str] = frozenset( + {"spawn_shelf", "chop_board", "stove", "prep", "serve", "sink"} +) +PLAYER_STATES: frozenset[str] = frozenset( + {"idle", "moving", "stunned", "working"} +) +HAZARD_KINDS: frozenset[str] = frozenset( + {"fire", "spill", "moving_obstacle", "rush"} +) + + +# ------------------------------------------------------------------ helpers + + +def _uri(url: str, code: str) -> str: + return url.replace("http://", "ws://", 1) + f"/ws/{code}" + + +async def _connect( + url: str, + code: str, + token: str | None = None, + *, + name: str | None = None, +) -> Any: + """A connected client with the `hello` already sent (a `token=None` + hello mints a fresh token — a spectator: no seat for it).""" + ws = await websockets.connect(_uri(url, code), open_timeout=10.0) + hello: dict[str, Any] = {"type": "hello", "p": 1} + if token is not None: + hello["token"] = token + if name is not None: + hello["name"] = name + await ws.send(json.dumps(hello)) + return ws + + +async def _next_json(ws: Any, wait_s: float = 10.0) -> dict[str, Any]: + return json.loads(await asyncio.wait_for(ws.recv(), wait_s)) + + +async def _wait_for( + predicate: Callable[[], bool], what: str, wait_s: float = 15.0 +) -> None: + """Poll across threads (the server's loop does the work; this thread + only observes it).""" + loop = asyncio.get_running_loop() + deadline = loop.time() + wait_s + while not predicate(): + if loop.time() > deadline: + pytest.fail(f"timed out waiting for {what}") + await asyncio.sleep(0.01) + + +def _create_room(url: str, token: str | None = None) -> str: + """`POST /api/rooms` over the live server; returns the code.""" + body: dict[str, Any] = {} + if token is not None: + body["token"] = token + response = httpx.post(f"{url}/api/rooms", json=body, timeout=5.0) + assert response.status_code == 201, response.text + return response.json()["room_code"] + + +def _join(url: str, code: str, token: str) -> None: + response = httpx.post( + f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0 + ) + assert response.status_code == 200, response.text + + +async def _playing_room( + url: str, app: Any, host_token: str +) -> tuple[str, Room, Any]: + """Create the room, connect the host, start (solo host), and wait + for `PLAYING` (the 3 s real-time countdown on the 20 Hz tick task).""" + code = _create_room(url, host_token) + host = await _connect(url, code, host_token, name="Host") + await _next_json(host) # welcome + await _next_json(host) # lobby + await host.send(json.dumps({"type": "start"})) # solo host + room = app.state.supervisor.get(code) + assert room is not None + await _wait_for( + lambda: room.state is RoomState.PLAYING and room.sim_state is not None, + f"room {code} to reach PLAYING", + wait_s=20.0, + ) + return code, room, host + + +def _apply_delta(base: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]: + """The client's merge (the decode half of the equivalence check): + upsert by identity key (preserving the base's order, new entities + appended — the encoder's own semantics), drop the `gone` tombstones, + take the scalars and `e` from the delta.""" + # The scalars (`t`, `s`, `tgt`, `tl`) and `e` ride the delta; the + # entity arrays merge onto the base. + decoded = { + key: value + for key, value in delta.items() + if key not in ("d", "full") + } + identity: dict[str, Any] = { + "p": lambda e: (e["i"],), + "st": lambda e: (e["k"], e["x"], e["y"]), + "o": lambda e: (e["id"],), + "hz": lambda e: (e["t"], e["x"], e["y"]), + } + for key in ("p", "st", "o", "hz"): + base_entries: list[dict[str, Any]] = base.get(key, []) + delta_entries: list[dict[str, Any]] = delta["d"][key] + by_identity = {identity[key](entry): dict(entry) for entry in delta_entries} + merged: list[dict[str, Any]] = [] + seen: set[tuple[Any, ...]] = set() + for entry in base_entries: + key_ = identity[key](entry) + if by_identity.get(key_, {}).get("gone") is True: + continue # tombstoned + merged.append(by_identity.get(key_, dict(entry))) + seen.add(key_) + for entry in delta_entries: + key_ = identity[key](entry) + if key_ not in seen and entry.get("gone") is not True: + merged.append(dict(entry)) + decoded[key] = merged + decoded["full"] = True # a decoded chain is a full state + return decoded + + +class _FakeConn: + """A duck for the fan-out's connection surface (the deterministic + drive): a role, the `needs_full` resync flag, an `enqueue` sink.""" + + def __init__(self, role: str) -> None: + self.role = role + self.needs_full = True + self.frames: list[dict[str, Any]] = [] + self.state_frames: list[dict[str, Any]] = [] + + def enqueue(self, msg: Any) -> None: + raw = getattr(msg, "raw", None) + if raw is not None: + self.frames.append(raw) + else: + self.state_frames.append(json.loads(msg.model_dump_json())) + + +class _FakeRegistry: + """A duck for `ConnectionRegistry.for_room` (the deterministic drive).""" + + def __init__(self, conns: list[_FakeConn]) -> None: + self._conns = conns + + def for_room(self, code: str) -> list[_FakeConn]: + return list(self._conns) + + +def _make_book() -> RecipeBook: + """A one-recipe book: the small levels' order pool names `soup`, and + the room passes the book to `step`, so a due order spawn must find + it.""" + return RecipeBook( + recipes={ + "soup": Recipe( + id="soup", + name="Soup", + base_score=40, + ingredients=[ + Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW, count=1) + ], + steps=[Step(kind="serve", station=StationKind.SERVE, seconds=0.0)], + ) + }, + ingredients=[ItemKind.TOMATO], + ) + + +def _make_level() -> Level: + """A 7x5 kitchen with all six station kinds (the frame-size test + needs a content-rich full for the ≥ 40% delta shrink) and one + spawn point. `target` is above anything an idle cook scores, so a + `PLAYING` round on it always ends `LOST` on the buzzer.""" + grid = [ + "#######", + "#T.C.P#", + "#...V.#", + "#S..K.#", + "#######", + ] + tiles: dict[str, dict[str, str]] = { + ".": {"kind": "floor"}, + "#": {"kind": "wall"}, + "S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"}, + "V": {"kind": "floor", "station": "serve"}, + "T": {"kind": "floor", "station": "stove"}, + "C": {"kind": "floor", "station": "chop_board"}, + "P": {"kind": "floor", "station": "prep"}, + "K": {"kind": "floor", "station": "sink"}, + } + data: dict[str, object] = { + "id": "TICK1", + "name": "Tick kitchen", + "order": 1, + "time_limit": 60.0, + "target_score": 1000, + "grid": grid, + "tiles": tiles, + "spawns": [[2, 2]], + "orders": { + "pool": ["soup"], + "concurrent": 1, + "spawn_interval": [4, 6], + "deadline": 30, + }, + } + return Level.build(LevelDef.model_validate(data)) + + +def _make_room( + bundle: ContentBundle | None = None, **settings: Any +) -> tuple[Room, FakeClock]: + clock = FakeClock() + room = Room( + code="TEST1", + settings=Settings(**settings), + clock=clock, + content=bundle, + level_id=bundle.levels[0].id if bundle and bundle.levels else None, + ) + return room, clock + + +def _enter_playing(room: Room) -> float: + """Two seats (the solo-host start + a ready non-host), the 3 s + countdown in 1 s frames, `PLAYING`; returns the final `now`.""" + room.add_seat("tok-a", "Ada", True) + seat = room.add_seat("tok-b", "Bob", False) + assert room.set_ready("tok-b", True) + assert seat is not None + room.start("tok-a") + room.tick_once(now=0.0) + for offset in (1.0, 2.0, 3.0): + room.tick_once(now=offset) + assert room.state is RoomState.PLAYING + return 3.0 + + +# ------------------------------------------------- live: the keysets (L1) + + +async def test_first_playing_frame_is_full_with_the_contract_keysets( + ws_server: Any, +) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + _, _, host = await _playing_room(url, app, "tok-keys") + # The countdown frames drain off first (they predate PLAYING). + first: dict[str, Any] | None = None + while first is None or first["type"] == "countdown": + first = await _next_json(host) + assert first is not None + # The first `PLAYING` frame of a fresh connection is a full one + # (its `needs_full` default). The wire wraps the frame in + # `raw` (the message's own `p` is the protocol version). + assert first["type"] == "snap" + raw = first["raw"] + assert raw["full"] is True + # The keysets, exactly — the shape the docs' captured frame + # carries (and no more: the grid, the station ids and the spawn + # coordinates never leave the server). + assert set(raw) == FULL_TOP_KEYS + for player in raw["p"]: + assert set(player) == PLAYER_KEYS + assert player["f"] in (0, 1, 2, 3) + assert player["s"] in PLAYER_STATES + assert isinstance(player["pi"], int) + for station in raw["st"]: + assert set(station) == STATION_KEYS + assert station["k"] in STATION_KINDS + for order in raw["o"]: + assert set(order) == ORDER_KEYS + for hazard in raw["hz"]: + assert set(hazard) == HAZARD_KEYS + assert hazard["t"] in HAZARD_KINDS + for entry in raw["e"]: + assert "k" in entry + # Level 1's kitchen, exactly: seven stations, the one solo + # cook, no hazards (the tutorial's `hazards: []`). + assert len(raw["st"]) == 7 + assert len(raw["p"]) == 1 + assert raw["hz"] == [] + assert raw["t"] >= 0 + assert raw["s"] == 0 + assert raw["tgt"] == 45 + + +# ------------------------------------------------ live: the size budgets + + +BUDGET_LEVEL = """\ +id: 00_budget +name: Budget Kitchen +order: 1 +difficulty: 1 +time_limit: 60 +target_score: 100 +blurb: >- + The frame-budget kitchen: four spawn points, four concurrent orders, + three fast hazards (the task's 4 players / 4 orders / 3 hazards case). +grid: + - "############" + - "#T..C...S..#" + - "#..........#" + - "#.P......X.#" + - "#..........#" + - "#..K....O..#" + - "#..........#" + - "#U......W..#" + - "############" +tiles: + "#": {kind: wall} + ".": {kind: floor} + "T": {kind: floor, station: spawn_shelf, item: tomato} + "U": {kind: floor, station: spawn_shelf, item: tomato} + "W": {kind: floor, station: spawn_shelf, item: onion} + "O": {kind: floor, station: spawn_shelf, item: onion} + "C": {kind: floor, station: chop_board} + "S": {kind: floor, station: stove} + "P": {kind: floor, station: prep} + "X": {kind: floor, station: serve} + "K": {kind: floor, station: sink} +spawns: [[1, 2], [1, 4], [1, 6], [5, 2]] +orders: + pool: [soup] + concurrent: 4 + spawn_interval: [1, 2] + deadline: 30 +hazards: + - {type: fire, x: 8, y: 1, chance_per_s: 0.5, respawn_s: 2} + - {type: fire, x: 4, y: 1, chance_per_s: 0.5, respawn_s: 2} + - {type: spill, x: 5, y: 2, chance_per_s: 0.5, respawn_s: 2} +escalation_notes: >- + Test content: the budget case, not a campaign level. +""" + + +def _level_64_yaml() -> str: + """The 64x64 budget level (a wall ring, a row of stations, floor).""" + top = "#" * 64 + station_row = "#" + "T..C.O.S.." + "." * 52 + "#" + station_row_2 = "#" + "P..X..K..." + "." * 52 + "#" + middle = "#" + "." * 62 + "#" + grid = [top, station_row, station_row_2] + [middle] * 60 + [top] + assert all(len(row) == 64 for row in grid) + assert len(grid) == 64 + return ( + "id: 00_big\n" + "name: Big Kitchen\n" + "order: 1\n" + "difficulty: 1\n" + "time_limit: 60\n" + "target_score: 100\n" + "blurb: >-\n" + " The 64x64 budget: the frame must stay under 4 KB whatever the\n" + " grid (the grid never leaves the server — the frame is entities\n" + " only).\n" + "grid:\n" + + "".join(f' - "{row}"\n' for row in grid) + + "tiles:\n" + ' "#": {kind: wall}\n' + ' ".": {kind: floor}\n' + ' "T": {kind: floor, station: spawn_shelf, item: tomato}\n' + ' "O": {kind: floor, station: spawn_shelf, item: onion}\n' + ' "C": {kind: floor, station: chop_board}\n' + ' "S": {kind: floor, station: stove}\n' + ' "P": {kind: floor, station: prep}\n' + ' "X": {kind: floor, station: serve}\n' + ' "K": {kind: floor, station: sink}\n' + "spawns: [[2, 3]]\n" + "orders:\n" + " pool: [soup]\n" + " concurrent: 1\n" + " spawn_interval: [16, 20]\n" + " deadline: 30\n" + "hazards: []\n" + "escalation_notes: >-\n" + " Test content: the 64x64 size case.\n" + ) + + +def _write_levels_dir(tmp_path: Path, level_yaml: str) -> Path: + """A throwaway content dir: the test level + the campaign's recipe + book (a `recipes.yaml` the content gate validates with it).""" + directory = tmp_path / "levels" + directory.mkdir() + (directory / "00_test.yaml").write_text(level_yaml, encoding="utf-8") + shutil.copy(PLAYABLE_LEVELS_DIR / "recipes.yaml", directory / "recipes.yaml") + return directory + + +async def test_the_4p_4o_3h_frame_fits_the_1_5_kb_budget( + ws_server: Any, tmp_path: Path +) -> None: + with ws_server( + levels_dir=str(_write_levels_dir(tmp_path, BUDGET_LEVEL)) + ) as (url, app): + code = _create_room(url, "tok-p1") + for token in ("tok-p2", "tok-p3", "tok-p4"): + _join(url, code, token) + clients = [ + await _connect(url, code, "tok-p1", name="P1"), + await _connect(url, code, "tok-p2", name="P2"), + await _connect(url, code, "tok-p3", name="P3"), + await _connect(url, code, "tok-p4", name="P4"), + ] + for client in clients: + await _next_json(client) # welcome + await _next_json(client) # lobby + await client.send(json.dumps({"type": "ready"})) + await clients[0].send(json.dumps({"type": "start"})) + room = app.state.supervisor.get(code) + assert room is not None + await _wait_for( + lambda: room.state is RoomState.PLAYING and room.sim_state is not None, + "the room to reach PLAYING", + wait_s=20.0, + ) + # Wait for one frame carrying the budget case (4 cooks, 4 + # orders) and measure *it* (orders spawn at 1-2 s intervals; + # the sim's hazard stage is identity until phase 10, so a live + # frame carries no `hz` yet — the hazard encoding is covered + # unit-side, and the 4p/4o frame already bounds the 1.5 KB + # budget: 3 hazards would add < 120 B). + frame: dict[str, Any] | None = None + deadline = time.monotonic() + 30.0 + while frame is None and time.monotonic() < deadline: + data = await _next_json(clients[0]) + if data["type"] != "snap": + continue + body = data["raw"] + if ( + len(body.get("p", [])) == 4 + and len(body.get("o", [])) >= 4 + ): + frame = body + assert frame is not None, "no frame carried the 4p/4o case in 30 s" + size = snapshot_size(frame) + assert size <= 1500, f"the budget frame is {size} bytes (budget 1.5 KB)" + # The keysets hold at the budget shape too (four distinct cooks). + assert set(frame) == FULL_TOP_KEYS + assert len({p["i"] for p in frame["p"]}) == 4 + + +async def test_the_64x64_level_frame_fits_the_4_kb_budget( + ws_server: Any, tmp_path: Path +) -> None: + with ws_server( + levels_dir=str(_write_levels_dir(tmp_path, _level_64_yaml())) + ) as (url, app): + _, _, host = await _playing_room(url, app, "tok-big") + frame: dict[str, Any] | None = None + while frame is None: + data = await _next_json(host) + if data["type"] == "snap": + frame = data["raw"] + assert frame is not None + assert frame["full"] is True # the fresh connection's first frame + size = snapshot_size(frame) + assert size <= 4096, f"the 64x64 frame is {size} bytes (budget 4 KB)" + # The grid is not in the frame: a 64x64 kitchen encodes to the + # same handful of stations as the small one. + assert len(frame["st"]) == 7 + + +# ------------------------------------------------- live: the delta stream + + +async def test_the_delta_stream_shrinks_by_at_least_40_percent( + ws_server: Any, +) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + _, _, host = await _playing_room(url, app, "tok-delta") + # Collect `snap` frames until the window holds a full one and + # the frame right after it (the fresh connection's first is a + # full; the cadence full, every 20 passes, lands in the window + # too — either pair works). + window: list[dict[str, Any]] = [] + deadline = time.monotonic() + 25.0 + while time.monotonic() < deadline: + data = await _next_json(host) + if data["type"] != "snap": + continue + window.append(data["raw"]) + for i, f in enumerate(window): + if f["full"] and i + 1 < len(window): + break + else: + continue + break + full = next(f for f in window if f["full"]) + after = window[window.index(full) + 1] + assert after["full"] is False, "the frame after the full must be a delta" + full_size = snapshot_size(full) + delta_size = snapshot_size(after) + shrink = 1.0 - delta_size / full_size + assert shrink >= 0.40, ( + f"the delta shrank by {shrink:.0%} (full {full_size} B, " + f"delta {delta_size} B; budget ≥ 40%)" + ) + # The decode half (live): applying the delta to the full must + # land on exactly the base's entities (kept) plus the delta's + # (upserted), minus the tombstoned — and the delta's scalars. + decoded = _apply_delta(full, after) + assert decoded["t"] == after["t"] + assert decoded["s"] == after["s"] + assert decoded["full"] is True + identity = { + "p": lambda e: (e["i"],), + "st": lambda e: (e["k"], e["x"], e["y"]), + "o": lambda e: (e["id"],), + "hz": lambda e: (e["t"], e["x"], e["y"]), + } + for key in ("p", "st", "o", "hz"): + base_ids = {identity[key](e) for e in full[key]} + delta_ids = { + identity[key](e) + for e in after["d"][key] + if e.get("gone") is not True + } + gone_ids = { + identity[key](e) + for e in after["d"][key] + if e.get("gone") is True + } + got = {identity[key](e) for e in decoded[key]} + assert got == (base_ids - gone_ids) | delta_ids, ( + f"the merge lost or invented {key} entities: {got} vs " + f"{(base_ids - gone_ids) | delta_ids}" + ) + + +# ------------------------------------------------- live: the pacing + + +async def test_spectators_receive_half_the_rate(ws_server: Any) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + code, _, host = await _playing_room(url, app, "tok-rate") + # The spectator joins by a token-less hello (a minted token has + # no seat — the documented spectator path). + spectator = await _connect(url, code, None, name="Spectator") + await _next_json(spectator) # welcome + await _next_json(spectator) # lobby + await asyncio.sleep(0.3) # let the spectator's first full flush + # A 3.0 s window: the default snapshot rate is the 20 Hz tick + # (period 1 — a player pass every tick), the spectator pass at + # the half rate (every 2nd tick, 10 Hz). + player_bucket: dict[str, int] = {"n": 0} + spec_bucket: dict[str, int] = {"n": 0} + host_q = asyncio.create_task(_count(host, "snap", player_bucket)) + spec_q = asyncio.create_task(_count(spectator, "snap", spec_bucket)) + await asyncio.sleep(3.0) + host_q.cancel() + spec_q.cancel() + for task in (host_q, spec_q): + with contextlib.suppress(asyncio.CancelledError): + await task + player_count = player_bucket["n"] + spectator_count = spec_bucket["n"] + assert player_count >= 50, f"the player got {player_count} frames in 3 s (20 Hz)" + assert 22 <= spectator_count <= 38, ( + f"the spectator got {spectator_count} frames in 3 s (10 Hz)" + ) + assert player_count >= 2 * spectator_count - 10, ( + f"the player's {player_count} is not ~2x the spectator's " + f"{spectator_count}" + ) + + +async def _count(ws: Any, frame_type: str, bucket: dict[str, int]) -> None: + """Count `frame_type` frames on a live socket until cancelled (the + pacing window's half).""" + bucket["n"] = 0 + while True: + data = json.loads(await ws.recv()) + if data["type"] == frame_type: + bucket["n"] += 1 + + +# ------------------------------------------------- live: the latency + + +async def test_the_configured_sim_latency_bounds_the_downlink( + ws_server: Any, +) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR), sim_latency_ms=150) as ( + url, + app, + ): + _, _, host = await _playing_room(url, app, "tok-latency") + # The pump's per-batch delay: the next `snap` after an input is + # written only after the pump's 150 ms sleep — the input→frame + # round trip is lower-bounded by it. + await host.send( + json.dumps( + { + "type": "input", + "seq": 1, + "move": {"x": 1.0, "y": 0.0}, + "act": False, + } + ) + ) + t0 = time.monotonic() + frame: dict[str, Any] | None = None + while frame is None: + data = await _next_json(host) + if data["type"] == "snap": + frame = data + elapsed = time.monotonic() - t0 + assert elapsed >= 0.145, ( + f"the input→frame round trip took {elapsed * 1000:.0f} ms; the " + f"configured 150 ms delay must lower-bound it" + ) + + +# ------------------------------------------------- live: the rate bound + + +async def test_the_downlink_is_bounded_over_hundred_ticks(ws_server: Any) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + _, _, host = await _playing_room(url, app, "tok-bound") + # 100 ticks = 5.0 s of wall clock (the 20 Hz tick task): the + # total bytes the player receives must stay under 60 KB. + total = 0 + frame_count = 0 + deadline = time.monotonic() + 5.0 + while time.monotonic() < deadline: + try: + data = await asyncio.wait_for(host.recv(), timeout=0.5) + except TimeoutError: + continue + frame = json.loads(data) + if frame["type"] == "snap": + frame_count += 1 + total += len(data.encode("utf-8")) + assert frame_count >= 30, ( + f"only {frame_count} snap frames in 5 s (10 Hz expected)" + ) + assert total <= 60_000, f"the 100-tick downlink was {total} bytes" + + +# ======================================= deterministic: the fan-out drive + + +def test_the_pass_pacing_full_cadence_and_decode_equivalence() -> None: + """The defaults (the 20 Hz tick, the 20 Hz snapshot — period 1, + so the player pass is due every tick; the spectator pass at the + half rate, every 2 ticks). The first pass of a fresh audience is a + full (its `needs_full` default), the cadence fulls land at passes + 1, 21, 41, … (a full every 20 passes), and applying the delta + chain must land on the server's own re-encode of the final state + (the decode-equivalence the client's merge owes).""" + room, _clock = _make_room( + bundle=ContentBundle(recipe_book=_make_book(), levels=(_make_level(),)) + ) + player = _FakeConn("player") + spectator = _FakeConn("spectator") + fanout = SnapshotFanout( + room, + cast(ConnectionRegistry, _FakeRegistry([player, spectator])), + ) + room.set_broadcaster(fanout.pass_) + now = _enter_playing(room) + # 21 PLAYING ticks at the 20 Hz cadence: 21 player passes (period + # 1), 10 spectator passes (every 2nd tick). + for _ in range(21): + now += 0.05 + room.tick_once(now=now) + assert len(player.frames) == 21, ( + f"21 player passes expected, got {len(player.frames)}" + ) + assert len(spectator.frames) == 10, ( + f"10 spectator passes expected, got {len(spectator.frames)}" + ) + # The fresh connection's first pass is a full; passes 2-20 are + # deltas; pass 21 is the cadence full (a full every 20 passes — + # one at 1, 21, 41, …). + assert player.frames[0]["full"] is True + for frame in player.frames[1:20]: + assert frame["full"] is False + assert player.frames[20]["full"] is True + assert spectator.frames[0]["full"] is True + for frame in spectator.frames[1:]: + assert frame["full"] is False + # The ≥ 40% shrink on the delta right after the fresh full (an + # idle kitchen: only the clock and the tick move). + full_size = snapshot_size(player.frames[0]) + delta_size = snapshot_size(player.frames[1]) + assert 1.0 - delta_size / full_size >= 0.40, ( + f"the delta shrank by {1.0 - delta_size / full_size:.0%} " + f"(full {full_size} B, delta {delta_size} B)" + ) + # The decode-equivalence: apply the chain (a full frame re-syncs + # the client state; a delta merges on top) through all 20 frames + # after the first and compare to the server's own re-encode of the + # final state (an idle kitchen is static apart from the clock). + state = room.sim_state + assert state is not None + reencoded = encode_snapshot(state) + decoded = player.frames[0] + for frame in player.frames[1:21]: + # A full frame re-syncs the client state; a delta merges on top. + decoded = frame if frame["full"] else _apply_delta(decoded, frame) + for key in ("p", "st", "hz", "o"): + assert decoded[key] == reencoded[key], f"the chain lost its {key}" + # The last pass encodes *before* the tick's own step (the room's + # pacing runs first), so the chain is exactly one tick behind the + # re-encode — and the idle kitchen's entities are identical in + # both. + assert decoded["t"] == reencoded["t"] - 1 + assert decoded["s"] == reencoded["s"] + assert decoded["tgt"] == reencoded["tgt"] + + +def test_the_event_cap_counts_the_drop() -> None: + """`MAX_EVENTS_PER_FRAME` (24) per frame: the 30th event is not + spread over the next frame — it counts in + `Room.stats()["events_dropped"]`, on the `ev` flush (a non-`PLAYING` + boundary) and on the `PLAYING` snapshot pass alike. The room's + buffer is not drained by the wire (the fan-out's cursor reads it + non-destructively).""" + room, _clock = _make_room( + bundle=ContentBundle(recipe_book=_make_book(), levels=(_make_level(),)) + ) + sink = _FakeConn("player") + fanout = SnapshotFanout( + room, cast(ConnectionRegistry, _FakeRegistry([sink])) + ) + room.set_broadcaster(fanout.pass_) + # 30 emote events into the room's buffer (a non-`PLAYING` room: + # the `ev` flush path). + for index in range(30): + room._buffer_event( + _emote("tok-a", f"n{index}") + ) + assert len(room.events_since_snapshot) == 30 + fanout.pass_({"ev"}, ()) + ev = sink.state_frames[-1] + assert ev["type"] == "ev" + assert len(ev["events"]) == MAX_EVENTS_PER_FRAME + assert room.stats()["events_dropped"] == 30 - MAX_EVENTS_PER_FRAME + # The buffer is intact (the cursor is non-destructive). + assert len(room.events_since_snapshot) == 30 + # And the `PLAYING` path caps the same way (30 more events, two + # ticks — the player pass is due on the 2nd). + now = _enter_playing(room) + for index in range(30): + room._buffer_event(_emote("tok-b", f"m{index}")) + now += 0.05 + room.tick_once(now=now) # the first player pass (period 1: due now) + snap = sink.frames[-1] + assert len(snap["e"]) == MAX_EVENTS_PER_FRAME + assert room.stats()["events_dropped"] == 2 * (30 - MAX_EVENTS_PER_FRAME) + assert len(room.events_since_snapshot) == 60 + + +def _emote(player_id: str, emote_id: str) -> Event: + """A lobby emote event (the `ev` flush's payload; the kind is the + server layer's plain string — `seats.EMOTE_EVENT_KIND`).""" + return Event(kind="emote", payload={"player_id": player_id, "emote": emote_id}) + + +def test_a_forced_needs_full_restarts_the_chain_full() -> None: + """The send queue's drop policy marks `needs_full` (a client that + has missed a frame): the audience's next pass is a full again, and + the stream's `prev` resets (the next delta diffs against it).""" + room, _clock = _make_room( + bundle=ContentBundle(recipe_book=_make_book(), levels=(_make_level(),)) + ) + player = _FakeConn("player") + fanout = SnapshotFanout( + room, cast(ConnectionRegistry, _FakeRegistry([player])) + ) + room.set_broadcaster(fanout.pass_) + now = _enter_playing(room) + # Three ticks: a pass on each (period 1): full, delta, delta. + for _ in range(3): + now += 0.05 + room.tick_once(now=now) + assert [f["full"] for f in player.frames] == [True, False, False] + # A dropped snapshot forces the next pass full. + player.needs_full = True + now += 0.05 + room.tick_once(now=now) + assert player.frames[-1]["full"] is True + # The chain resets: the following pass is a delta against the new + # full (not against the stale `prev`). + now += 0.05 + room.tick_once(now=now) + assert player.frames[-1]["full"] is False diff --git a/tests/integration/test_room_fsm.py b/tests/integration/test_room_fsm.py index fe4c2e5..ad0e711 100644 --- a/tests/integration/test_room_fsm.py +++ b/tests/integration/test_room_fsm.py @@ -669,15 +669,19 @@ def test_queue_input_rejects_an_unknown_player_with_an_err_event() -> None: "player_id": "tok-stranger", "seq": 0, } - # A known seat is queued, stamped with the sim's tick, and the queue is - # per-tick: the next `tick_once` consumes and clears it. + # A known seat is queued, stamped with the sim's tick, and the queue + # is per-tick: the next `tick_once` that advances a substep consumes + # and clears it (the task-03 substep guard: a wall-clock tick too + # short to fill a `STEP_DT` keeps the queue, so a frame is never + # dropped without being applied — hence the 0.05 s frame off the + # countdown's final `now`). frame = PlayerInput(player_id="tok-a", seq=7) assert room.queue_input("tok-a", frame) is True state = room.sim_state assert state is not None assert room._queued_inputs[-1].received_at_tick == state.tick assert room._queued_inputs[-1].seq == 7 - room.tick_once() + room.tick_once(now=3.0 + 0.05) assert room._queued_inputs == [] diff --git a/tests/integration/test_room_isolation.py b/tests/integration/test_room_isolation.py index 03104bb..e6865c0 100644 --- a/tests/integration/test_room_isolation.py +++ b/tests/integration/test_room_isolation.py @@ -287,11 +287,16 @@ def test_one_room_fails_without_affecting_the_other( bring_both_to_playing(room_a, room_b, clock) inject_the_fault(room_a) - # The crash tick — isolated: it must not propagate. The wall - # time is not advanced: A takes this tick at the last wall time - # (dt 0), so B's cadence below stays exactly one 0.05 s frame - # per substep. + # The crash tick — isolated: it must not propagate. The task-03 + # substep guard makes a dt-0 tick skip `step` entirely (a + # zero-substep tick would drop queued inputs without applying + # them), so the fault needs a substep-advancing tick: advance + # the wall clock by one period. B's `_last_at` is re-anchored + # to the same wall time so B's cadence below stays exactly one + # 0.05 s frame per substep. + clock.advance(PERIOD) room_a.tick_once() + room_b._last_at = clock.now() check_room_a_is_aborted(room_a, caplog, ada.token, bob.token) check_room_b_keeps_ticking(room_b, clock) diff --git a/tests/integration/test_ws_abuse.py b/tests/integration/test_ws_abuse.py new file mode 100644 index 0000000..6cbc513 --- /dev/null +++ b/tests/integration/test_ws_abuse.py @@ -0,0 +1,450 @@ +"""The abuse suite (phase 06 task 06): the wire's guardrails, driven by +real sockets against a real `uvicorn.Server`. + +The contract under test is PLAN §7 / `docs/PROTOCOL.md` "Inbound +guards": a malformed frame is an `err` + a counted shape violation (5 +inside 10 s → close `4404`); a frame over `max_message_bytes` is +`too_big`, a frame over the token bucket `rate_limited` (3 consecutive +→ `4404`); the per-state whitelist is enforced before the semantics; +the host-only verbs are host-only no matter how fast they are +repeated; the emote throttle (2 per 1.0 s per connection) is a drop, +not an error. A flood of any of these must leave the **other** +connections — and the server itself — exactly as they were: the guard +is per-connection, the room and the process are not the blast radius. + +Every test here bounds its own window: a live room's `snap` stream +never goes quiet while the round is `PLAYING`, so a drain waits for a +*budget* of frames or a *deadline*, never for silence that a healthy +server does not produce (the pre-task-06 drain design hung on a fixed +server and only ever passed on the pre-fix one that aborted). + +Measured on this suite's machine (the `docs/PROTOCOL.md` budget table +carries the same numbers): 200 concurrent handshakes wall 0.18 s, +p95 164 ms, RSS delta 23.4 MB, `send_queue_max` 2. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import time +from typing import Any + +import httpx +import pytest +import websockets +from websockets.exceptions import ConnectionClosed + +from undercooked.server.ws import ( + ABUSE_WINDOW_S, + MAX_SEND_QUEUE, + SHAPE_VIOLATIONS_TO_CLOSE, +) + +pytestmark = pytest.mark.integration + +#: The 4 KB boundary (PLAN §7's `max_message_bytes` default) in bytes. +MAX_MESSAGE_BYTES = 4096 +#: The frame the cap refuses (64 KB — sixteen times the cap). +OVERSIZED_FRAME_BYTES = 64 * 1024 +#: The `send_queue_max` bound for the burst test: the cap bounds +#: droppable traffic; non-droppable frames (the `pong`s a burst buys) +#: may push a queue past `MAX_SEND_QUEUE` until the pump drains it — +#: 4x the cap is the bound a 500-frame burst must stay inside +#: (measured peak: 2). +BURST_QUEUE_BOUND = 4 * MAX_SEND_QUEUE +#: The 200-connection memory bound (RSS delta, MB): the measured +#: footprint is 23.4 MB — the bound is 5x that, still 15x under a +#: per-connection "unbounded" regression would be visible at. +HANDSHAKE_MEMORY_BOUND_MB = 128.0 +#: The p95 handshake bound (the task's: under 1 s). Measured 164 ms. +HANDSHAKE_P95_BOUND_S = 1.0 + + +# ------------------------------------------------------------- the room + + +def _create_room(url: str, token: str) -> str: + """One room, its creator at seat 0 as host.""" + response = httpx.post(f"{url}/api/rooms", json={"token": token}, timeout=10.0) + assert response.status_code == 201, response.text + return response.json()["room_code"] + + +def _join(url: str, code: str, token: str) -> None: + """Seat `token` in `code`.""" + response = httpx.post( + f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=10.0 + ) + assert 200 <= response.status_code < 300, response.text + + +async def _connect( + url: str, code: str, token: str, name: str | None = None +) -> websockets.ClientConnection: + """A connected socket: the upgrade, the `hello`, the `welcome`.""" + ws = await websockets.connect( + url.replace("http://", "ws://", 1) + f"/ws/{code}", open_timeout=10.0 + ) + hello: dict[str, Any] = {"type": "hello", "p": 1, "token": token} + if name is not None: + hello["name"] = name + await ws.send(json.dumps(hello)) + welcome = json.loads(await ws.recv()) + assert welcome["type"] == "welcome", welcome + return ws + + +async def _read_until_close(ws: Any) -> list[dict[str, Any]]: + """Everything `ws` has left to say, until the close frame.""" + frames: list[dict[str, Any]] = [] + while True: + try: + frames.append(json.loads(await asyncio.wait_for(ws.recv(), 5.0))) + except ( + TimeoutError, + ConnectionClosed, + ): + break + return frames + + +async def _alive(ws: Any, tag: str) -> None: + """A keep-alive round trip: `ping` → `pong` (the other connection + is still being served).""" + await ws.send(json.dumps({"type": "ping", "t": 1})) + deadline = asyncio.get_running_loop().time() + 5.0 + while True: + frame = json.loads( + await asyncio.wait_for( + ws.recv(), max(0.1, deadline - asyncio.get_running_loop().time()) + ) + ) + if frame["type"] == "pong": + return + assert ( + asyncio.get_running_loop().time() < deadline + ), f"{tag} lost its pong within 5 s: {frame}" + + +# ------------------------------------------------- 1: the shape storm + + +async def test_two_thousand_malformed_frames_cannot_take_the_server_down( + ws_server: Any, +) -> None: + """2000 malformed frames on one socket: the socket closes at the + violation limit (`4404`), the *other* connection keeps being served, + and the server is healthy afterwards.""" + with ws_server() as (url, app): + code = _create_room(url, "tok-host") + _join(url, code, "tok-attacker") + host = await _connect(url, code, "tok-host", "Host") + attacker = await _connect(url, code, "tok-attacker", "Attacker") + room = app.state.supervisor.get(code) + assert room is not None + + # The storm: 2000 shape violations (valid JSON, no `type` — + # `parse_client_message` answers `bad_field` for each). The + # close falls on the 5th inside the 10 s window; the rest of + # the flood meets a closed socket (suppressed — the guard did + # its job). + for _ in range(2000): + with contextlib.suppress(Exception): + await attacker.send(json.dumps({"nope": 1})) + + # The attacker's socket is gone, by the abuse close. + await _read_until_close(attacker) + closed = attacker.close_code + assert closed is not None, "the attacker's socket was not closed" + assert closed == 4404, f"expected the 4404 abuse close, got {closed}" + assert ( + SHAPE_VIOLATIONS_TO_CLOSE == 5 + ) # the bound the close fell on (the doc's "disconnect after 5") + assert ABUSE_WINDOW_S == 10.0 + + # The other connection is unaffected: a round trip on it, and + # the room exactly as it was (LOBBY, two seats — the attacker's + # seat is kept for the grace, not ejected by the flood). + await _alive(host, "the host") + assert room.state.value == "lobby" + assert len(room.seats) == 2 + probe = await asyncio.to_thread( + httpx.get, f"{url}/healthz", timeout=5.0 + ) + assert probe.status_code == 200 + + +# ------------------------------------------------- 2: the rate burst + + +async def test_a_500_message_burst_hits_the_rate_limit_and_the_queue_bound( + ws_server: Any, +) -> None: + """500 pings on one socket: the bucket (40/40) empties, the + overage is `rate_limited` (3 consecutive → `4404`), the send + queue's high-water mark stays within its bound, and the server + survives.""" + with ws_server() as (url, app): + code = _create_room(url, "tok-host") + host = await _connect(url, code, "tok-host", "Host") + room = app.state.supervisor.get(code) + assert room is not None + + # The burst: 500 pings, as fast as the socket lets us (the + # first 40 take the bucket's burst; the overage is refused). + for index in range(500): + with contextlib.suppress(Exception): + await host.send(json.dumps({"type": "ping", "t": index})) + + frames = await _read_until_close(host) + codes = [ + frame.get("code") + for frame in frames + if frame["type"] == "err" + ] + assert "rate_limited" in codes, ( + "no rate_limited err frame arrived in the burst" + ) + closed = host.close_code + assert closed is not None, "the burst socket was not closed" + assert closed == 4404, f"expected the 4404 abuse close, got {closed}" + + # The queue's high-water mark: the `pong`s the burst buys are + # non-droppable, so a queue may lawfully run past the cap — + # the bound is what must hold (measured peak: 2). + stats = app.state.ws_registry.stats() + assert stats["send_queue_max"] <= BURST_QUEUE_BOUND, stats + + # And the server is healthy. + probe = await asyncio.to_thread( + httpx.get, f"{url}/healthz", timeout=5.0 + ) + assert probe.status_code == 200 + assert room.state.value == "lobby" + + +# ------------------------------------------------- 3: the size cap + + +async def test_the_size_cap_refuses_64_kb_and_admits_the_4kb_boundary( + ws_server: Any, +) -> None: + """A 64 KB frame is `too_big` (refused before parsing, a rate + violation); a frame of exactly `max_message_bytes` (4096 B — the + cap's boundary) is admitted: the `ready` in it lands (the lobby + re-broadcast) without a single `too_big`.""" + with ws_server() as (url, app): + code = _create_room(url, "tok-host") + host = await _connect(url, code, "tok-host", "Host") + room = app.state.supervisor.get(code) + assert room is not None + seat = room.seat_for_token("tok-host") + assert seat is not None and seat.ready is False + + # The 64 KB *text* frame (the wire's contract): any bytes do + # (the cap is checked before the parse) — a `ready` padded + # past the cap with JSON whitespace. + oversized = b'{"type": "ready"}' + b" " * ( + OVERSIZED_FRAME_BYTES - len(b'{"type": "ready"}') + ) + assert len(oversized) == OVERSIZED_FRAME_BYTES + await host.send(oversized.decode("utf-8")) + + # The 64 KB *binary* frame: the same bytes, but the client + # library encodes `bytes` as a binary frame — the regression + # the `KeyError`'s fix covers (the cap answers, not the + # stack's `receive_text()`). + await host.send(oversized) + + # The 4 KB boundary: a `ready` padded with JSON whitespace to + # exactly `max_message_bytes` (not over — the cap is `>`). + payload = b'{"type": "ready"}' + boundary = payload + b" " * (MAX_MESSAGE_BYTES - len(payload)) + assert len(boundary) == MAX_MESSAGE_BYTES + await host.send(boundary.decode("utf-8")) + + # Read what the server said: two `too_big`s (the two 64 KB + # frames), the `ready`'s lobby re-broadcast, nothing else. + saw_too_big = 0 + saw_lobby = False + deadline = asyncio.get_running_loop().time() + 5.0 + while asyncio.get_running_loop().time() < deadline: + try: + frame = json.loads(await asyncio.wait_for(host.recv(), 0.5)) + except TimeoutError: + break + if frame["type"] == "err": + if frame["code"] == "too_big": + saw_too_big += 1 + else: + pytest.fail(f"an unexpected err frame: {frame}") + elif frame["type"] == "lobby": + saw_lobby = True + + assert saw_too_big == 2, ( + f"each 64 KB frame (text and binary) must be refused " + f"exactly once (too_big); saw {saw_too_big}" + ) + assert saw_lobby, "the 4 KB boundary frame did not land (no ready-up)" + assert seat.ready is True, "the boundary frame's ready-up did not apply" + + +# ------------------------------------------------- 4: the lobby flood + + +async def test_a_lobby_flood_cannot_bypass_the_host_check_or_the_emote_throttle( + ws_server: Any, +) -> None: + """A non-host floods the host-only verbs (`start` x 50, + `set_level` x 50) and the emote (x 20): the `not_host` refusals + are per-frame errors, not shape violations (the connection is not + closed by them), no transition happens, the level is untouched, + and the emote throttle (2 per 1.0 s) drops the overage silently + (no `err`, fewer `ev`s than the flood). The flood is paced to + the token bucket (40/40 — waves of ≤ 40 frames a second apart): + the `not_host` refusals must be tested *through* the rate guard, + not by tripping it first.""" + with ws_server() as (url, app): + code = _create_room(url, "tok-host") + _join(url, code, "tok-b") + host = await _connect(url, code, "tok-host", "Host") + b = await _connect(url, code, "tok-b", "B") + room = app.state.supervisor.get(code) + assert room is not None + original_level = room.level + + # The flood, paced to the token bucket (40/40): waves of ≤ 40 + # frames, a second apart (the refill outpaces the wave — the + # `not_host` refusals are what the flood is meant to test). + async def _wave(frames: list[dict[str, Any]]) -> None: + for frame in frames: + with contextlib.suppress(Exception): + await b.send(json.dumps(frame)) + await asyncio.sleep(1.1) + + await _wave([{"type": "start"} for _ in range(40)]) + await _wave( + [{"type": "start"} for _ in range(10)] + + [{"type": "set_level", "level": "not-a-level"} for _ in range(30)] + ) + await _wave( + [{"type": "set_level", "level": "not-a-level"} for _ in range(20)] + + [{"type": "emote", "id": "wave"} for _ in range(10)] + ) + await _wave( + [{"type": "emote", "id": "wave"} for _ in range(10)] + ) + + # B's view: `not_host` for every start/set_level (the semantic + # refusals — 100 of them), no close by the flood. + not_host = 0 + emote_events_b = 0 + deadline = asyncio.get_running_loop().time() + 5.0 + while asyncio.get_running_loop().time() < deadline: + try: + frame = json.loads(await asyncio.wait_for(b.recv(), 0.5)) + except TimeoutError: + break + if frame["type"] == "err" and frame["code"] == "not_host": + not_host += 1 + elif frame["type"] == "ev": + for event in frame["events"]: + if event.get("k") == "emote": + emote_events_b += 1 + assert not_host >= 100, ( + f"every start/set_level needs its not_host refusal " + f"(got {not_host})" + ) + assert ( + b.close_code is None + ), "the not_host refusals are semantic errors, not a 4404" + + # The room is exactly as it was: no transition, no level. + assert room.state.value == "lobby", ( + "the start flood must not have left the lobby" + ) + assert room.level is original_level, ( + "the set_level flood must not have changed the level" + ) + + # The emote overage is a silent drop: fewer events than the + # flood (2 per 1.0 s window — the whole burst is one window on + # a healthy loop; a slow one may roll into a second). + assert emote_events_b < 20, ( + f"the emote throttle must drop the overage (got " + f"{emote_events_b} events from a 20-emote burst)" + ) + + # And the host is unaffected. + await _alive(host, "the host") + + +# ------------------------------------------------- 5: 200 handshakes + + +async def test_200_concurrent_handshakes_stay_within_the_memory_bound( + ws_server: Any, +) -> None: + """200 concurrent handshakes (50 rooms x 4 seats, every seat + taken by a real socket): all 200 `welcome`s land, the p95 + handshake is under 1 s, the socket census sees them, and the + process's RSS growth stays within the bound (measured: 23.4 MB + → the bound is 5x that).""" + with ws_server() as (url, app): + code_of: dict[str, str] = {} + for room_index in range(50): + creator = f"tok-{room_index}-0" + code = _create_room(url, creator) + for seat_index in (1, 2, 3): + _join(url, code, f"tok-{room_index}-{seat_index}") + for seat_index in (0, 1, 2, 3): + code_of[f"tok-{room_index}-{seat_index}"] = code + assert len(code_of) == 200 + + baseline_mb = app.state.ws_registry.memory_mb() + + async def handshake(token: str) -> tuple[str, float]: + started = time.monotonic() + ws = await websockets.connect( + url.replace("http://", "ws://", 1) + f"/ws/{code_of[token]}", + open_timeout=10.0, + ) + await ws.send( + json.dumps({"type": "hello", "p": 1, "token": token}) + ) + welcome = json.loads(await ws.recv()) + assert welcome["type"] == "welcome", welcome + return token, time.monotonic() - started + + results = await asyncio.gather( + *(handshake(token) for token in code_of) + ) + assert len(results) == 200 + + durations = sorted(duration for _, duration in results) + p95 = durations[int(0.95 * len(durations)) - 1] + assert p95 < HANDSHAKE_P95_BOUND_S, ( + f"the p95 handshake is {p95 * 1000:.0f} ms " + f"(bound {HANDSHAKE_P95_BOUND_S * 1000:.0f} ms)" + ) + + # The census sees all 200 sockets (one per token — no + # eviction: every token is distinct). + stats = app.state.ws_registry.stats() + assert stats["sockets"] == 200, stats + + # And the process's growth is bounded (the 200 connections' + # footprint: sockets, queues, the room/seat bookkeeping). + delta_mb = app.state.ws_registry.memory_mb() - baseline_mb + assert delta_mb <= HANDSHAKE_MEMORY_BOUND_MB, ( + f"200 handshakes grew the process by {delta_mb:.1f} MB " + f"(bound {HANDSHAKE_MEMORY_BOUND_MB:.0f} MB)" + ) + + # The server is healthy under the load. + probe = await asyncio.to_thread( + httpx.get, f"{url}/healthz", timeout=5.0 + ) + assert probe.status_code == 200 diff --git a/tests/integration/test_ws_budgets.py b/tests/integration/test_ws_budgets.py new file mode 100644 index 0000000..904a6dc --- /dev/null +++ b/tests/integration/test_ws_budgets.py @@ -0,0 +1,318 @@ +"""The budget suite (phase 06 task 06, `slow`): PLAN §10's +performance numbers, measured on a **live** 4-player room — the mean +tick cost over 400 real 20 Hz ticks (≤ 2 ms, the p99 ≤ 8 ms), the +`snap` frame size (≤ 1.5 KB on the 4-player wire shape), the +downlink rate (≤ 60 KB/s over a 5 s window on one client) and the +input→snapshot echo (≤ 120 ms — the send to the first `snap` that +arrives after it: at 20 Hz the server answers within one tick — +wall-tick jitter re-encodes the same sim tick on some ticks, so the +*distinct*-tick definition is the 2-tick one, and the budget is 2.4 +ticks). + +The tick cost is measured by wrapping `room._tick` (the bound method +the room's own tick task drives — no `FakeClock`, no replay: the +wall-clock 20 Hz loop, the substep dance and all). The sim is kept +working by three driving sockets (a `input` frame each ~10 s⁻¹, a +random unit direction and an occasional press — the same choreography +the two-client round test uses, in miniature); the fourth cook stays +still and is the measurement socket (its `input` seq counter is not +interleaved with the drivers' — a stale-seq refusal would poison +both). + +`docs/PROTOCOL.md` "Testing the protocol" carries the measured +values this suite bounds (the bound is the budget; the measurement +is what a regression moves). +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +import math +import random +import time +from pathlib import Path +from typing import Any + +import httpx +import pytest +import websockets +from websockets.exceptions import ConnectionClosed + +pytestmark = [pytest.mark.integration, pytest.mark.slow] + +PLAYABLE_LEVELS_DIR = ( + Path(__file__).resolve().parents[1] / "fixtures" / "levels_playable" +) + +#: The tick budget (PLAN §10): the mean over 400 real ticks, and the +#: p99 (a single slow tick may not hide in the mean). +TICKS_TO_MEASURE = 400 +MEAN_TICK_BUDGET_S = 0.002 +P99_TICK_BUDGET_S = 0.008 +#: The `snap` size budget (the 4p wire shape, `test_encoding.py`'s +#: 1.5 KB). +SNAPSHOT_BUDGET_BYTES = 1500 +#: The downlink budget (bytes per second, a 5 s window on one client). +DOWNLINK_WINDOW_S = 5.0 +DOWNLINK_BUDGET_BYTES_PER_S = 60 * 1024 +#: The input→snapshot echo budget (send → the first `snap` that +#: arrives after the send; at 20 Hz the server answers within one +#: tick — 50 ms — and the budget is 2.4 ticks). +ECHO_BUDGET_S = 0.120 + + +# ------------------------------------------------------------- the room + + +def _create_room(url: str, token: str) -> str: + response = httpx.post( + f"{url}/api/rooms", json={"token": token}, timeout=10.0 + ) + assert response.status_code == 201, response.text + return response.json()["room_code"] + + +def _join(url: str, code: str, token: str) -> None: + response = httpx.post( + f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=10.0 + ) + assert 200 <= response.status_code < 300, response.text + + +async def _connect(url: str, code: str, token: str, name: str) -> Any: + ws = await websockets.connect( + url.replace("http://", "ws://", 1) + f"/ws/{code}", open_timeout=10.0 + ) + await ws.send( + json.dumps({"type": "hello", "p": 1, "token": token, "name": name}) + ) + welcome = json.loads(await ws.recv()) + assert welcome["type"] == "welcome", welcome + return ws + + +async def _wait_for_async(predicate: Any, timeout_s: float) -> None: + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if predicate(): + return + await asyncio.sleep(0.05) + msg = f"timed out after {timeout_s:.0f} s" + raise TimeoutError(msg) + + +def _input_frame(rng: random.Random, seq: int) -> dict[str, Any]: + """One `input` frame: a random unit movement intent (the sim + clamps any magnitude) and an occasional press.""" + angle = rng.uniform(0.0, 2.0 * math.pi) + return { + "type": "input", + "seq": seq, + "move": { + "x": round(math.cos(angle), 6), + "y": round(math.sin(angle), 6), + }, + "act": rng.random() < 0.1, + } + + +# --------------------------------------------------- the three phases + + +async def _measure_tick_cost(room: Any) -> tuple[float, float, int]: + """The mean / p99 / n of the room's real tick, over + `TICKS_TO_MEASURE` ticks (the `room._tick` wrap — the room's own + tick task drives it; `tick_once`'s crash isolation stays outside + the wrap).""" + durations: list[float] = [] + original_tick = room._tick + + def _timed_tick(dt: float, when: float) -> None: + started = time.perf_counter() + original_tick(dt, when) + durations.append(time.perf_counter() - started) + + room._tick = _timed_tick # type: ignore[method-assign] + try: + await _wait_for_async( + lambda: len(durations) >= TICKS_TO_MEASURE, timeout_s=60.0 + ) + finally: + room._tick = original_tick # type: ignore[method-assign] + mean_tick = sum(durations) / len(durations) + p99 = sorted(durations)[int(0.99 * len(durations)) - 1] + return mean_tick, p99, len(durations) + + +async def _measure_downlink(ws: Any) -> tuple[float, int, int]: + """One client's downlink over `DOWNLINK_WINDOW_S`: the bytes per + second, the largest `snap` frame and the snap count. The kernel + TCP backlog the tick phase left on this (unread) socket is + drained first (2 s — the window's bound, and loopback delivers + the backlog far faster): without it the window would measure the + catch-up burst, not the steady state a continuously-reading + client sees.""" + drain_deadline = time.monotonic() + 2.0 + while time.monotonic() < drain_deadline: + with contextlib.suppress( + TimeoutError, asyncio.TimeoutError, ConnectionClosed + ): + await asyncio.wait_for(ws.recv(decode=False), 0.5) + total_bytes = 0 + snapshot_sizes: list[int] = [] + deadline = time.monotonic() + DOWNLINK_WINDOW_S + while time.monotonic() < deadline: + try: + raw = await asyncio.wait_for(ws.recv(decode=False), 1.0) + except TimeoutError: + continue + except ConnectionClosed: + break + total_bytes += len(raw) + if json.loads(raw)["type"] == "snap": + snapshot_sizes.append(len(raw)) + downlink_bytes_per_s = total_bytes / DOWNLINK_WINDOW_S + max_snapshot = max(snapshot_sizes) if snapshot_sizes else 0 + return downlink_bytes_per_s, max_snapshot, len(snapshot_sizes) + + +async def _measure_echo(ws: Any, rng: random.Random) -> list[float]: + """Three input→snapshot echoes (~1 s apart): the send to the first + `snap` that arrives after it. The echo budget is the bound — a + sample that misses it fails, and a snap already in the socket's + buffer (the tick between the send and the read) reads 0 ms.""" + elapsed_s: list[float] = [] + for sample in range(3): + frame = _input_frame(rng, 100_000 + sample) + sent_at = time.monotonic() + with contextlib.suppress(Exception): + await ws.send(json.dumps(frame)) + saw_echo = False + sample_elapsed: float | None = None + deadline = sent_at + ECHO_BUDGET_S + while time.monotonic() < deadline: + try: + raw = await asyncio.wait_for(ws.recv(decode=False), 1.0) + except TimeoutError: + break # the echo budget is the bound; it expires + except ConnectionClosed: + break + if json.loads(raw)["type"] == "snap": + elapsed = time.monotonic() - sent_at + assert elapsed <= ECHO_BUDGET_S, ( + f"the input→snapshot echo is " + f"{elapsed * 1000:.0f} ms " + f"(budget {ECHO_BUDGET_S * 1000:.0f} ms)" + ) + saw_echo = True + sample_elapsed = elapsed + break + assert saw_echo and sample_elapsed is not None, ( + f"sample {sample}: no snap frame arrived after the input " + f"within the {ECHO_BUDGET_S * 1000:.0f} ms echo budget" + ) + elapsed_s.append(sample_elapsed) + await asyncio.sleep(1.0) + return elapsed_s + + +# ------------------------------------------------------------- the test + + +async def test_the_4_player_room_holds_the_tick_snapshot_downlink_and_echo_budgets( + ws_server: Any, +) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + code = _create_room(url, "tok-0") + for seat in (1, 2, 3): + _join(url, code, f"tok-{seat}") + clients = [ + await _connect(url, code, f"tok-{seat}", f"Cook{seat}") + for seat in range(4) + ] + room = app.state.supervisor.get(code) + assert room is not None + + # Ready-up (every seat — the start guard), then the start. + for ws in clients: + await ws.send(json.dumps({"type": "ready"})) + await clients[0].send(json.dumps({"type": "start"})) + await _wait_for_async( + lambda: room.state.value == "playing" + and room.sim_state is not None, + timeout_s=30.0, + ) + + # The drivers: three cooks wander on a ~10 s⁻¹ cadence (a + # random unit direction, an occasional press). The sim is the + # loaded machine the budget applies to. The fourth (the + # measurement socket) stays still — its `input` seq counter + # is not interleaved with the drivers' (a stale-seq refusal + # would poison both). The seeded generator is the test's own + # determinism (a fixed choreography, never a credential — + # `random` is the sim's only RNG, and this is not the sim). + rng = random.Random(20240606) # noqa: S311 + + async def _drive(ws: Any) -> None: + seq = 1 + while True: + frame = _input_frame(rng, seq) + seq += 1 + with contextlib.suppress(Exception): + await ws.send(json.dumps(frame)) + await asyncio.sleep(0.1) + + drivers = [ + asyncio.create_task(_drive(ws)) for ws in clients[:3] + ] + + # The measurement socket's keep-alive (the documented client + # behaviour — a silent socket for `HEARTBEAT_DEAD_S` (12 s) is + # a dead client, and the test's window outlives the silence): + # a `ping` every 2 s keeps it alive for the full ~30 s. + async def _keepalive(ws: Any) -> None: + n = 0 + while True: + await asyncio.sleep(2.0) + n += 1 + with contextlib.suppress(Exception): + await ws.send(json.dumps({"type": "ping", "t": n})) + + keepalive = asyncio.create_task(_keepalive(clients[3])) + try: + # ---- the tick cost (400 real 20 Hz ticks, ~20 s of wall) + mean_tick, p99, n_ticks = await _measure_tick_cost(room) + assert mean_tick <= MEAN_TICK_BUDGET_S, ( + f"the mean tick cost is {mean_tick * 1000:.3f} ms over " + f"{n_ticks} ticks (budget " + f"{MEAN_TICK_BUDGET_S * 1000:.1f} ms)" + ) + assert p99 <= P99_TICK_BUDGET_S, ( + f"the p99 tick cost is {p99 * 1000:.3f} ms " + f"(budget {P99_TICK_BUDGET_S * 1000:.1f} ms)" + ) + + # ---- the downlink + the snapshot size (one client, 5 s) + bytes_per_s, max_snapshot, n_snaps = await _measure_downlink( + clients[3] + ) + assert n_snaps > 0, "no snap frames in the downlink window" + assert bytes_per_s <= DOWNLINK_BUDGET_BYTES_PER_S, ( + f"the downlink is {bytes_per_s / 1024:.1f} KB/s " + f"(budget {DOWNLINK_BUDGET_BYTES_PER_S / 1024:.0f} KB/s)" + ) + assert max_snapshot <= SNAPSHOT_BUDGET_BYTES, ( + f"a snap frame is {max_snapshot} bytes " + f"(budget {SNAPSHOT_BUDGET_BYTES})" + ) + + # ---- the input→snapshot echo (3 samples, ~1 s apart) + await _measure_echo(clients[3], rng) + finally: + for task in drivers: + task.cancel() + keepalive.cancel() + with contextlib.suppress(asyncio.CancelledError): + await asyncio.gather(*drivers, keepalive) diff --git a/tests/integration/test_ws_disconnect.py b/tests/integration/test_ws_disconnect.py new file mode 100644 index 0000000..2bdfe52 --- /dev/null +++ b/tests/integration/test_ws_disconnect.py @@ -0,0 +1,686 @@ +"""Phase 06 task 05 (US-09): heartbeats, drops, the seat grace and the +rejoin — end to end. + +Two parts, one contract (docs/PROTOCOL.md "Heartbeats, drops, and seat +grace"): + +* **the room level** — a `FakeClock`-driven `Room` (no test sleeps): + `on_disconnect` keeps the seat and ghosts the sim's cook (one `GHOST_ON`), + the park at `PARK_AFTER_S` (3 s) releases the carried item exactly once + (a station set-down or `lost_items`), `on_reconnect` restores the seat + (one `GHOST_OFF`, the carry kept), a reconnect of a live seat emits no + `GHOST_OFF`, the `SeatAbandoned` at `guest_idle_timeout_s` empties the + seat with a *forced* release (even before the 3 s park deadline), and + a closed room runs the `on_close` hook once. +* **the socket level** — a live uvicorn server (real time, the 20 Hz tick + task) and `websockets` clients: an abrupt drop keeps the seat in the + grace and parks the carry, a rejoin inside the grace restores the seat + (`welcome.rejoined = true`, one `GHOST_OFF`, the snapshot stream resumes + with a full frame within one tick's budget), a rejoin after the grace + spectates (or gets `err room_gone` + close `4401` when the room has + closed in the meantime), a 12 s silent client is closed `1000` and the + room sees the standard drop, and the test-only seam + (`POST /api/test/kill_socket/{code}/{token}`, `UNDERCOOKED_TEST_MODE=1` + only) simulates a hard drop — `404` when the seam is off. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import copy +import json +import pathlib +from collections.abc import Callable, Mapping +from dataclasses import replace as dc_replace +from typing import Any + +import httpx +import pytest +import websockets + +from undercooked.config import Settings +from undercooked.server.clock import FakeClock +from undercooked.server.room import Room, RoomState +from undercooked.sim.content import ContentBundle +from undercooked.sim.level import Level, LevelDef +from undercooked.sim.model import ( + EventKind, + Item, + ItemForm, + ItemKind, + PlayerState, + StationKind, +) +from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step + +pytestmark = pytest.mark.integration + +PLAYABLE_LEVELS_DIR = pathlib.Path(__file__).resolve().parents[1] / "fixtures" / "levels_playable" + + +# --------------------------------------------------------------------- helpers + + +def _uri(url: str, code: str) -> str: + return url.replace("http://", "ws://", 1) + f"/ws/{code}" + + +async def _connect( + url: str, + code: str, + token: str | None = None, + *, + name: str | None = None, +) -> Any: + """A connected client with the `hello` already sent.""" + ws = await websockets.connect(_uri(url, code), open_timeout=10.0) + if token is not None or name is not None: + hello: dict[str, Any] = {"type": "hello", "p": 1} + if token is not None: + hello["token"] = token + if name is not None: + hello["name"] = name + await ws.send(json.dumps(hello)) + return ws + + +async def _next_json(ws: Any, wait_s: float = 15.0) -> dict[str, Any]: + return json.loads(await asyncio.wait_for(ws.recv(), wait_s)) + + +async def _wait_for( + predicate: Callable[[], bool], what: str, wait_s: float = 20.0 +) -> None: + """Poll across threads (the server's loop does the work; this thread + only observes it).""" + loop = asyncio.get_running_loop() + deadline = loop.time() + wait_s + while not predicate(): + if loop.time() > deadline: + pytest.fail(f"timed out waiting for {what}") + await asyncio.sleep(0.02) + + +def _create_room(url: str, token: str | None = None) -> str: + body: dict[str, Any] = {} + if token is not None: + body["token"] = token + response = httpx.post(f"{url}/api/rooms", json=body, timeout=5.0) + assert response.status_code == 201, response.text + return response.json()["room_code"] + + +def _join(url: str, code: str, token: str) -> None: + response = httpx.post( + f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0 + ) + assert response.status_code == 200, response.text + + +def _kill_socket(url: str, code: str, token: str) -> httpx.Response: + """The test-only seam (`UNDERCOOKED_TEST_MODE=1`): a hard drop.""" + return httpx.post(f"{url}/api/test/kill_socket/{code}/{token}", timeout=5.0) + + +def _ghost_count( + frames: list[dict[str, Any]], kind: str +) -> int: + """How many `kind` events a frame stream carries (the `snap` frame + nests the PLAN §4 shape under `raw`, events in `raw.e` under `k`; the + standalone `ev` frame under `kind`).""" + count = 0 + for frame in frames: + if frame["type"] == "snap": + count += sum( + 1 + for event in frame.get("raw", {}).get("e", ()) + if event.get("k") == kind + ) + elif frame["type"] == "ev": + count += sum( + 1 for event in frame.get("events", ()) if event.get("kind") == kind + ) + return count + + +def _sim_player(room: Room, token: str) -> Any: + sim = room.sim_state + assert sim is not None, "the round is not built" + player = next((p for p in sim.players if p.id == token), None) + assert player is not None, f"no sim player for {token!r}" + return player + + +def _counters(room: Room) -> Mapping[str, int]: + sim = room.sim_state + assert sim is not None, "the round is not built" + return sim.counters + + +def _seat_connected(room: Room, token: str) -> bool: + seat = room.seat_for_token(token) + return seat is not None and seat.connected is True + + +def _seat_disconnected(room: Room, token: str) -> bool: + seat = room.seat_for_token(token) + return seat is not None and seat.connected is False + + +def _give_carry(room: Room, token: str) -> None: + """A test-side hand item: the park's release needs something to + release (a live round's cooking is too flaky to script).""" + sim = room.sim_state + assert sim is not None + sim = dc_replace( + sim, + players=tuple( + dc_replace( + p, carrying=Item(kind=ItemKind.TOMATO, form=ItemForm.RAW) + ) + if p.id == token + else p + for p in sim.players + ), + ) + room.sim_state = sim + + +async def _playing_room( + url: str, app: Any, host_token: str, guest_token: str +) -> tuple[str, Room, Any, Any]: + """Two seated players in `PLAYING` (the 3 s real-time countdown on + the 20 Hz tick task).""" + code = _create_room(url, host_token) + _join(url, code, guest_token) + host = await _connect(url, code, host_token, name="Host") + await _next_json(host) # welcome + await _next_json(host) # lobby + guest = await _connect(url, code, guest_token, name="Guest") + await _next_json(guest) # welcome + await _next_json(guest) # lobby + await guest.send(json.dumps({"type": "ready"})) + await host.send(json.dumps({"type": "start"})) + room = app.state.supervisor.get(code) + assert room is not None + await _wait_for( + lambda: room.state is RoomState.PLAYING and room.sim_state is not None, + f"room {code} to reach PLAYING", + wait_s=30.0, + ) + return code, room, host, guest + + +async def _close_clean(ws: Any) -> None: + with contextlib.suppress(Exception): + await ws.close() + + +def _keepalive(ws: Any) -> asyncio.Task[None]: + """The documented keep-alive (≥ 5 Hz budget; 0.5 Hz is plenty for a + test client): a silent socket for `HEARTBEAT_DEAD_S` (12 s) is a + dead client, so a test client that sits for a while must ping.""" + state = {"n": 0} + + async def _loop() -> None: + while True: + await asyncio.sleep(2.0) + state["n"] += 1 + with contextlib.suppress(Exception): + await ws.send(json.dumps({"type": "ping", "t": state["n"]})) + + return asyncio.create_task(_loop()) + + +# ------------------------------------------------- the room level (FakeClock) + + +def make_book() -> RecipeBook: + return RecipeBook( + recipes={ + "soup": Recipe( + id="soup", + name="Soup", + base_score=40, + ingredients=[ + Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW, count=1) + ], + steps=[ + Step(kind="serve", station=StationKind.SERVE, seconds=0.0) + ], + ) + }, + ingredients=[ItemKind.TOMATO], + ) + + +def make_level(level_id: str) -> Level: + """A 7x4 kitchen; `time_limit` 60 s — a `PLAYING` round that will not + end while the grace/park windows run.""" + grid = [ + "#######", + "#S...V#", + "#.....#", + "#######", + ] + tiles: dict[str, dict[str, str]] = { + ".": {"kind": "floor"}, + "#": {"kind": "wall"}, + "S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"}, + "V": {"kind": "floor", "station": "serve"}, + } + data: dict[str, object] = { + "id": level_id, + "name": f"Ghost kitchen {level_id}", + "order": 1, + "time_limit": 60.0, + "target_score": 1000, + "grid": copy.deepcopy(grid), + "tiles": copy.deepcopy(tiles), + "spawns": [[2, 2]], + "orders": { + "pool": ["soup"], + "concurrent": 1, + "spawn_interval": [4, 6], + "deadline": 30, + }, + } + return Level.build(LevelDef.model_validate(data)) + + +def make_bundle() -> ContentBundle: + return ContentBundle(recipe_book=make_book(), levels=(make_level("ghost"),)) + + +def make_room( + **settings: Any, +) -> tuple[Room, FakeClock]: + clock = FakeClock() + bundle = make_bundle() + room = Room( + code="TEST1", + settings=Settings(**settings), + clock=clock, + content=bundle, + level_id=bundle.levels[0].id, + ) + return room, clock + + +def _playing(room: Room) -> float: + """A two-seat `PLAYING` room; returns the `now` the round starts at.""" + room.add_seat("tok-a", "Ada", True) + room.add_seat("tok-b", "Bob", False) + room.set_ready("tok-b", True) + room.request_start() + room.tick_once(now=0.0) # the dt-0 prime (the countdown's first frame) + for offset in (1.0, 2.0, 3.0): + room.tick_once(now=offset) + assert room.state is RoomState.PLAYING + return 3.0 + + +def test_on_disconnect_keeps_the_seat_and_ghosts_the_cook() -> None: + room, _ = make_room() + now = _playing(room) + room.on_disconnect("tok-b", now=now) + seat = room.seat_for_token("tok-b") + assert seat is not None # the seat is KEPT for the rejoin path + assert seat.connected is False + player = _sim_player(room, "tok-b") + assert player.connected is False + assert player.state is PlayerState.DISCONNECTED + events = [e for e in room.take_events() if e.kind is EventKind.GHOST_ON] + assert len(events) == 1 # one `GHOST_ON` per drop + # A second drop mark is a no-op: no second `GHOST_ON`. + assert room.on_disconnect("tok-b", now=now) is True + assert room.take_events() == () + + +def test_park_releases_the_carried_item_after_three_seconds() -> None: + room, _ = make_room() + now = _playing(room) + _give_carry(room, "tok-b") + room.on_disconnect("tok-b", now=now) + events = [e for e in room.take_events() if e.kind is EventKind.GHOST_ON] + assert len(events) == 1 # one `GHOST_ON` per drop + # One second before the deadline: no release yet. + room.tick_once(now=now + 2.9) + assert _sim_player(room, "tok-b").carrying is not None + assert room.take_events() == () + # The 3 s deadline (the room's tick, off `seat.last_seen`): the item + # is released into `lost_items` (the tile has no station under the + # spawn) with one `GHOST_PARK`. + room.tick_once(now=now + 3.0) + assert _sim_player(room, "tok-b").carrying is None + assert _counters(room)["lost_items"] == 1 + kinds = [e.kind for e in room.take_events()] + assert EventKind.GHOST_PARK in kinds + # A later tick cannot double-release. + room.tick_once(now=now + 6.0) + assert _counters(room)["lost_items"] == 1 + assert room.take_events() == () + + +def test_on_reconnect_restores_the_seat_and_keeps_the_carry() -> None: + room, _ = make_room() + now = _playing(room) + _give_carry(room, "tok-b") + room.on_disconnect("tok-b", now=now) + room.on_reconnect("tok-b", now=now + 1.0) + seat = room.seat_for_token("tok-b") + assert seat is not None and seat.connected is True + player = _sim_player(room, "tok-b") + assert player.connected is True + assert player.state is PlayerState.IDLE + assert player.carrying is not None # the rejoin keeps the carry + events = [e for e in room.take_events() if e.kind is EventKind.GHOST_OFF] + assert len(events) == 1 # one `GHOST_OFF` (the drop's `GHOST_ON` drained) + + +def test_reconnect_without_a_disconnect_emits_no_ghost_off() -> None: + room, _ = make_room() + _playing(room) + assert room.on_reconnect("tok-b") is True # the seat was never a ghost + assert room.take_events() == () # no `GHOST_OFF` + + +def test_seat_abandoned_after_the_grace_releases_and_empties() -> None: + room, _ = make_room(guest_idle_timeout_s=2.0) + now = _playing(room) + _give_carry(room, "tok-b") + room.on_disconnect("tok-b", now=now) + # Inside the grace: the seat stays (the rejoin window is open). + room.tick_once(now=now + 1.5) + assert room.seat_for_token("tok-b") is not None + # Past the grace: the seat is emptied, the carry released at once, + # one `GHOST_OFF`, the sim's player stays (parked). + room.tick_once(now=now + 2.1) + assert room.seat_for_token("tok-b") is None + assert _sim_player(room, "tok-b").carrying is None + assert _sim_player(room, "tok-b").connected is False + assert _counters(room)["lost_items"] == 1 + kinds = [e.kind for e in room.take_events()] + assert EventKind.GHOST_OFF in kinds + # The room may close once the last seat is gone too: the close + # rules apply (the 30 s seat grace / the supervisor's GC). + room.on_disconnect("tok-a", now=now + 2.1) + room.tick_once(now=now + 4.2) + assert room.seat_for_token("tok-a") is None + assert room.is_empty + + +def test_abandon_before_the_park_deadline_forces_the_release() -> None: + """A 1 s grace runs out before the 3 s park deadline: the release + must not wait for the park — the seat is going away.""" + room, _ = make_room(guest_idle_timeout_s=1.0) + now = _playing(room) + _give_carry(room, "tok-b") + room.on_disconnect("tok-b", now=now) + room.tick_once(now=now + 1.1) # the grace expires, 1.9 s before the park + assert room.seat_for_token("tok-b") is None + assert _sim_player(room, "tok-b").carrying is None + assert _counters(room)["lost_items"] == 1 + + +def test_a_closed_room_runs_the_on_close_hook_once() -> None: + calls: list[str] = [] + + async def _hook(room: Room) -> None: + calls.append(room.code) + + clock = FakeClock() + bundle = make_bundle() + room = Room( + code="TEST1", + settings=Settings(), + clock=clock, + content=bundle, + level_id=bundle.levels[0].id, + on_close=_hook, + ) + room.add_seat("tok-a", "Ada", True) + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(room.close()) + assert calls == ["TEST1"] # once, not twice + loop.run_until_complete(room.close()) # idempotent: no second call + assert calls == ["TEST1"] + finally: + loop.close() + + +# ------------------------------------------------- the socket level (live) + + +async def test_an_abrupt_drop_keeps_the_seat_and_parks_the_carry( + ws_server: Any, +) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + _code, room, host, guest = await _playing_room(url, app, "tok-a", "tok-b") + _keep = _keepalive(host) + _give_carry(room, "tok-b") + # The drop: the client closes abruptly (no clean close). + with contextlib.suppress(Exception): + await guest.close() + await _wait_for( + lambda: _seat_disconnected(room, "tok-b"), + "the seat to be marked disconnected", + wait_s=10.0, + ) + seat = room.seat_for_token("tok-b") + assert seat is not None # the seat is kept for the grace + assert seat.connected is False + player = _sim_player(room, "tok-b") + assert player.connected is False + assert player.state is PlayerState.DISCONNECTED + # The park at 3 s (the room's tick, real time): the carry lands + # in `lost_items`; the wire saw one `GHOST_ON` and one + # `GHOST_PARK` (collected on the other player's stream). + frames: list[dict[str, Any]] = [] + await _wait_for( + lambda: room.sim_state is not None + and room.sim_state.counters.get("lost_items") == 1, + "the park release", + wait_s=15.0, + ) + # Drain the host's stream and count the ghost events. Bounded + # by design: a live room's `snap` stream never goes quiet + # while the round is `PLAYING` (this level's 150 s time limit + # outlives the test), and both ghost events land within one + # tick of the drop and of the park — a 10 s window after the + # park release is ten times the margin; the `ConnectionClosed` + # break still catches the round ending early. + deadline = asyncio.get_running_loop().time() + 10.0 + while asyncio.get_running_loop().time() < deadline: + try: + frames.append( + json.loads(await asyncio.wait_for(host.recv(), 1.0)) + ) + except TimeoutError: + continue # a quiet second is fine; the window bounds + except websockets.exceptions.ConnectionClosed: + break + assert _ghost_count(frames, "ghost_on") == 1 + assert _ghost_count(frames, "ghost_park") == 1 + _keep.cancel() + with contextlib.suppress(asyncio.CancelledError): + await _keep + + +async def test_rejoin_in_grace_restores_the_seat(ws_server: Any) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + code, room, host, guest = await _playing_room(url, app, "tok-a", "tok-b") + _keep = _keepalive(host) + await _close_clean(guest) + await _wait_for( + lambda: _seat_disconnected(room, "tok-b"), + "the seat to be marked disconnected", + wait_s=10.0, + ) + # The rejoin inside the grace (a fresh socket, the same token). + back = await _connect(url, code, "tok-b", name="Guest") + welcome = await _next_json(back) + assert welcome["type"] == "welcome" + assert welcome["rejoined"] is True # the rejoin flag (task 05) + await _wait_for( + lambda: _seat_connected(room, "tok-b"), + "the seat to be restored", + wait_s=10.0, + ) + player = _sim_player(room, "tok-b") + assert player.connected is True + # The snapshot stream resumes with a full frame within one + # tick's budget (the rejoining socket's `needs_full` resync). + snap = None + while snap is None: + frame = json.loads(await asyncio.wait_for(back.recv(), 5.0)) + if frame["type"] == "snap": + snap = frame + assert "d" not in snap.get("raw", {}) # a full frame, not a delta + # The wire saw one `GHOST_OFF` (and the drop's one `GHOST_ON`). + frames: list[dict[str, Any]] = [] + for _ in range(30): + try: + frames.append(json.loads(await asyncio.wait_for(host.recv(), 1.0))) + except TimeoutError: + break + except websockets.exceptions.ConnectionClosed: + break + assert _ghost_count(frames, "ghost_on") == 1 + assert _ghost_count(frames, "ghost_off") >= 1 + _keep.cancel() + with contextlib.suppress(asyncio.CancelledError): + await _keep + + +async def test_rejoin_after_the_grace_spectates(ws_server: Any) -> None: + with ws_server( + levels_dir=str(PLAYABLE_LEVELS_DIR), guest_idle_timeout_s=2.0 + ) as (url, app): + code, room, _host, guest = await _playing_room(url, app, "tok-a", "tok-b") + await _close_clean(guest) + await _wait_for( + lambda: room.seat_for_token("tok-b") is None, + "the seat to be abandoned (the grace ran out)", + wait_s=15.0, + ) + # The rejoin after the grace: the seat is gone — a late join in + # `PLAYING` spectates (not a rejoin). + back = await _connect(url, code, "tok-b", name="Guest") + welcome = await _next_json(back) + assert welcome["type"] == "welcome" + assert welcome["rejoined"] is False + assert room.seat_for_token("tok-b") is None + assert room.spectator_for_token("tok-b") is not None + + +async def test_a_rejoin_into_a_gone_room_says_room_gone(ws_server: Any) -> None: + with ws_server( + levels_dir=str(PLAYABLE_LEVELS_DIR), + guest_idle_timeout_s=2.0, + lobby_idle_timeout_s=3.0, + ) as (url, app): + # A solo host room in `LOBBY`: the drop empties it, and the + # supervisor's GC closes it (the `LOBBY` idle rule, the 5 s + # pass). + code = _create_room(url, "tok-a") + solo = await _connect(url, code, "tok-a", name="Solo") + await _next_json(solo) # welcome + await _next_json(solo) # lobby + room = app.state.supervisor.get(code) + assert room is not None + await _close_clean(solo) + await _wait_for( + lambda: room.seat_for_token("tok-a") is None, + "the seat to be abandoned", + wait_s=15.0, + ) + await _wait_for( + lambda: room.state is RoomState.CLOSED, + "the room to close (the GC's idle rule)", + wait_s=30.0, + ) + # A stale-token rejoin: `err room_gone` + close `4401`, before + # any seat is taken — the client's "the room died" signal. The + # close may race the open handshake (the accept went out before + # the refusal): either the `err` frame arrives, or the `4401` + # close does — both carry the same signal. + closed: websockets.exceptions.ConnectionClosed | None = None + late: Any = None + try: + late = await websockets.connect(_uri(url, code), open_timeout=10.0) + await late.send(json.dumps({"type": "hello", "p": 1, "token": "tok-a"})) + for _ in range(5): # the refusal close follows the `err` + await asyncio.wait_for(late.recv(), 10.0) + except websockets.exceptions.ConnectionClosed as exc: + closed = exc + assert closed is not None and closed.rcvd is not None + assert closed.rcvd.code == 4401 + + +async def test_a_dead_client_is_closed_by_the_heartbeat_watchdog(ws_server: Any) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + code = _create_room(url, "tok-a") + silent = await _connect(url, code, "tok-a", name="Silent") + await _next_json(silent) # welcome + await _next_json(silent) # lobby + room = app.state.supervisor.get(code) + assert room is not None + # 12 s of silence (no `ping`): the watchdog closes `1000` and + # the room sees the standard drop — indistinguishable from a + # hard drop (one mark, one ghost). + with pytest.raises(websockets.exceptions.ConnectionClosed) as exc: + while True: + await asyncio.wait_for(silent.recv(), 20.0) + assert exc.value.rcvd is not None + assert exc.value.rcvd.code == 1000 + await _wait_for( + lambda: _seat_disconnected(room, "tok-a"), + "the seat to be marked disconnected", + wait_s=10.0, + ) + + +async def test_the_test_seam_kills_a_socket(ws_server: Any) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR), test_mode=True) as ( + url, + app, + ): + code, room, _host, guest = await _playing_room(url, app, "tok-a", "tok-b") + # The seam force-closes the socket server-side: a hard drop for + # the client, the standard `on_disconnect` for the room. + response = _kill_socket(url, code, "tok-b") + assert response.status_code == 200, response.text + # The client sees the hard drop (past any buffered frames). + closed = False + for _ in range(50): + try: + await asyncio.wait_for(guest.recv(), 10.0) + except websockets.exceptions.ConnectionClosed: + closed = True + break + assert closed, "the killed socket did not close" + await _wait_for( + lambda: _seat_disconnected(room, "tok-b"), + "the seat to be marked disconnected", + wait_s=10.0, + ) + # An unknown token: `404 no_connection`. + response = _kill_socket(url, code, "tok-nobody") + assert response.status_code == 404 + assert response.json()["code"] == "no_connection" + + +async def test_the_test_seam_is_gated_by_test_mode(ws_server: Any) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, _app): + # `test_mode` unset: the seam is not registered at all. + code = _create_room(url, "tok-a") + solo = await _connect(url, code, "tok-a", name="Solo") + await _next_json(solo) # welcome + await _next_json(solo) # lobby + response = _kill_socket(url, code, "tok-a") + assert response.status_code == 404 + # The socket is still alive (the seam never ran). + await _close_clean(solo) diff --git a/tests/integration/test_ws_handshake.py b/tests/integration/test_ws_handshake.py new file mode 100644 index 0000000..e25e363 --- /dev/null +++ b/tests/integration/test_ws_handshake.py @@ -0,0 +1,477 @@ +"""Integration: the WebSocket endpoint, handshake, and connection guardrails +(phase 06 task 02; US-01/US-02) — every path driven by **real sockets** +against a live `uvicorn.Server` on an ephemeral `127.0.0.1` port (the +`ws_server` factory in `tests/integration/conftest.py`), with the app's +own supervisor reached for state assertions (the seat's `connected` flag, +the `mark_disconnected` call count, the socket census). + +* **the happy handshake** — `POST /api/rooms` (a known token) → + `/ws/{code}` → `hello` → `welcome` (the joiner's seat index / host flag + correct) + the current `lobby` (the host's seat, `can_start`); a + `ping` inside the handshake window gets a `pong` before the `hello`, +* **the refusal closes** — unknown code → `err room_gone` + close `4401`; + a full room (a *new* token) → `err room_full` + close `4402`; no + `hello` within the (shortened) handshake window → close `4400`; a `p` + outside the supported range → `err protocol_mismatch` + close `4400` on + the first bad frame (before any seat is taken); +* **the origin check** — an explicit `allowed_origins` list that the + request's `Origin` misses is refused at the HTTP level (the client + sees a 403, never a close frame); the same-host rule (the empty + setting) admits the matching `Origin`; +* **one socket per token** — a second `hello` for a token evicts the + first (`err evicted` + close `4403`), the first stops receiving, and + the *evicted* socket's teardown does not mark the seat disconnected + (the new socket owns it — the crown stays put); +* **the size/rate guardrails** — an oversized frame is `too_big` and + three consecutive refusals close `4404`; a message flood is + `rate_limited` and three consecutive refusals close `4404`; five + shape violations in the handshake window close `4404`; a + `wrong_state` / `unknown_type` frame is refused with the named code + and the connection stays open; +* **the disconnect** — an abrupt transport close (the client's socket + vanishes without a close handshake) reaches the room as exactly one + `mark_disconnected` (the seat is kept for the rejoin path), releases + the registry slot, and leaves no error in the server log. + +Note on the "abrupt close" mechanics: the installed `websockets` (17.2) +client has no `close(transport=True)` keyword, so the test drives the +abrupt path through `ws.transport.close()` — the same primitive. A bare +FIN is enough: the server's transport sees the EOF, uvicorn closes the +connection, and the endpoint's `finally` reports the disconnect. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections.abc import Callable +from typing import Any + +import httpx +import pytest +import websockets +from websockets.exceptions import ConnectionClosed, InvalidHandshake + +pytestmark = pytest.mark.integration + +HELLO_TIMEOUT_S = 0.5 # the shortened window, set via settings (task 02) + + +# --------------------------------------------------------------------- helpers + + +def _uri(url: str, code: str) -> str: + return url.replace("http://", "ws://", 1) + f"/ws/{code}" + + +async def _connect( + url: str, + code: str, + token: str | None = None, + *, + p: int = 1, + name: str | None = None, + origin: str | None = None, +) -> Any: + """A connected client with a `hello` already sent (the handshake is + the point under test, so the hello goes out immediately).""" + kwargs: dict[str, Any] = {"open_timeout": 10.0} + if origin is not None: + kwargs["origin"] = origin + ws = await websockets.connect(_uri(url, code), **kwargs) + if token is not None or name is not None or p != 1: + hello: dict[str, Any] = {"type": "hello", "p": p} + if token is not None: + hello["token"] = token + if name is not None: + hello["name"] = name + await ws.send(json.dumps(hello)) + return ws + + +async def _next_json(ws: Any) -> dict[str, Any]: + return json.loads(await ws.recv()) + + +async def _close_code(ws: Any) -> int: + with pytest.raises(ConnectionClosed) as excinfo: + await ws.recv() + return int(excinfo.value.code) + + +async def _drain_until_close( + ws: Any, max_frames: int, wait_s: float = 5.0 +) -> tuple[list[dict[str, Any]], int | None]: + """Read frames until the connection closes. + + A WebSocket client may hold *buffered* frames — the `too_big` / + `rate_limited` errors the server queued before the abuse close + arrive ahead of the close frame — so the close must be expected + after draining them, not on the very next read. Returns the frames + (in arrival order) and the close code (`None` if the window ran out + first, which the caller asserts against). + """ + frames: list[dict[str, Any]] = [] + close_code: int | None = None + for _ in range(max_frames): + try: + raw = await asyncio.wait_for(ws.recv(), wait_s) + except ConnectionClosed as exc: + close_code = int(exc.code) + break + frames.append(json.loads(raw)) + return frames, close_code + + +async def _poll(predicate: Callable[[], bool], what: str, wait_s: float = 5.0) -> None: + """Poll across threads (the server's loop does the work; this thread + only observes it).""" + loop = asyncio.get_running_loop() + deadline = loop.time() + wait_s + while not predicate(): + if loop.time() > deadline: + pytest.fail(f"timed out waiting for {what}") + await asyncio.sleep(0.01) + + +def _create_room(url: str, token: str | None = None) -> tuple[str, str | None]: + """`POST /api/rooms` over the live server; returns (code, you.token).""" + body: dict[str, Any] = {} + if token is not None: + body["token"] = token + response = httpx.post(f"{url}/api/rooms", json=body, timeout=5.0) + assert response.status_code == 201, response.text + data = response.json() + return data["room_code"], (data["you"] or {}).get("token") + + +def _join(url: str, code: str, token: str) -> None: + response = httpx.post( + f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0 + ) + assert response.status_code == 200, response.text + + +# ---------------------------------------------------------------- the happy path + + +async def test_happy_handshake_welcome_and_lobby(ws_server: Any) -> None: + with ws_server() as (url, _app): + code, you_echo = _create_room(url, "tok-creator") + assert you_echo == "tok-creator" + async with await _connect(url, code, "tok-joiner", name="Joiner") as ws: + welcome = await _next_json(ws) + assert welcome["type"] == "welcome" + assert welcome["p"] == 1 + assert welcome["room"] == code + assert welcome["state"] == "lobby" + assert welcome["you"] == { + "token": "tok-joiner", + "seat": 1, + "host": False, + } + seats = welcome["seats"] + assert len(seats) == 2 + assert seats[0]["index"] == 0 + assert seats[0]["host"] is True + assert seats[1]["index"] == 1 + assert seats[1]["name"] == "Joiner" + assert seats[1]["host"] is False + lobby = await _next_json(ws) + assert lobby["type"] == "lobby" + assert lobby["code"] == code + assert lobby["state"] == "lobby" + assert lobby["host_seat"] == 0 + # The fixture campaign is level-free, so `can_start` is False + # (the start preconditions — level selected, ready players — are + # covered by the phase-05 lifecycle suite over real levels). + assert lobby["can_start"] is False + # The ready-up: a toggle that re-broadcasts the lobby. + await ws.send(json.dumps({"type": "ready"})) + lobby2 = await _next_json(ws) + assert lobby2["can_start"] is False # still: no level selected + assert lobby2["seats"][1]["ready"] is True + assert len(lobby2["seats"]) == 2 + + +async def test_ping_before_hello_gets_pong(ws_server: Any) -> None: + with ws_server() as (url, _app): + code, _ = _create_room(url) + ws = await websockets.connect(_uri(url, code), open_timeout=10.0) + await ws.send(json.dumps({"type": "ping", "t": 7})) + pong = await _next_json(ws) + assert pong["type"] == "pong" + assert pong["t"] == 7 + await ws.send(json.dumps({"type": "hello", "p": 1})) + welcome = await _next_json(ws) + assert welcome["type"] == "welcome" + await ws.close() + + +# ---------------------------------------------------------------- refusal closes + + +async def test_unknown_code_closes_4401(ws_server: Any) -> None: + with ws_server() as (url, _app): + ws = await websockets.connect(_uri(url, "ZZZZZ"), open_timeout=10.0) + err = await _next_json(ws) + assert err["type"] == "err" + assert err["code"] == "room_gone" + assert await _close_code(ws) == 4401 + + +async def test_full_room_new_token_closes_4402(ws_server: Any) -> None: + with ws_server(max_players=2) as (url, _app): + code, _ = _create_room(url, "tok-a") + _join(url, code, "tok-b") # the second (and last) seat + async with await _connect(url, code, "tok-c") as ws: + err = await _next_json(ws) + assert err["type"] == "err" + assert err["code"] == "room_full" + assert await _close_code(ws) == 4402 + + +async def test_no_hello_within_timeout_closes_4400(ws_server: Any) -> None: + with ws_server(hello_timeout_s=HELLO_TIMEOUT_S) as (url, _app): + code, _ = _create_room(url) + ws = await websockets.connect(_uri(url, code), open_timeout=10.0) + # No hello at all: the shortened window closes the socket. + assert await _close_code(ws) == 4400 + + +async def test_wrong_protocol_version_closes_4400(ws_server: Any) -> None: + with ws_server() as (url, _app): + code, _ = _create_room(url) + async with await _connect(url, code, "tok-x", p=99) as ws: + err = await _next_json(ws) + assert err["type"] == "err" + assert err["code"] == "protocol_mismatch" + assert await _close_code(ws) == 4400 + # No seat was taken on the refused version. + room = _app.state.supervisor.get(code) + assert len(room.seats) == 1 + + +# ---------------------------------------------------------------- the origin check + + +async def test_disallowed_origin_refused_at_http_level(ws_server: Any) -> None: + with ws_server(allowed_origins=("https://elsewhere.example",)) as (url, _app): + code, _ = _create_room(url) + # The client's Origin (http://127.0.0.1:) is not in the + # explicit list: the upgrade is refused before the accept — the + # client sees a 403 handshake failure, never a close frame. + with pytest.raises(InvalidHandshake): + await websockets.connect(_uri(url, code), open_timeout=10.0) + + +async def test_same_host_origin_allowed(ws_server: Any) -> None: + with ws_server() as (url, _app): + code, _ = _create_room(url) + origin = "http://" + url.removeprefix("http://") + async with await _connect(url, code, "tok-orig", origin=origin) as ws: + welcome = await _next_json(ws) + assert welcome["type"] == "welcome" + + +# ------------------------------------------------- one socket per token (eviction) + + +async def test_second_socket_same_token_evicts_first(ws_server: Any) -> None: + with ws_server() as (url, app): + code, _ = _create_room(url) + first = await _connect(url, code, "tok-shared") + welcome = await _next_json(first) + assert welcome["type"] == "welcome" + await _next_json(first) # the lobby + + second = await _connect(url, code, "tok-shared") + welcome2 = await _next_json(second) + assert welcome2["type"] == "welcome" + assert welcome2["you"]["seat"] == 1 # the creator holds seat 0 + + # The evicted socket: the named refusal, then the close. + err = await _next_json(first) + assert err["type"] == "err" + assert err["code"] == "evicted" + assert await _close_code(first) == 4403 + + # The evicted socket's teardown must not mark the seat + # disconnected (the new socket owns it now), and the crown stays + # with the creator (an eviction is not a host leave — no + # `transfer_host`). Checked before `second` closes: closing it + # is a genuine disconnect for the token. + room = app.state.supervisor.get(code) + seat = room.seat_for_token("tok-shared") + assert seat is not None + assert seat.connected is True + assert room.seats[0].host is True + await second.close() + + +async def test_evicted_socket_stops_receiving(ws_server: Any) -> None: + with ws_server() as (url, _app): + code, _ = _create_room(url) + first = await _connect(url, code, "tok-shared") + await _next_json(first) # welcome + await _next_json(first) # lobby + second = await _connect(url, code, "tok-shared") + await _next_json(second) # welcome + await _next_json(second) # lobby + await _next_json(first) # the evicted err + await _close_code(first) # 4403 (asserted in the other test; the + # point here is what happens *after* the close) + # The evicted socket is out of the broadcast: a ready-up from the + # second socket re-broadcasts the lobby to the second only. + await second.send(json.dumps({"type": "ready"})) + lobby = await _next_json(second) + assert lobby["type"] == "lobby" + assert any(seat["ready"] for seat in lobby["seats"]) + await second.close() + + +# ----------------------------------------------------------- size / rate guardrails + + +async def test_oversized_frame_refused_then_4404(ws_server: Any) -> None: + with ws_server() as (url, _app): + code, _ = _create_room(url) + async with await _connect(url, code, "tok-big") as ws: + await _next_json(ws) # welcome + await _next_json(ws) # lobby + oversized = json.dumps( + {"type": "ping", "t": 1, "pad": "x" * 5000} # > max_message_bytes + ) + for _ in range(3): # three consecutive refusals -> the abuse close + await ws.send(oversized) + frames, close_code = await _drain_until_close(ws, 8) + assert frames, "the oversized frames must be refused" + assert all( + frame["type"] == "err" and frame["code"] == "too_big" + for frame in frames + ), f"unexpected frames before the close: {frames}" + assert close_code == 4404 + + +async def test_flood_rate_limited_then_4404(ws_server: Any) -> None: + with ws_server(max_inputs_per_s=5) as (url, _app): + code, _ = _create_room(url) + async with await _connect(url, code, "tok-flood") as ws: + await _next_json(ws) # welcome + await _next_json(ws) # lobby + for index in range(8): # burst 5: the 6th..8th exhaust the bucket + await ws.send(json.dumps({"type": "ping", "t": index})) + frames, close_code = await _drain_until_close(ws, 16) + assert any( + frame.get("type") == "err" and frame["code"] == "rate_limited" + for frame in frames + ), f"the flood never tripped the token bucket: {frames}" + assert close_code == 4404 + + +async def test_shape_violations_in_handshake_window_close_4404(ws_server: Any) -> None: + with ws_server() as (url, _app): + code, _ = _create_room(url) + ws = await websockets.connect(_uri(url, code), open_timeout=10.0) + for _ in range(5): # five shape violations inside the window + await ws.send("{not json") + assert await _close_code(ws) == 4404 + + +async def test_wrong_state_refused_and_connection_stays(ws_server: Any) -> None: + with ws_server() as (url, _app): + code, _ = _create_room(url) + async with await _connect(url, code, "tok-state") as ws: + await _next_json(ws) # welcome + await _next_json(ws) # lobby + await ws.send( + json.dumps( + { + "type": "input", # legal in PLAYING, not in LOBBY + "seq": 1, + "move": {"x": 1.0, "y": 0.0}, + "act": False, + "face": None, + } + ) + ) + err = await _next_json(ws) + assert err["type"] == "err" + assert err["code"] == "wrong_state" + # One shape violation: the connection survives. + await ws.send(json.dumps({"type": "ping", "t": 3})) + pong = await _next_json(ws) + assert pong["type"] == "pong" + + +async def test_unknown_type_refused_and_connection_stays(ws_server: Any) -> None: + with ws_server() as (url, _app): + code, _ = _create_room(url) + async with await _connect(url, code, "tok-bogus") as ws: + await _next_json(ws) # welcome + await _next_json(ws) # lobby + await ws.send(json.dumps({"type": "teleport", "x": 1})) + err = await _next_json(ws) + assert err["type"] == "err" + assert err["code"] == "unknown_type" + await ws.send(json.dumps({"type": "ping", "t": 4})) + pong = await _next_json(ws) + assert pong["type"] == "pong" + + +# ------------------------------------------------------------------- the disconnect + + +async def test_abrupt_close_notifies_room_once_and_releases( + ws_server: Any, +) -> None: + with ws_server() as (url, app): + code, _ = _create_room(url, "tok-abrupt") + supervisor = app.state.supervisor + calls: list[tuple[str, str]] = [] + original = supervisor.mark_disconnected + + def counting(raw_code: str, token: str) -> None: + calls.append((raw_code, token)) + original(raw_code, token) + + supervisor.mark_disconnected = counting + records: list[logging.LogRecord] = [] + + class _Collector(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + collector = _Collector(level=logging.ERROR) + logging.getLogger().addHandler(collector) + try: + ws = await websockets.connect(_uri(url, code), open_timeout=10.0) + await ws.send(json.dumps({"type": "hello", "p": 1, "token": "tok-abrupt"})) + assert json.loads(await ws.recv())["type"] == "welcome" + room = supervisor.get(code) + assert room.seat_for_token("tok-abrupt").connected is True + ws.transport.close() # abrupt: the transport vanishes, no close handshake + # Both halves of the teardown (the `mark_disconnected` and the + # registry release) must be visible before the assertions — they + # run in the server's loop, this one in the test's. + await _poll( + lambda: room.seat_for_token("tok-abrupt").connected is False + and app.state.ws_registry.socket_count == 0, + "the seat to be marked disconnected and the socket released", + ) + finally: + supervisor.mark_disconnected = original + logging.getLogger().removeHandler(collector) + + # Exactly one on_disconnect — and for the right token. + assert calls == [(code, "tok-abrupt")] + # No exception in the server log: the drop is a clean no-op. + errors = [record for record in records if record.levelno >= logging.ERROR] + exceptions = [ + record for record in errors if record.exc_info is not None + ] + assert not exceptions, ( + "the server log carries an exception for the abrupt close:\n" + + "\n".join(repr(record.exc_info) for record in exceptions) + ) diff --git a/tests/integration/test_ws_input.py b/tests/integration/test_ws_input.py new file mode 100644 index 0000000..a79e44d --- /dev/null +++ b/tests/integration/test_ws_input.py @@ -0,0 +1,517 @@ +"""Integration: input ingestion over **real sockets** (phase 06, task 03; +US-04/US-06) — the `seq` discipline, the hostile-clamp invariants, the +seat rules and the per-tick queue, driven against a live `uvicorn.Server` +(the `ws_server` factory in `tests/integration/conftest.py`) with a +*playable* level (the shipped, already-validated `01_first_kitchen` in +`tests/fixtures/levels_playable/`, so a room can actually reach `PLAYING` +— the fixture campaign in `tests/fixtures/levels/` has no levels and a +room built from it cannot start a round). + +* **the `seq` discipline, end to end** — monotonic seqs are accepted + (`inputs_received` / `last_seq` advance); an equal seq is an idempotent + resend, silently dropped; a backwards seq is `err bad_field` *with the + frame's `seq`* + the room's `resync` event (`stale_seqs` counted, + `last_seq` never lowered); a jump of ≤ 3 ahead is tolerated silently; + a larger jump is accepted and counted in `input_gaps`; +* **the hostile clamp** — a `move` of `{x: 1000, y: 1000}` is a + `bad_field` at the wire (the `Vec2Msg`'s early, explicit half), and a + cook that *is* moving (fed a legitimate intent) never exceeds + `MAX_SPEED x 1.01` — per-tick distance `vel x 0.05` within + `MAX_SPEED x 0.05 x 1.01`; a `pos` field (the teleport attempt) is a + `bad_field` by `extra="forbid"`; a non-bool `act` is rejected upstream; +* **the seat rules** — a spectator's input is `err not_seated`; a + `DISCONNECTED` seat's input is silently ignored (no err, no stat); +* **the per-tick queue** — a 20-frame burst on a one-seat room never + leaves more than `4 x 1` frames queued (the capacity), every frame is + accounted (`inputs_received + inputs_dropped == 20` — the drops + themselves are proven deterministic in + `tests/unit/test_ws_input_path.py`), and the sim keeps stepping + through it. + +The sim-side half of the hostile-speed proof — `(1000, 1000)` driven +through `to_player_input` + `Room.queue_input` + `tick_once` for 20 +ticks — lives in `tests/unit/test_ws_input_path.py`, where it can be +measured tick-exact; here the live socket proves the wire layer's half. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from collections.abc import Callable +from pathlib import Path +from typing import Any + +import httpx +import pytest +import websockets + +from undercooked.server.room import Room, RoomState +from undercooked.sim.movement import MAX_SPEED + +pytestmark = pytest.mark.integration + +#: The playable campaign (the shipped level 1, validated by the content +#: gate at server boot) — a room built from it can start a round. +PLAYABLE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels_playable" + + +# --------------------------------------------------------------------- helpers + + +def _uri(url: str, code: str) -> str: + return url.replace("http://", "ws://", 1) + f"/ws/{code}" + + +async def _connect( + url: str, + code: str, + token: str | None = None, + *, + name: str | None = None, +) -> Any: + """A connected client with the `hello` already sent.""" + ws = await websockets.connect(_uri(url, code), open_timeout=10.0) + if token is not None or name is not None: + hello: dict[str, Any] = {"type": "hello", "p": 1} + if token is not None: + hello["token"] = token + if name is not None: + hello["name"] = name + await ws.send(json.dumps(hello)) + return ws + + +async def _next_json(ws: Any, wait_s: float = 10.0) -> dict[str, Any]: + return json.loads(await asyncio.wait_for(ws.recv(), wait_s)) + + +async def _next_non_countdown(ws: Any, wait_s: float = 10.0) -> dict[str, Any]: + """The next frame that is not part of the continuous `PLAYING` + stream (task 04: the 3 s countdown's `n=3,2,1` frames queue before + the first `PLAYING` frame, and the `snap` stream then flows + continuously — a `PLAYING` probe skips both).""" + while True: + frame = await _next_json(ws, wait_s) + if frame["type"] not in ("countdown", "snap"): + return frame + + +async def _wait_for( + predicate: Callable[[], bool], what: str, wait_s: float = 15.0 +) -> None: + """Poll across threads (the server's loop does the work; this thread + only observes it).""" + loop = asyncio.get_running_loop() + deadline = loop.time() + wait_s + while not predicate(): + if loop.time() > deadline: + pytest.fail(f"timed out waiting for {what}") + await asyncio.sleep(0.01) + + +def _create_room(url: str, token: str | None = None) -> str: + """`POST /api/rooms` over the live server; returns the code.""" + body: dict[str, Any] = {} + if token is not None: + body["token"] = token + response = httpx.post(f"{url}/api/rooms", json=body, timeout=5.0) + assert response.status_code == 201, response.text + return response.json()["room_code"] + + +def _join(url: str, code: str, token: str) -> None: + response = httpx.post( + f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0 + ) + assert response.status_code == 200, response.text + + +def _conn_for_token(app: Any, code: str, token: str) -> Any: + for conn in app.state.ws_registry.for_room(code): + if conn.token == token: + return conn + msg = f"no live connection for token {token!r} in room {code}" + raise AssertionError(msg) + + +async def _playing_room( + url: str, app: Any, host_token: str +) -> tuple[str, Room, Any]: + """Create the room, connect the host, start (solo host), and wait for + `PLAYING` (the 3 s real-time countdown on the 20 Hz tick task).""" + code = _create_room(url, host_token) + host = await _connect(url, code, host_token, name="Host") + await _next_json(host) # welcome + await _next_json(host) # lobby + await host.send(json.dumps({"type": "start"})) # solo host + room = app.state.supervisor.get(code) + assert room is not None + await _wait_for( + lambda: room.state is RoomState.PLAYING and room.sim_state is not None, + f"room {code} to reach PLAYING", + wait_s=20.0, + ) + return code, room, host + + +async def _send_input(ws: Any, seq: int, **extra: Any) -> None: + frame: dict[str, Any] = { + "type": "input", + "seq": seq, + "move": {"x": 1.0, "y": 0.0}, + "act": False, + "face": None, + } + frame.update(extra) + await ws.send(json.dumps(frame)) + + +# ------------------------------------------------------------ the seq discipline + + +async def test_seq_discipline_end_to_end(ws_server: Any) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + code, room, host = await _playing_room(url, app, "tok-seq") + stats = room.stats + + # Monotonic seqs are accepted. + for seq in (1, 2, 3): + await _send_input(host, seq) + await _wait_for( + lambda: stats()["inputs_received"] == 3, + "the three accepted inputs to be counted", + ) + conn = _conn_for_token(app, code, "tok-seq") + assert conn.last_seq == 3 + + # An equal seq is an idempotent resend: silently dropped. + await _send_input(host, 3) + await asyncio.sleep(0.3) + assert stats()["inputs_received"] == 3 + assert conn.last_seq == 3 + + # A backwards seq: `err bad_field` with the frame's `seq` + the + # room's `resync` event; `last_seq` is never lowered. + await _send_input(host, 1) + err = await _next_non_countdown(host) + assert err["type"] == "err" + assert err["code"] == "bad_field" + assert err["seq"] == 1 + await _wait_for( + lambda: stats()["stale_seqs"] == 1, + "the stale-seq refusal to be counted", + ) + assert conn.last_seq == 3 + await _wait_for( + lambda: any(event.kind == "resync" for event in room.events_since_snapshot), + "the resync event to be buffered", + ) + resync = next( + event + for event in room.events_since_snapshot + if event.kind == "resync" + ) + assert resync.payload == {"player_id": "tok-seq", "seq": 1} + + # A jump of ≤ 3 ahead is tolerated silently. + await _send_input(host, 6) + await _wait_for( + lambda: conn.last_seq == 6 and stats()["inputs_received"] == 4, + "the tolerated gap to be accepted", + ) + assert stats()["input_gaps"] == 0 + + # A larger jump is accepted and counted. + await _send_input(host, 60) + await _wait_for( + lambda: conn.last_seq == 60 and stats()["inputs_received"] == 5, + "the large gap to be accepted", + ) + assert stats()["input_gaps"] == 1 + + +# ------------------------------------------------------------ the hostile clamp + + +async def test_a_hostile_move_is_rejected_and_the_cook_stays_within_max_speed( + ws_server: Any, +) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + _, room, host = await _playing_room(url, app, "tok-hostile") + + # The wire's early, explicit half: magnitude > 1 is a `bad_field`, + # whatever the `seq` is (the parse fails before the seq check). + await _send_input(host, 999, move={"x": 1000.0, "y": 1000.0}, act=True) + err = await _next_non_countdown(host) + assert err["type"] == "err" + assert err["code"] == "bad_field" + + # The sim-side bound: feed a legitimate intent on a ~10 Hz feeder + # (the cook accelerates to the cap and stays there) while the + # hostile frame's effect — none — is what the bound must hold. + base = 1 + + # Feed slightly faster than the 50 ms tick cadence: a frame in + # every tick window (the room's substep guard carries a queued + # frame to the next substep-advancing tick, so at this cadence + # the cook has an intent on every tick and accelerates to the + # cap — a slower feed would let it brake between frames). + async def feed() -> None: + nonlocal base + for offset in range(24): + await _send_input(host, base + offset) + base += 1 + await asyncio.sleep(0.04) + + feeder = asyncio.create_task(feed()) + await asyncio.sleep(0.7) # acceleration window (30 tiles/s^2) + samples: list[float] = [] + for _ in range(5): + assert room.sim_state is not None + (player,) = room.sim_state.players + samples.append(player.vel.length) + await asyncio.sleep(0.1) + feeder.cancel() + with contextlib.suppress(asyncio.CancelledError): + await feeder + + bound = MAX_SPEED * 1.01 + assert samples, "no velocity samples were taken" + assert max(samples) <= bound, ( + f"the cook reached {max(samples):.3f} tiles/s; the bound is {bound:.3f}" + ) + # The bound was non-trivial: the cook was genuinely moving (within + # the cap) when it was measured — a stationary cook would prove + # nothing about a hostile `move`. + assert max(samples) >= 0.5 * MAX_SPEED, ( + f"the cook only reached {max(samples):.3f} tiles/s — the bound " + f"was not exercised (expected near {MAX_SPEED:.3f})" + ) + # Per-tick distance = vel x STEP_DT: the sweep can only apply + # *less* motion than the velocity would, so the distance bound + # follows from the velocity bound. + assert max(samples) * 0.05 <= MAX_SPEED * 0.05 * 1.01 + + +async def test_a_position_field_is_rejected_by_the_schema(ws_server: Any) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + code, room, host = await _playing_room(url, app, "tok-pos") + # The teleport attempt: a `pos` field in an `input` (there is no + # position field in the protocol; `extra="forbid"` rejects one). + await host.send( + json.dumps( + { + "type": "input", + "seq": 1, + "move": {"x": 1.0, "y": 0.0}, + "act": False, + "face": None, + "pos": {"x": 3.5, "y": 1.5}, + } + ) + ) + err = await _next_non_countdown(host) + assert err["type"] == "err" + assert err["code"] == "bad_field" + assert "pos" in err["msg"] # the extra field is named + # Nothing was accepted (and `last_seq` never moved on a refusal). + assert room.stats()["inputs_received"] == 0 + assert _conn_for_token(app, code, "tok-pos").last_seq == 0 + + +async def test_act_non_bool_is_rejected_upstream(ws_server: Any) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + _, room, host = await _playing_room(url, app, "tok-act") + await host.send( + json.dumps( + { + "type": "input", + "seq": 1, + "move": {"x": 1.0, "y": 0.0}, + "act": 1, # a JSON int, not a bool: the strict model refuses + "face": None, + } + ) + ) + err = await _next_non_countdown(host) + assert err["type"] == "err" + assert err["code"] == "bad_field" + assert room.stats()["inputs_received"] == 0 + + +async def test_face_is_derived_from_move_in_the_sim(ws_server: Any) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + _, room, host = await _playing_room(url, app, "tok-face") + # `face: null` + `move (0, -1)`: the facing is derived (up). + await _send_input(host, 1, move={"x": 0.0, "y": -1.0}) + await _wait_for( + lambda: room.sim_state is not None + and room.sim_state.players[0].facing == "up", + "the derived facing to reach the sim", + wait_s=10.0, + ) + # And the other way down, for symmetry. + await _send_input(host, 2, move={"x": 0.0, "y": 1.0}) + await _wait_for( + lambda: room.sim_state is not None + and room.sim_state.players[0].facing == "down", + "the derived facing to flip", + wait_s=10.0, + ) + + +# --------------------------------------------------------------- the seat rules + + +async def test_a_spectators_input_is_refused_not_seated(ws_server: Any) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + code, _, _ = await _playing_room(url, app, "tok-spect") + room = app.state.supervisor.get(code) + assert room is not None + # A late join (the room is past `LOBBY`): `tok-watch` becomes a + # spectator — its input is the documented `not_seated`. + watcher = await _connect(url, code, "tok-watch", name="Watcher") + await _next_json(watcher) # welcome + await _next_json(watcher) # lobby + await _send_input(watcher, 1) + err = await _next_json(watcher) + assert err["type"] == "err" + assert err["code"] == "not_seated" + assert room.stats()["inputs_received"] == 0 + + +async def test_a_disconnected_seats_input_is_silently_ignored(ws_server: Any) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + # Two players in before the start: the host starts, the ready-up + # is the non-host's. + code = _create_room(url, "tok-p1") + _join(url, code, "tok-p2") + p1 = await _connect(url, code, "tok-p1", name="P1") + await _next_json(p1) # welcome + await _next_json(p1) # lobby + p2 = await _connect(url, code, "tok-p2", name="P2") + await _next_json(p2) # welcome + await _next_json(p2) # lobby + # The documented keep-alive (phase 06 task 05): both clients + # ping periodically — under load the `PLAYING` wait below may + # outlast the 12 s heartbeat dead window, and a silent socket + # is a dead client (the server's close `1000` is correct + # behaviour, not a bug). + async def _keepalive() -> None: + n = 0 + while True: + await asyncio.sleep(2.0) + n += 1 + for socket in (p1, p2): + with contextlib.suppress(Exception): + await socket.send(json.dumps({"type": "ping", "t": n})) + + keepalive = asyncio.create_task(_keepalive()) + await p2.send(json.dumps({"type": "ready"})) + await p1.send(json.dumps({"type": "start"})) + room = app.state.supervisor.get(code) + assert room is not None + await _wait_for( + lambda: room.state is RoomState.PLAYING and room.sim_state is not None, + f"room {code} to reach PLAYING", + wait_s=20.0, + ) + # The seat's connection "drops" (the supervisor marks it — the + # socket stays open: this is the mark the endpoint's `finally` + # makes on a genuine drop, driven directly here). + app.state.supervisor.mark_disconnected(code, "tok-p2") + assert room.seat_for_token("tok-p2").connected is False + # The (still-open) socket's input is silently ignored: no err, no + # stat, `last_seq` untouched. (The `ready` re-broadcast is still + # in the socket's queue — drain it first: it predates the drop.) + # Bounded by design: a live room's `snap` stream never goes + # quiet while the round is `PLAYING` (this level's 150 s time + # limit outlives the test), so the pre-existing `ready` + # re-broadcast, the three `countdown` frames and the first + # snaps all land within a few seconds of the start — an 8 s + # window covers them with twice the margin, and it is the + # window the ignored input's (absent) `err` would have to + # arrive in. + await _send_input(p2, 1) + deadline = asyncio.get_running_loop().time() + 8.0 + while asyncio.get_running_loop().time() < deadline: + try: + pending = json.loads( + await asyncio.wait_for(p2.recv(), 0.5) + ) + except TimeoutError: + continue # a quiet half second is fine; the window bounds + assert pending["type"] in ( + "lobby", + "countdown", + "snap", + "pong", + ), ( + f"only the pre-existing `lobby` re-broadcast, the " + f"countdown frames, the keep-alive `pong`s and the " + f"`snap` stream may still be in flight; got {pending}" + ) + await asyncio.sleep(0.5) + assert room.stats()["inputs_received"] == 0 + assert room.stats()["stale_seqs"] == 0 + assert _conn_for_token(app, code, "tok-p2").last_seq == 0 + # And the silence held: no *refusal* frame arrived after the + # drain. The live socket keeps receiving `snap` frames (the + # dropped seat's cook is parked, not ejected — the stream + # continues for any live connection in the player audience); + # what must not come is the `err`. + saw_refusal: dict[str, Any] | None = None + deadline = asyncio.get_running_loop().time() + 3.0 + while asyncio.get_running_loop().time() < deadline: + try: + frame = json.loads(await asyncio.wait_for(p2.recv(), 1.0)) + except TimeoutError: + continue # the point: the refusal was *silent* + if frame["type"] == "err": + saw_refusal = frame + break + assert saw_refusal is None, ( + f"an unexpected err frame reached the ignored seat: {saw_refusal}" + ) + keepalive.cancel() + with contextlib.suppress(asyncio.CancelledError): + await keepalive + + +# ----------------------------------------------------------- the per-tick queue + + +async def test_a_burst_never_exceeds_the_capacity_and_the_sim_keeps_stepping( + ws_server: Any, +) -> None: + with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app): + _, room, host = await _playing_room(url, app, "tok-burst") + # One seat: the queue's capacity is `4 x 1`. A 20-frame burst (all + # within the token bucket's burst of 40) — with a single seat every + # queued entry is the sender's own, so an overflow evicts the + # sender's oldest (never a rejection) and *every* frame lands: + # `inputs_received` reaches 20 no matter how the 20 Hz tick task + # interleaves with the burst (the drop *mechanism* is proven + # deterministic in tests/unit/test_ws_input_path.py). + start_tick = room.sim_state.tick if room.sim_state is not None else 0 + for seq in range(1, 21): + await _send_input(host, seq) + for _ in range(6): + assert len(room._queued_inputs) <= 4, ( + f"the per-tick queue grew past its capacity " + f"(4 x 1 seat): {len(room._queued_inputs)} frames" + ) + await asyncio.sleep(0.03) + await _wait_for( + lambda: room.stats()["inputs_received"] == 20, + "every burst frame to be queued (one seat: evict, never reject)", + ) + # The sim kept stepping through the whole burst. + await _wait_for( + lambda: room.sim_state is not None + and room.sim_state.tick >= start_tick + 10, + "the sim to keep stepping (10+ ticks)", + wait_s=15.0, + ) diff --git a/tests/integration/test_ws_round_two_clients.py b/tests/integration/test_ws_round_two_clients.py new file mode 100644 index 0000000..ca2cc3d --- /dev/null +++ b/tests/integration/test_ws_round_two_clients.py @@ -0,0 +1,589 @@ +"""Integration: a complete round over **real sockets**, two clients +(phase 06 task 06; US-02/US-03/US-05) — the `live_server` / `ws_client` +fixtures from `tests/integration/conftest.py` drive a playable campaign +(`tests/fixtures/levels_round/`: the shipped level 1 verbatim in layout +and content, `target_score` 20 and `time_limit` 60 s so the scripted +first serve wins the round — one soup scores 44-66 by +`sim/scoring.py`, the shipped 150 s / 45 target would make the win +depend on a *second* serve's timing; the wire contract under test is +the same). + +The round, end to end, on the wire: + +* **the lobby** — A creates the room (HTTP, a known token), B joins + (HTTP), both connect and handshake (`welcome` + `lobby`); A (the + host) sends `set_level`, both send `ready`, then A sends `start`; +* **the countdown and the stream** — both receive `countdown` + `n = 3, 2, 1`, then 30 `snap` frames with non-decreasing `t` and ≥ 20 + distinct ticks (a substep-less wall tick — the 20 Hz cadence jitters + 48-51 ms — re-encodes the same sim tick, so "increasing" means that, + not *strictly* increasing, and the 30-frame window carries the + re-encode tolerance); +* **the inputs** — A drives the scripted soup (walk → shelf pick → + board hold → stove hold → prep hold → serve press; one rising-edge + press per verb, holds until the view's station reads `ready`), B + drives a walking loop; both clients' merged snapshot views carry the + state; +* **the `SERVED` event and the score** — both observe the same + `served` event (the payload's `oi` / `pts` / `q` / `stk`) and the + same final `score` in the `round` frame; +* **the `round` frame** — the round ends `WON` on the serve's + substep; both receive `round` and the two payloads are JSON-equal + (byte-identical under the canonical dump: the only + viewer-specific field, `unlocked`, is `None` for both — a win on + the campaign's single level unlocks nothing); +* **the lobby return** — after `round`, A's `to_lobby` returns both to + `LOBBY` with the seats preserved (two seats, the ready flags reset, + the crown on A). + +The choreography is feedback-driven off the merged view (positions are +one snapshot stale — ≤ 100 ms at the 20 Hz stream — so every station +visit settles: wait for the view to confirm arrival, hold the action +while the station works, release, one fresh press for the pick). The +staging points are the cells *directly* adjacent to each station, on +the axis the approach comes in on (the reach zone's discrete half: +the front cell, the two diagonal-front cells, the tile under the cook +— a station whose cell is not in that set is not a candidate at all). +""" + +from __future__ import annotations + +import asyncio +import contextlib +import itertools +import json +import math +from pathlib import Path +from typing import Any + +import httpx +import pytest + +pytestmark = pytest.mark.integration + +#: The playable campaign (the shipped level 1, layout-verbatim, +#: `target_score` 20 / `time_limit` 60 s — the module docstring's +#: tuning): a room built from it can start a round and the first +#: serve wins it. +ROUND_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels_round" + +# The level 1 geometry (grid cell (col, row); the wire's position is in +# tile units, a cell (x, y) centre is (x + 0.5, y + 0.5). +SHELF_STAGE = (2.5, 1.5) # cell (2, 1), facing LEFT at the shelf (1, 1) +BOARD_STAGE = (3.5, 1.5) # cell (3, 1), facing RIGHT at the board (4, 1) +STOVE_STAGE = (7.5, 1.5) # cell (7, 1), facing RIGHT at the stove (8, 1) +PREP_STAGE = (3.5, 3.5) # cell (3, 3), facing LEFT at the prep (2, 3) +SERVE_STAGE = (9.5, 3.5) # the window's own cell (walkable, `PASS_THROUGH`) + +# ~16.7 Hz feed: a frame in (almost) every wall-tick window — the +# per-tick queue then carries an intent to every substep-advancing tick, +# so the cook's velocity holds near the cap instead of oscillating +# (a slower feed lets it brake between frames; the carried items rot +# with the time, and the serve's `quality` is what the win turns on). +INPUT_INTERVAL_S = 0.06 + + +# ---------------------------------------------------------------- helpers + + +def _input(seq: int, move: tuple[float, float] | None, act: bool) -> dict[str, Any]: + x, y = move if move is not None else (0.0, 0.0) + return { + "type": "input", + "seq": seq, + "move": {"x": x, "y": y}, + "act": act, + "face": None, + } + + +class _Sequencer: + """The client's monotonic `seq` counter (one per connection — the + seq discipline is per connection).""" + + def __init__(self) -> None: + self.next = 0 + + def __call__(self) -> int: + self.next += 1 + return self.next + + +async def _walk_to( + client: Any, + pid: str, + seq: _Sequencer, + target: tuple[float, float], + band: float, + *, + wait_s: float = 40.0, + settle: bool = False, +) -> None: + """Walk the view position to within `band` of `target`. + + With `settle` (the final waypoint) the arrival is then confirmed + *twice* — the second check after a 150 ms settle (a full snapshot + cycle, the view is ≤ 100 ms stale at 20 Hz) — with a zero `move` + so the derived facing keeps the last approach vector (aimed at the + target, which the staging points sit on the axis of) and the + deceleration of the last approach vector runs out in ~0.12 s. + """ + loop = asyncio.get_running_loop() + deadline = loop.time() + wait_s + while True: + player = client.view.player(pid) + assert player is not None, "the scripted cook vanished from the view" + dx = target[0] - player["x"] + dy = target[1] - player["y"] + distance = math.hypot(dx, dy) + if distance <= band: + if settle: + await client.send(_input(seq(), None, False)) + await asyncio.sleep(0.15) # the settle: the view catches up + player = client.view.player(pid) + assert player is not None + distance = math.hypot( + target[0] - player["x"], target[1] - player["y"] + ) + if distance <= band + 0.2: # the deceleration tail + return + continue # the settle was not enough: walk again + return # pass the waypoint immediately (the caller walks on — + # a stopped cook does not leave the band on its own, and a + # sleeping loop here would dead-lock the choreography) + assert loop.time() < deadline, f"timed out walking to {target}" + length = distance if distance > 1e-6 else 1.0 + await client.send(_input(seq(), (dx / length, dy / length), False)) + await asyncio.sleep(INPUT_INTERVAL_S) + + +async def _walk( + client: Any, + pid: str, + seq: _Sequencer, + waypoints: list[tuple[float, float]], + band: float = 0.4, + *, + wait_s: float = 40.0, +) -> None: + """Walk the waypoint list; the last one is the settled stop. + + The waypoints detour around the *blocking* fixtures (a body of + `2 x RADIUS` = 0.7 tiles cannot pass a station's cell — the sweep + stops it before it intrudes, and a body touching a blocking cell's + edge cannot move into it: the scripted path has to route around, + the same way a human cook would). The corridors keep ≥ 0.15 tile + between the body edge and every blocking cell. + """ + for target in waypoints[:-1]: + await _walk_to(client, pid, seq, target, 0.35, wait_s=wait_s) + await _walk_to( + client, pid, seq, waypoints[-1], band, wait_s=wait_s, settle=True + ) + + +async def _press(client: Any, seq: _Sequencer) -> None: + """One action press: a rising edge (an `act: true` frame after an + `act: false` one — the `action_gate`'s one-press-one-event), then + the release.""" + await client.send(_input(seq(), None, True)) + await asyncio.sleep(0.09) + await client.send(_input(seq(), None, False)) + + +async def _hold_until( + client: Any, seq: _Sequencer, predicate: Any, wait_s: float = 20.0 +) -> None: + """Hold the action down (the first frame is the edge — the caller + just walked with `act: false`) until `predicate()`; the station's + progress advances while the worker is in reach and holding + (`sim/stations.py` stage 4).""" + loop = asyncio.get_running_loop() + deadline = loop.time() + wait_s + while not predicate(): + assert loop.time() < deadline, "timed out holding the action" + await client.send(_input(seq(), None, True)) + await asyncio.sleep(INPUT_INTERVAL_S) + + +async def _wait_for(predicate: Any, what: str, wait_s: float = 20.0) -> None: + loop = asyncio.get_running_loop() + deadline = loop.time() + wait_s + while not predicate(): + if loop.time() > deadline: + pytest.fail(f"timed out waiting for {what}") + await asyncio.sleep(0.05) + + +def _station_ready(client: Any, kind: str) -> bool: + """The view's station of `kind` reads `ready` (the work is done).""" + station = client.view.station(kind) + return station is not None and station.get("s") == "ready" + + +def _carrying(client: Any, pid: str, short: str) -> bool: + """The view's player carries the item with the short form `short` + (e.g. `tomato_chopped`).""" + player = client.view.player(pid) + return player is not None and player.get("h") == short + + +def _create_room(url: str, token: str) -> str: + response = httpx.post(f"{url}/api/rooms", json={"token": token}, timeout=5.0) + assert response.status_code == 201, response.text + return response.json()["room_code"] + + +def _join(url: str, code: str, token: str) -> None: + response = httpx.post( + f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0 + ) + assert response.status_code == 200, response.text + + +async def _start_playing( + url: str, app: Any, ws_client: Any +) -> tuple[str, Any, Any, Any]: + """Two clients, the room, and the lobby, to `PLAYING` (the module + docstring's lobby half); returns `(code, room, a, b)`.""" + supervisor = app.state.supervisor + code = _create_room(url, "tok-a") + _join(url, code, "tok-b") + a = await ws_client(url, code, "tok-a", name="A") + b = await ws_client(url, code, "tok-b", name="B") + for client in (a, b): + welcome = await client.expect("welcome") + assert welcome["state"] == "lobby" + assert welcome["rejoined"] is False + lobby = await client.expect("lobby") + assert lobby["state"] == "lobby" + a.send_model({"type": "set_level", "level": "01_first_kitchen"}) + await _wait_for( + lambda: supervisor.get(code) is not None + and supervisor.get(code).level is not None, + "the room to hold the level pick", + ) + a.send_model({"type": "ready"}) + b.send_model({"type": "ready"}) + # The ready-ups arrive on *different* connections — the host's `start` + # must not race ahead of B's (a premature start is the named + # `no_ready_players` refusal). Wait for the room to see both. + room = supervisor.get(code) + assert room is not None + await _wait_for( + lambda: room.can_start, + "the room to see both ready-ups (can_start)", + wait_s=10.0, + ) + a.send_model({"type": "start"}) + await _wait_for( + lambda: room.state.value == "playing" and room.sim_state is not None, + "the room to reach PLAYING (the 3 s real-time countdown)", + wait_s=20.0, + ) + # Drain the lobby re-broadcasts (the `set_level` pick and the two + # ready-ups) so a later `expect("lobby")` cannot match a stale one — + # they arrived before the countdown frames, so the queue is in order. + for client in (a, b): + await client.collect("lobby", 3, wait_s=10.0) + return code, room, a, b + + +async def _drive_soup(client: Any, pid: str, seq: _Sequencer) -> None: + """The scripted soup: shelf → board → stove → prep → serve, one + fresh press per verb, holds until the view's station reads + `ready`.""" + # 1. The tomato, off the spawn shelf (the room seeded 8 of them). + # The detour: straight up column 2 runs into the prep's cell + # (blocking); the x = 3.5 corridor is clear of it (body edge + # 3.15 > 3.0) and of the board (3.85 < 4.0). + await _walk( + client, + pid, + seq, + [(3.5, 7.5), (3.5, 1.5), SHELF_STAGE], + 0.4, + ) + await _press(client, seq) + await _wait_for( + lambda: _carrying(client, pid, "tomato_raw"), + f"{pid} to pick the tomato", + wait_s=10.0, + ) + # 2. Chop it (0.9 s of work). + await _walk(client, pid, seq, [BOARD_STAGE], 0.4) + await _hold_until(client, seq, lambda: _station_ready(client, "chop_board")) + await client.send(_input(seq(), None, False)) # the release + await asyncio.sleep(0.09) + await _press(client, seq) + await _wait_for( + lambda: _carrying(client, pid, "tomato_chopped"), + f"{pid} to pick the chopped tomato", + wait_s=10.0, + ) + # 3. Cook it (6 s of work, the pot's turn). The board's cell blocks + # the row-1 corridor: the row-2 corridor is clear (body edge + # 5.15 > 5.0 clears the board; 7.85 < 8.0 clears the stove). + # The last leg comes in *west-to-east* along row 1: the reach + # zone's discrete half (the front cell, the two diagonal-front + # cells, the tile under the cook) puts the stove's cell (8, 1) + # in the candidate set only while the cook faces RIGHT. + await _walk( + client, + pid, + seq, + [(3.5, 2.5), (5.5, 2.5), (5.5, 1.5), STOVE_STAGE], + 0.4, + ) + await _hold_until( + client, seq, lambda: _station_ready(client, "stove"), wait_s=25.0 + ) + await client.send(_input(seq(), None, False)) + await asyncio.sleep(0.09) + await _press(client, seq) + await _wait_for( + lambda: _carrying(client, pid, "tomato_cooked"), + f"{pid} to pick the cooked tomato", + wait_s=10.0, + ) + # 4. Plate it (0.6 s of work, a plate out of the clean stock). + # The prep's cell blocks its own west side: the staging point is + # its *east* neighbour (body edge 3.15 > 3.0), reached along row 3. + await _walk( + client, + pid, + seq, + [(7.5, 3.5), PREP_STAGE], + 0.4, + ) + await _hold_until(client, seq, lambda: _station_ready(client, "prep")) + await client.send(_input(seq(), None, False)) + await asyncio.sleep(0.09) + await _press(client, seq) + await _wait_for( + lambda: _carrying(client, pid, "tomato_plated"), + f"{pid} to pick the plated dish", + wait_s=10.0, + ) + # 5. Serve it (the window is walkable: stand on it, one press). + await _walk(client, pid, seq, [SERVE_STAGE], 0.4) + await _press(client, seq) + + +async def _wander(client: Any, pid: str, seq: _Sequencer) -> None: + """B's input: a walking loop in rows 6-7 (A's route is the top + rows — the two cooks never share a tile; the loop's body edges + stay clear of the row-5 fixtures: at y = 6.5 the body edge is + 6.15 > 6.0).""" + waypoints: list[tuple[float, float]] = [ + (10.5, 7.5), # east along the bottom row + (10.5, 6.5), # north + (5.5, 6.5), # west + (5.5, 7.5), # south + ] + for target in waypoints: + await _walk_to(client, pid, seq, target, 0.3, wait_s=20.0) + await client.send(_input(seq(), None, False)) + + +async def _keepalive(clients: tuple[Any, ...]) -> None: + """The documented keep-alive (the 12 s heartbeat dead window): a + `ping` from every client every 2 s — a silent socket is a dead + client, and the round outlasts the window.""" + n = 0 + while True: + await asyncio.sleep(2.0) + n += 1 + for client in clients: + with contextlib.suppress(Exception): + client.send_model({"type": "ping", "t": n}) + + +# ---------------------------------------------------------------- the round + + +def _assert_snap_ticks(name: str, frames: list[dict[str, Any]]) -> None: + """One client's `snap` stream: non-decreasing `t` (a substep-less + wall tick re-encodes the same sim tick — "increasing" means + non-decreasing here) plus ≥ 20 *distinct* ticks (the stream + stalled if it stalled at one tick).""" + ticks = [frame["raw"]["t"] for frame in frames] + assert all( + later >= earlier for earlier, later in itertools.pairwise(ticks) + ), f"client {name}'s snap ticks went backwards: {ticks}" + assert len(set(ticks)) >= 20, ( + f"client {name} saw {len(set(ticks))} distinct ticks in " + f"{len(ticks)} frames (the stream stalled)" + ) + + +def _assert_lobby_return(name: str, lobby: dict[str, Any]) -> None: + """One client's `to_lobby` re-broadcast: the state is `lobby`, the + seats are preserved, the ready flags reset, the crown stays on A. + """ + assert lobby["state"] == "lobby", f"client {name} is not back in the lobby" + assert len(lobby["seats"]) == 2, ( + f"client {name}: the seats are not preserved" + ) + assert all(seat["ready"] is False for seat in lobby["seats"]), ( + f"client {name}: the ready flags must reset on the lobby return" + ) + assert lobby["host_seat"] == 0, ( + f"client {name}: the crown must stay on A" + ) + + +async def test_two_clients_play_a_complete_round( + live_server: Any, ws_client: Any +) -> None: + with live_server(levels_dir=str(ROUND_LEVELS_DIR)) as (url, app): + code, room, a, b = await _start_playing(url, app, ws_client) + seq_a, seq_b = _Sequencer(), _Sequencer() + + # --- the countdown 3 -> 1, then the snap stream + countdown_a = await a.collect("countdown", 3, wait_s=20.0) + countdown_b = await b.collect("countdown", 3, wait_s=20.0) + assert [frame["n"] for frame in countdown_a] == [3, 2, 1] + assert [frame["n"] for frame in countdown_b] == [3, 2, 1] + + # ≥ 20 `snap` frames with increasing `t`: a substep-less wall tick + # (a 20 Hz cadence jitters 48-51 ms) re-encodes the *same* sim + # tick, so "increasing" is non-decreasing plus ≥ 20 *distinct* + # ticks — 30 frames carry the tolerance for the re-encodes. + snaps_a = await a.collect("snap", 30, wait_s=30.0) + snaps_b = await b.collect("snap", 30, wait_s=30.0) + for name, frames in (("a", snaps_a), ("b", snaps_b)): + _assert_snap_ticks(name, frames) + + # --- the inputs: A the scripted soup, B the walking loop, both + # kept alive past the heartbeat dead window + keepalive = asyncio.create_task(_keepalive((a, b))) + soup = asyncio.create_task(_drive_soup(a, "tok-a", seq_a)) + try: + await _wander(b, "tok-b", seq_b) # B's inputs, while the soup runs + await soup + + # --- the `SERVED` event, both clients, the same payload + room = app.state.supervisor.get(code) + assert room is not None and room.sim_state is not None + await _wait_for( + lambda: a.view.event("served") is not None, + "A to see the `served` event", + wait_s=30.0, + ) + await _wait_for( + lambda: b.view.event("served") is not None, + "B to see the same `served` event", + wait_s=30.0, + ) + served_a = a.view.event("served") + served_b = b.view.event("served") + assert served_a is not None and served_b is not None + for field in ("oi", "pts", "q", "stk"): + assert served_a[field] == served_b[field], ( + f"the `served` events disagree on {field}: " + f"{served_a} vs {served_b}" + ) + assert served_a["pts"] >= room.sim_state.target_score, ( + f"the first serve ({served_a['pts']} pts) must cross the " + f"target ({room.sim_state.target_score}) — the round is " + "designed to win on this substep" + ) + + # --- the round ends; both see the same score and payload + round_a = await a.expect("round", wait_s=30.0) + round_b = await b.expect("round", wait_s=30.0) + assert round_a["result"] == "won" + assert round_a["score"] >= round_a["target"] >= 20 + assert round_b["result"] == "won" + # The same final score (the `round` frame carries it). + assert round_a["score"] == round_b["score"] + # JSON equality (the "byte-identical" under the canonical dump: + # the only viewer-specific field is `unlocked`, `None` for + # both — the win is on the campaign's single level). + assert round_a["unlocked"] is None + assert round_b["unlocked"] is None + assert round_a == round_b + assert json.dumps(round_a, sort_keys=True) == json.dumps( + round_b, sort_keys=True + ) + # The per-player contribution is there (two seats — A the + # soup's cook, B the wanderer). + assert len(round_a["players"]) == 2 + assert {p["id"] for p in round_a["players"]} == {"tok-a", "tok-b"} + assert round_a["counters"]["served"] == 1 + + # --- the lobby return: `to_lobby`, seats preserved + a.send_model({"type": "to_lobby"}) + # The server-side truth first: the transition happens in the + # dispatch, the re-broadcast right after — the frame may trail + # the state by a read pass. + await _wait_for( + lambda: room.state.value == "lobby", + "the room to take the to_lobby transition", + wait_s=15.0, + ) + lobby_a = await a.expect("lobby", wait_s=15.0) + lobby_b = await b.expect("lobby", wait_s=15.0) + for name, lobby in (("a", lobby_a), ("b", lobby_b)): + _assert_lobby_return(name, lobby) + assert room.state.value == "lobby" + assert {seat.token for seat in room.seats} == {"tok-a", "tok-b"} + finally: + keepalive.cancel() + with contextlib.suppress(asyncio.CancelledError): + await keepalive + + for client in (a, b): + await client.close() + + +async def test_a_third_connection_mid_round_is_a_spectator( + live_server: Any, ws_client: Any +) -> None: + with live_server(levels_dir=str(ROUND_LEVELS_DIR)) as (url, app): + code, room, a, b = await _start_playing(url, app, ws_client) + seq_a, seq_b = _Sequencer(), _Sequencer() + + await a.collect("countdown", 3, wait_s=20.0) + await b.collect("countdown", 3, wait_s=20.0) + + # --- C arrives mid-round (PLAYING — past the LOBBY join rule) + c = await ws_client(url, code, "tok-c", name="C") + welcome = await c.expect("welcome") + assert welcome["state"] == "playing" + assert welcome["you"]["host"] is False + assert welcome["you"]["seat"] is not None # the spectator index + assert len(welcome["seats"]) == 2 # the seats, unchanged + room = app.state.supervisor.get(code) + assert room is not None + assert room.spectator_for_token("tok-c") is not None + + # C receives snapshots (the spectator pass: half the player rate) + snap = await c.expect("snap", wait_s=20.0) + assert snap["raw"]["t"] >= 0 + + # C's input is the documented refusal (a spectator holds no seat) + c.send_model( + { + "type": "input", + "seq": 1, + "move": {"x": 1.0, "y": 0.0}, + "act": False, + "face": None, + } + ) + err = await c.expect("err", wait_s=10.0) + assert err["code"] == "not_seated" + assert room.stats()["inputs_received"] == 0 # nothing reached the sim + + # A and B keep driving inputs while C watches (the task's "both + # drive" — and the keep-alive, so the watchdog stays quiet). + for _ in range(10): + a.send_model(_input(seq_a(), (0.0, -1.0), False)) + b.send_model(_input(seq_b(), (0.0, 1.0), False)) + await asyncio.sleep(0.1) + + await c.close() + await a.close() + await b.close() diff --git a/tests/unit/test_docs_index.py b/tests/unit/test_docs_index.py index 9d8ca60..895d073 100644 --- a/tests/unit/test_docs_index.py +++ b/tests/unit/test_docs_index.py @@ -210,6 +210,7 @@ def test_the_docs_on_disk_are_the_shipped_documents() -> None: "docs/DESIGN.md", "docs/DEPLOYMENT.md", "docs/LEVELS.md", + "docs/PROTOCOL.md", "docs/ROOMS.md", "docs/SIMULATION.md", "docs/TESTING.md", diff --git a/tests/unit/test_encoding_no_leak.py b/tests/unit/test_encoding_no_leak.py new file mode 100644 index 0000000..082c589 --- /dev/null +++ b/tests/unit/test_encoding_no_leak.py @@ -0,0 +1,434 @@ +"""Unit: the snapshot encoder keeps the sim's internals off the wire +(phase 06, task 04) — the recursive no-leak guard, the 2-decimal +quantization, the explicit event-payload map, the delta tombstones, and +`snapshot_size`'s UTF-8 accounting. + +The no-leak rule (task 04): a frame contains no `token`, no +`display_name`, no `seed`, no station internal `id`, no `rng_state` — +checked recursively, on *values* as well as *keys* (a station's +internal id, `stove_1`, is a value, not a key). The sim's internal +identity is projected out by design: a station's wire identity is its +kind + coordinates (`k` + `x`/`y`), and an event's payload goes through +the explicit `EVENT_PAYLOAD_SHORT` map — a key that is not listed there +never leaves the server. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from dataclasses import replace +from typing import Any + +import pytest + +from undercooked.server.encoding import ( + EVENT_PAYLOAD_SHORT, + encode_events, + encode_snapshot, + event_wire_dict, + snapshot_size, +) +from undercooked.sim.grid import Grid +from undercooked.sim.model import ( + Event, + EventKind, + Facing, + Hazard, + HazardKind, + Item, + ItemForm, + ItemKind, + KitchenState, + Order, + Player, + PlayerState, + Station, + StationKind, + StationState, + TileKind, + Vec2, +) + +pytestmark = pytest.mark.unit + +#: The forbidden *key names* — the sim's internal fields, never on the +#: wire (task 04's no-leak list; `station` is the internal-id carrier +#: the explicit payload map exists to drop). +FORBIDDEN_KEYS: frozenset[str] = frozenset( + {"token", "display_name", "seed", "rng_state", "station"} +) + +#: The station/hazard internal ids (values — they must not appear +#: anywhere in a frame). +STOVE_ID = "stove_1" +CHOP_ID = "chop_board_1" +HARD_ID = "hz-fire-1" + +A_DISPLAY_NAME = "A very unique cook name" +A_SEED = 0x0BADF00D +A_RNG_STATE = 0xCAFEBABE +A_PLAYER_ID = "tok-abcdef0123456789" # the token value (allowed as `i`) + + +def make_state() -> KitchenState: + """A hand-built state with every internal field set to a unique + value: two stations (internal ids, contents, progress), one player + (token id, display name, unquantized position, a carried item), one + order, one hazard, a big `seed` and `rng_state`.""" + return KitchenState( + level_id="01_first_kitchen", + seed=A_SEED, + grid=Grid(width=8, height=8, tiles=(TileKind.FLOOR,) * 64), + tick=4821, + stations=( + Station( + id=STOVE_ID, + kind=StationKind.STOVE, + x=3.23456, + y=2.0, + contents=(Item(ItemKind.ONION, ItemForm.RAW, quality=0.98765),), + state=StationState.WORKING, + progress=0.4242, + ), + Station( + id=CHOP_ID, + kind=StationKind.CHOP_BOARD, + x=5.0, + y=7.0, + state=StationState.DIRTY, + dirty=True, + ), + ), + players=( + Player( + id=A_PLAYER_ID, + pos=Vec2(1.23456, -2.34567), + vel=Vec2(0.5, -0.5), + facing=Facing.RIGHT, + carrying=Item(ItemKind.TOMATO, ItemForm.CHOPPED, quality=0.98765), + state=PlayerState.MOVING, + seat=1, + display_name=A_DISPLAY_NAME, + ), + ), + orders=( + Order(id=7, recipe_id="soup", time_left=30.0, deadline=1200, value=40), + ), + order_deadline=1200, + hazards=( + Hazard( + id=HARD_ID, + kind=HazardKind.FIRE, + pos=Vec2(7.654321, 5.2), + life=12.346, + ), + ), + score=120, + target_score=140, + time_left=83.654, + rng_state=A_RNG_STATE, + ) + + +def walk(node: Any) -> Iterator[tuple[Any, Any]]: + """Every `(key, value)` pair in the JSON tree (keys only on objects).""" + if isinstance(node, dict): + for key, value in node.items(): + yield key, value + yield from walk(value) + elif isinstance(node, list): + for item in node: + yield from walk(item) + + +def assert_no_leak(frame: dict[str, Any]) -> None: + """The recursive no-leak check: no forbidden key, and no internal + value (a station/hazard id, the display name, the seed, the + rng_state) anywhere in the tree.""" + values: list[Any] = [] + for key, value in walk(frame): + assert key not in FORBIDDEN_KEYS, f"forbidden key on the wire: {key!r}" + values.append(value) + for forbidden in (STOVE_ID, CHOP_ID, HARD_ID, A_DISPLAY_NAME): + assert forbidden not in values, f"internal value on the wire: {forbidden!r}" + assert A_SEED not in values, "the round seed leaked onto the wire" + assert A_RNG_STATE not in values, "the rng_state leaked onto the wire" + + +# ------------------------------------------------------------- the no-leak guard + + +def test_no_forbidden_key_or_internal_value_reaches_the_wire() -> None: + assert_no_leak(encode_snapshot(make_state())) + + +def test_the_delta_frame_is_no_leakier_than_the_full() -> None: + state = make_state() + full = encode_snapshot(state) + moved = state.with_players( + (replace(state.players[0], pos=Vec2(4.321, 2.765), state=PlayerState.IDLE),) + ) + assert_no_leak(encode_snapshot(moved, full)) + + +def test_the_event_entries_carry_only_the_mapped_keys() -> None: + """An event whose payload carries the internal keys (`station`, + `tick`, an unknown one) is projected through the explicit map — the + unlisted keys (and the internal id they carry) never reach the + wire.""" + events = [ + Event( + kind=EventKind.RESYNC, + payload={"player_id": A_PLAYER_ID, "seq": 41}, + ), + Event( + kind=EventKind.DENIED, + x=3.23456, + y=2.0, + payload={ + "player_id": A_PLAYER_ID, + "reason": "contested", + "station": STOVE_ID, # the internal id: must not survive + "tick": 4821, # the frame's `t` is the tick: dropped + "mystery": "no-such-mapping", + }, + ), + ] + encoded = encode_events(events) + assert len(encoded) == 2 + allowed = {"k", "x", "y", *EVENT_PAYLOAD_SHORT.values()} + for entry in encoded: + for key, _ in walk(entry): + assert key in allowed, f"an unlisted event key reached the wire: {key!r}" + (denied,) = [entry for entry in encoded if entry["k"] == "denied"] + assert denied == {"k": "denied", "x": 3.23, "y": 2.0, "i": A_PLAYER_ID, "r": "contested"} + assert STOVE_ID not in [value for _, value in walk(encoded)] + + +def test_the_event_wire_dict_drops_unlisted_keys_the_same_way() -> None: + event = Event( + kind=EventKind.BURNT, + x=3.23456, + y=2.0, + payload={ + "station": STOVE_ID, + "item": "onion", + "form": "burnt", + "tick": 4821, + "mystery": 1, + }, + ) + wire = event_wire_dict(event) + assert wire["kind"] == "burnt" + assert wire["x"] == 3.23 + assert wire["payload"] == {"it": "onion", "fm": "burnt"} + + +# ----------------------------------------------------------- quantization + + +def test_quantization_is_to_two_decimals() -> None: + frame = encode_snapshot(make_state()) + (player,) = frame["p"] + (station,) = ( + entry for entry in frame["st"] if entry["k"] == "chop_board" + ) + (stove,) = (entry for entry in frame["st"] if entry["k"] == "stove") + (order,) = frame["o"] + (hazard,) = frame["hz"] + # The player's position (Vec2.quantized(2)). + assert (player["x"], player["y"]) == (1.23, -2.35) + # The station's position, progress and the content's quality. + assert (stove["x"], stove["y"]) == (3.23, 2.0) + assert stove["p"] == 0.42 + assert stove["q"] == 0.99 # round(0.98765, 2) + # The order's deadline share (30 s of a 60 s window) and the clock. + assert order["l"] == 0.5 + assert frame["tl"] == 83.65 # round(83.654, 2) + # The hazard's position and life (seconds, 2 decimals). + assert (hazard["x"], hazard["y"]) == (7.65, 5.2) + assert hazard["l"] == 12.35 + # The carried item's short form (the `h` field) and the station's + # first content (`g`). + assert player["h"] == "tomato_chopped" + assert stove["g"] == "onion_raw" + assert station["g"] is None + assert station["q"] == 1.0 # an empty station's quality + + +def test_facing_is_an_index_of_zero_to_three() -> None: + frame = encode_snapshot(make_state()) + (player,) = frame["p"] + assert player["f"] == 1 # Facing.RIGHT + for entry in frame["p"]: + assert entry["f"] in (0, 1, 2, 3) + + +def test_snapshot_size_is_the_utf8_length_of_the_frame_json() -> None: + frame = encode_snapshot(make_state()) + assert snapshot_size(frame) == len(json.dumps(frame).encode("utf-8")) + + +# ---------------------------------------------------------------- the delta + + +def test_removed_orders_and_hazards_are_tombstoned() -> None: + state = make_state() + full = encode_snapshot(state) + without = state.with_orders(()).with_hazards(()) + delta = encode_snapshot(without, full) + assert delta["full"] is False + (order_tombstone,) = [ + entry for entry in delta["d"]["o"] if entry.get("gone") is True + ] + assert order_tombstone == {"id": 7, "gone": True} + (hazard_tombstone,) = [ + entry for entry in delta["d"]["hz"] if entry.get("gone") is True + ] + assert hazard_tombstone == {"t": "fire", "x": 7.65, "y": 5.2, "gone": True} + + +def test_removed_players_and_stations_are_tombstoned_too() -> None: + """Every collection tombstones a vanished entity: a player (a cook + that left the round) and a station (a fixture that is gone) carry + their identity keys under `gone` so the client can drop them.""" + state = make_state() + full = encode_snapshot(state) + without = state.with_players(()).with_stations(()) + delta = encode_snapshot(without, full) + (player_tombstone,) = [ + entry for entry in delta["d"]["p"] if entry.get("gone") is True + ] + assert player_tombstone == {"i": A_PLAYER_ID, "gone": True} + station_tombstones = [ + entry for entry in delta["d"]["st"] if entry.get("gone") is True + ] + assert len(station_tombstones) == 2 + assert { + (entry["k"], entry["x"], entry["y"]) + for entry in station_tombstones + } == {("stove", 3.23, 2.0), ("chop_board", 5.0, 7.0)} + + +def test_a_malformed_prev_degrades_to_an_empty_prev() -> None: + """A `prev` whose entity collection is not a list (a client bug in + the stream, not a sim state) degrades to an empty previous: every + current entity is in, and no tombstone is invented.""" + state = make_state() + delta = encode_snapshot(state, prev={"p": {}, "st": "nope"}) + expected_player = { + "i": A_PLAYER_ID, + "x": 1.23, + "y": -2.35, + "f": 1, + "h": "tomato_chopped", + "s": "moving", + "pi": 1, + } + assert delta["d"]["p"] == [expected_player] + assert len(delta["d"]["st"]) == 2 + assert not any(entry.get("gone") for entry in delta["d"]["st"]) + + +def test_a_non_finite_or_null_payload_value_never_reaches_the_wire() -> None: + """`_short_value`'s guard: a `None` (and a `NaN`/`inf`) payload + value is dropped — a null or a `NaN` in a JSON frame is noise, not + information; a finite float is quantized to 2 decimals.""" + event = Event( + kind=EventKind.SERVED, + payload={ + "order_id": 7, + "points": float("nan"), + "quality": None, + "streak": float("inf"), + "delta": 12.346, + }, + ) + (entry,) = encode_events([event]) + assert entry == {"k": "served", "x": 0.0, "y": 0.0, "oi": 7, "d": 12.35} + (wire,) = [event_wire_dict(event)] + assert wire["payload"] == {"oi": 7, "d": 12.35} + + +def test_an_unchanged_entity_is_out_of_the_delta() -> None: + """The diff is against the *previous* frame (not the last full): an + entity unchanged since `prev` is out, and one that moved *back* to + its original values is in (the client holds the previous values — + the move back is a change it must hear about). Decoding the chain + (full → delta1) must land on `moved`'s full frame.""" + state = make_state() + full = encode_snapshot(state) + moved = state.with_players( + (replace(state.players[0], pos=Vec2(4.321, 2.765)),) + ) + delta1 = encode_snapshot(moved, full) + assert delta1["d"]["p"][0]["x"] == 4.32 # the move is in + # Unchanged since the previous frame: out. + delta2 = encode_snapshot(moved, delta1) + assert delta2["d"]["p"] == [] + # Back at the original values: in (a change since the previous frame). + delta3 = encode_snapshot(state, delta2) + (player,) = delta3["d"]["p"] + assert player["x"] == 1.23 and player["y"] == -2.35 + # And the chain decodes: applying delta1 to the full lands on + # `moved`'s full frame. + assert _apply_delta(full, delta1) == encode_snapshot(moved) + + +def _apply_delta(base: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]: + """The client's merge: upsert by identity key (preserving the base's + order, new entities appended — the encoder's own semantics), drop + the `gone` tombstones, take the scalars and `e` from the delta.""" + # The scalars (`t`, `s`, `tgt`, `tl`) and `e` ride the delta; the + # entity arrays merge onto the base. + decoded = { + key: value + for key, value in delta.items() + if key not in ("d", "full") + } + identity: dict[str, Any] = { + "p": lambda e: (e["i"],), + "st": lambda e: (e["k"], e["x"], e["y"]), + "o": lambda e: (e["id"],), + "hz": lambda e: (e["t"], e["x"], e["y"]), + } + for key in ("p", "st", "o", "hz"): + base_entries: list[dict[str, Any]] = base.get(key, []) + delta_entries: list[dict[str, Any]] = delta["d"][key] + by_identity = {identity[key](entry): dict(entry) for entry in delta_entries} + merged: list[dict[str, Any]] = [] + seen: set[tuple[Any, ...]] = set() + for entry in base_entries: + key_ = identity[key](entry) + if by_identity.get(key_, {}).get("gone") is True: + continue # tombstoned + merged.append(by_identity.get(key_, dict(entry))) + seen.add(key_) + for entry in delta_entries: + key_ = identity[key](entry) + if key_ not in seen and entry.get("gone") is not True: + merged.append(dict(entry)) + decoded[key] = merged + decoded["full"] = True # a decoded chain is a full state + return decoded + + +def test_the_delta_against_a_delta_prev_reads_the_prev_from_d() -> None: + """A delta frame's entities live under `d` — the next delta must + diff against *that* (a prev that is itself a delta).""" + state = make_state() + full = encode_snapshot(state) + moved = state.with_players( + (replace(state.players[0], pos=Vec2(4.321, 2.765)),) + ) + delta1 = encode_snapshot(moved, full) + moved_again = state.with_players( + (replace(state.players[0], pos=Vec2(4.5, 2.765)),) + ) + delta2 = encode_snapshot(moved_again, delta1) + (player,) = delta2["d"]["p"] + assert player["x"] == 4.5 # the change since delta1, not since the full + # `y` is unchanged since delta1, but the whole entry is in (it is a + # whole-entry diff): the entry carries both values. + assert player["y"] == 2.77 diff --git a/tests/unit/test_ghost_parking.py b/tests/unit/test_ghost_parking.py new file mode 100644 index 0000000..ceaaba1 --- /dev/null +++ b/tests/unit/test_ghost_parking.py @@ -0,0 +1,303 @@ +"""`sim/ghost.py` — the ghost's state and hands (phase 06 task 05, US-09). + +The wall-clock grace is the room's (measured off `seat.last_seen`); this file +pins the sim's half, pure and deterministic: + +* **`mark_ghost`** — the sim-side half of a drop: `connected=False`, the cook + in `DISCONNECTED`; idempotent (a second call changes nothing); +* **`restore_player`** — the sim-side half of the rejoin inside the grace: + `connected=True`, the cook in `IDLE`; a still-carried item is kept; + idempotent; +* **`release_ghost_carry`** — the park's release: the carried item goes onto + the station on the cook's tile (a marked set-down, one `DROPPED`) or into + `counters["lost_items"]` (one `GHOST_PARK`) on a bare tile; an item is never + deleted silently; idempotent (a double call cannot double-release); a + not-yet-marked (connected) cook is switched first — the forced release on + a `SeatAbandoned` when the grace ran out before the park deadline; +* **the ghost parks in the movement stage** — a disconnected cook's (stale) + input does not move it, and a stun that runs out on a disconnected cook + revives it in `DISCONNECTED`, not `IDLE`; +* **the events encode** — `ghost_on`/`ghost_off`/`ghost_park` ride the `k` + names with the short payload keys (`i`/`it`/`fm`), and the unlisted keys + never leave the server. +""" + +from __future__ import annotations + +import pytest + +from undercooked.server.encoding import encode_events +from undercooked.sim.carry import first_loose_entry +from undercooked.sim.ghost import ( + PARK_AFTER_S, + mark_ghost, + release_ghost_carry, + restore_player, + station_at, +) +from undercooked.sim.grid import Grid +from undercooked.sim.model import ( + Event, + EventKind, + Facing, + Item, + ItemForm, + ItemKind, + KitchenState, + Player, + PlayerState, + Station, + StationKind, + TileKind, + Vec2, +) +from undercooked.sim.movement import apply_stun, resolve_moves +from undercooked.sim.step import PlayerInput + +pytestmark = pytest.mark.unit + +WIDTH, HEIGHT = 7, 5 +#: The cook's cell — the station test's fixture sits on it. +CELL_X, CELL_Y = 3, 2 + + +def make_state( + stations: tuple[Station, ...] = (), + players: tuple[Player, ...] = (), +) -> KitchenState: + return KitchenState( + level_id="03_ghost", + seed=7, + grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)), + stations=stations, + players=players, + target_score=40, + time_left=90.0, + ) + + +def cook( + player_id: str = "p0", + *, + pos: Vec2 | None = None, + carrying: Item | None = None, + connected: bool = True, + state: PlayerState = PlayerState.IDLE, + stun: float = 0.0, + seat: int = 0, +) -> Player: + return Player( + id=player_id, + pos=pos if pos is not None else Vec2(CELL_X + 0.5, CELL_Y + 0.5), + facing=Facing.DOWN, + carrying=carrying, + connected=connected, + state=state, + stun=stun, + seat=seat, + ) + + +def tomato(form: ItemForm = ItemForm.RAW) -> Item: + return Item(kind=ItemKind.TOMATO, form=form) + + +def fixture(station_id: str, kind: StationKind = StationKind.PREP) -> Station: + return Station(id=station_id, kind=kind, x=float(CELL_X), y=float(CELL_Y)) + + +# --- the constant --------------------------------------------------------------- + + +def test_the_park_deadline_is_three_seconds() -> None: + assert PARK_AFTER_S == 3.0 + + +# --- mark / restore ------------------------------------------------------------- + + +def test_mark_ghost_switches_the_cook_and_is_idempotent() -> None: + state = make_state(players=(cook(),)) + once = mark_ghost(state, state.players[0]) + assert once.players[0].connected is False + assert once.players[0].state is PlayerState.DISCONNECTED + # Idempotent: a second call changes nothing (the event is the room's, + # so the sim must not double-apply the switch either). + twice = mark_ghost(once, once.players[0]) + assert twice is once + assert twice.players[0].connected is False + + +def test_mark_ghost_keeps_the_carry() -> None: + state = make_state(players=(cook(carrying=tomato()),)) + marked = mark_ghost(state, state.players[0]) + assert marked.players[0].carrying is not None # the item waits for the park + assert marked.players[0].state is PlayerState.DISCONNECTED + + +def test_restore_player_brings_the_cook_back_and_keeps_the_carry() -> None: + ghost = mark_ghost(make_state(players=(cook(carrying=tomato()),)), cook(carrying=tomato())) + player = ghost.players[0] + restored = restore_player(ghost, player) + assert restored.players[0].connected is True + assert restored.players[0].state is PlayerState.IDLE + assert restored.players[0].carrying is not None # the rejoin keeps the carry + # Idempotent: a second restore changes nothing. + again = restore_player(restored, restored.players[0]) + assert again is restored + + +# --- the release (the park / the forced release) -------------------------------- + + +def test_station_at_finds_the_station_on_the_tile_and_none_on_bare_floor() -> None: + shelf = fixture("prep_0", StationKind.PREP) + state = make_state(stations=(shelf,), players=(cook(),)) + assert station_at(state, state.players[0].pos) is shelf + bare = make_state(players=(cook(pos=Vec2(1.5, 1.5)),)) + assert station_at(bare, Vec2(1.5, 1.5)) is None + + +def test_release_parks_onto_the_station_with_a_marked_set_down() -> None: + shelf = fixture("prep_0", StationKind.PREP) + state = make_state(stations=(shelf,), players=(cook(carrying=tomato()),)) + state = mark_ghost(state, state.players[0]) + released, events = release_ghost_carry(state, state.players[0]) + assert released.players[0].carrying is None + station = released.stations[0] + assert station.contents == (tomato(),) + # A marked set-down: grabbable at once (the hand-off window applies — a + # released item can be passed). + entry = first_loose_entry(released, station) + assert entry is not None and entry[0] == tomato() + assert [e.kind for e in events] == [EventKind.DROPPED] + + +def test_release_parks_into_lost_items_on_a_bare_tile() -> None: + state = make_state(players=(cook(pos=Vec2(1.5, 1.5), carrying=tomato(ItemForm.CHOPPED)),)) + state = mark_ghost(state, state.players[0]) + released, events = release_ghost_carry(state, state.players[0]) + assert released.players[0].carrying is None + assert released.counters["lost_items"] == 1 + assert len(events) == 1 + event = events[0] + assert event.kind is EventKind.GHOST_PARK + assert event.payload == { + "player_id": "p0", + "item": "tomato", + "form": "chopped", + } + + +def test_release_is_idempotent_a_double_call_cannot_double_release() -> None: + shelf = fixture("prep_0", StationKind.PREP) + state = make_state(stations=(shelf,), players=(cook(carrying=tomato()),)) + state = mark_ghost(state, state.players[0]) + released, first = release_ghost_carry(state, state.players[0]) + assert len(first) == 1 + # Second call: the hand is empty, nothing to release — no events, no + # counter bump, the set-down untouched. + again, second = release_ghost_carry(released, released.players[0]) + assert second == () + assert released.counters.get("lost_items", 0) == 0 + assert again.stations[0].contents == (tomato(),) + # Same for the counter path: the tombstone is counted exactly once. + bare = make_state(players=(cook(pos=Vec2(1.5, 1.5), carrying=tomato()),)) + bare = mark_ghost(bare, bare.players[0]) + parked, _ = release_ghost_carry(bare, bare.players[0]) + again_bare, second_bare = release_ghost_carry(parked, parked.players[0]) + assert second_bare == () + assert again_bare.counters["lost_items"] == 1 + + +def test_release_of_a_connected_cook_switches_and_releases() -> None: + """The forced release on a `SeatAbandoned`: the seat is emptied before + the 3 s park deadline, and the item must release at once (the sim + switches the ghost first, then releases).""" + shelf = fixture("prep_0", StationKind.PREP) + state = make_state(stations=(shelf,), players=(cook(carrying=tomato()),)) + released, events = release_ghost_carry(state, state.players[0]) + assert released.players[0].connected is False + assert released.players[0].state is PlayerState.DISCONNECTED + assert released.players[0].carrying is None + assert released.stations[0].contents == (tomato(),) + assert [e.kind for e in events] == [EventKind.DROPPED] + + +def test_release_of_an_empty_hand_is_a_silent_noop() -> None: + state = make_state(players=(cook(pos=Vec2(1.5, 1.5)),)) + state = mark_ghost(state, state.players[0]) + released, events = release_ghost_carry(state, state.players[0]) + assert events == () + assert "lost_items" not in released.counters + + +# --- the ghost parks in the movement stage --------------------------------------- + + +def test_a_stun_that_runs_out_on_a_disconnected_cook_revives_it_disconnected() -> None: + players, _ = apply_stun((cook(connected=False, state=PlayerState.STUNNED, stun=0.05),), 0.05) + assert players[0].state is PlayerState.DISCONNECTED + assert players[0].connected is False + # A stun still running keeps the cook pinned (and stunned). + running, _ = apply_stun((cook(connected=False, state=PlayerState.STUNNED, stun=0.3),), 0.05) + assert running[0].state is PlayerState.STUNNED + assert running[0].stun == pytest.approx(0.25) + # A stun-zero STUNNED cook revives to `DISCONNECTED` as well, not `IDLE`. + zero, _ = apply_stun((cook(connected=False, state=PlayerState.STUNNED, stun=0.0),), 0.05) + assert zero[0].state is PlayerState.DISCONNECTED + # A connected cook still revives to `IDLE`. + alive, _ = apply_stun((cook(state=PlayerState.STUNNED, stun=0.05),), 0.05) + assert alive[0].state is PlayerState.IDLE + + +def test_a_disconnected_cook_parks_stale_input_does_not_move_it() -> None: + state = make_state(players=(cook(connected=False, state=PlayerState.DISCONNECTED),)) + before = state.players[0].pos + after, events = resolve_moves( + state, 0.05, {"p0": PlayerInput(player_id="p0", move=Vec2(1.0, 0.0))} + ) + assert after[0].pos == before # the ghost does not walk + assert after[0].vel.length == 0.0 + assert after[0].state is PlayerState.DISCONNECTED + assert events == () + # A connected cook on the same frame still moves. + state2 = make_state(players=(cook(state=PlayerState.IDLE),)) + after2, _ = resolve_moves( + state2, 0.05, {"p0": PlayerInput(player_id="p0", move=Vec2(1.0, 0.0))} + ) + assert after2[0].pos.x > state2.players[0].pos.x + assert after2[0].state is PlayerState.MOVING + + +# --- the events encode ----------------------------------------------------------- + + +def test_the_ghost_events_encode_with_their_k_names_and_short_payloads() -> None: + encoded = encode_events( + ( + Event( + kind=EventKind.GHOST_ON, + x=3.5, + y=2.5, + payload={"player_id": "p0"}, + ), + Event(kind=EventKind.GHOST_OFF, x=3.5, y=2.5, payload={"player_id": "p0"}), + Event( + kind=EventKind.GHOST_PARK, + x=1.5, + y=1.5, + payload={"player_id": "p0", "item": "tomato", "form": "chopped"}, + ), + ) + ) + assert [entry["k"] for entry in encoded] == ["ghost_on", "ghost_off", "ghost_park"] + assert encoded[0] == {"k": "ghost_on", "x": 3.5, "y": 2.5, "i": "p0"} + assert encoded[2] == { + "k": "ghost_park", + "x": 1.5, + "y": 1.5, + "i": "p0", + "it": "tomato", + "fm": "chopped", + } diff --git a/tests/unit/test_protocol_doc_sync.py b/tests/unit/test_protocol_doc_sync.py new file mode 100644 index 0000000..f8a3d67 --- /dev/null +++ b/tests/unit/test_protocol_doc_sync.py @@ -0,0 +1,247 @@ +"""Unit: `docs/PROTOCOL.md` and `protocol.py` cannot drift (phase 06, task 01). + +The phase's completion criteria demand that "every field in code appears in +the doc" — and, in the other direction, that the doc never names a field +that does not exist. This test is the guard: for **every** message model +in both directions (the nine client→server, the eight server→client, and +the six wire objects they nest) it extracts the field names from the doc's +``### ModelName`` table and compares them with the model's ``model_fields`` +— set-equal, in both directions. It also keeps the doc's error-code and +close-code tables equal to the `ErrorCode` / `CloseCode` constants. + +Doc convention (what this test relies on): every ``###`` heading in +``docs/PROTOCOL.md`` is exactly one model section (no other ``###`` +headings — use ``##`` for prose sections), and each section holds one +markdown table whose first column is the field name. So: an added field +that is not documented fails here, a documented field that is not in the +model fails here, a renamed model fails here, and a new error code without +a doc row fails here. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from pydantic import BaseModel + +from undercooked.server.protocol import ( + CloseCode, + CountdownMsg, + EmoteMsg, + ErrMsg, + ErrorCode, + EventWire, + EvMsg, + HelloMsg, + InputMsg, + LobbyMsg, + NextLevelMsg, + PingMsg, + PlayerScoreWire, + PongMsg, + ReadyMsg, + RoundMsg, + SeatWire, + SetLevelMsg, + SnapMsg, + SpectatorWire, + StartMsg, + ToLobbyMsg, + Vec2Msg, + WelcomeMsg, + YouWire, +) + +pytestmark = pytest.mark.unit + +DOCS = Path(__file__).resolve().parents[2] / "docs" / "PROTOCOL.md" + +#: Every message model in both directions, plus the wire objects they nest. +C_TO_S_MODELS: dict[str, type[BaseModel]] = { + "HelloMsg": HelloMsg, + "ReadyMsg": ReadyMsg, + "SetLevelMsg": SetLevelMsg, + "StartMsg": StartMsg, + "InputMsg": InputMsg, + "EmoteMsg": EmoteMsg, + "ToLobbyMsg": ToLobbyMsg, + "NextLevelMsg": NextLevelMsg, + "PingMsg": PingMsg, +} +S_TO_C_MODELS: dict[str, type[BaseModel]] = { + "WelcomeMsg": WelcomeMsg, + "LobbyMsg": LobbyMsg, + "CountdownMsg": CountdownMsg, + "SnapMsg": SnapMsg, + "RoundMsg": RoundMsg, + "EvMsg": EvMsg, + "ErrMsg": ErrMsg, + "PongMsg": PongMsg, +} +WIRE_MODELS: dict[str, type[BaseModel]] = { + "Vec2Msg": Vec2Msg, + "YouWire": YouWire, + "SeatWire": SeatWire, + "SpectatorWire": SpectatorWire, + "EventWire": EventWire, + "PlayerScoreWire": PlayerScoreWire, +} +ALL_MODELS: dict[str, type[BaseModel]] = { + **C_TO_S_MODELS, + **S_TO_C_MODELS, + **WIRE_MODELS, +} + +SECTION_HEADING = re.compile(r"^###\s+(\S+)\s*$") + + +def _clean(cell: str) -> str: + return cell.strip().strip("`").strip() + + +def extract_model_sections(text: str) -> dict[str, list[str]]: + """Field names (first table column) per ``###`` section. + + A section runs from its ``###`` heading to the next heading of any + level; inside it, only table rows count, and the header row (first cell + ``Field``) and the separator row (dashes/colons) are skipped. + """ + sections: dict[str, list[str]] = {} + current: str | None = None + for line in text.splitlines(): + heading = SECTION_HEADING.match(line) + if heading is not None: + name = heading.group(1) + sections.setdefault(name, []) + current = name + continue + if line.lstrip().startswith("#"): + current = None # any other heading ends the section + continue + if current is None: + continue + stripped = line.strip() + if not stripped.startswith("|"): + continue + cells = [_clean(cell) for cell in stripped.strip("|").split("|")] + if not cells or cells[0] in {"Field", ""}: + continue + if set(cells[0]) <= {"-", ":"}: + continue + sections[current].append(cells[0]) + return sections + + +def extract_section_first_cells(text: str, heading: str) -> list[str]: + """First table column of one ``##`` section (the code tables).""" + lines = text.splitlines() + try: + start = next(i for i, line in enumerate(lines) if line.strip() == heading) + except StopIteration: + pytest.fail(f"{heading!r} section not found in docs/PROTOCOL.md") + cells: list[str] = [] + for line in lines[start + 1 :]: + if line.lstrip().startswith("#"): + break + stripped = line.strip() + if not stripped.startswith("|"): + continue + first = _clean(stripped.strip("|").split("|")[0]) + if first in {"Code", ""} or set(first) <= {"-", ":"}: + continue + cells.append(first) + return cells + + +def test_the_doc_exists() -> None: + assert DOCS.is_file(), "docs/PROTOCOL.md is the phase deliverable — it must exist" + + +def test_documented_sections_are_exactly_the_model_registry() -> None: + sections = extract_model_sections(DOCS.read_text(encoding="utf-8")) + missing = set(ALL_MODELS) - set(sections) + extra = set(sections) - set(ALL_MODELS) + assert not missing, f"models with no ### section in the doc: {sorted(missing)}" + assert not extra, ( + f"### sections that are not model sections (doc convention): {sorted(extra)}" + ) + + +@pytest.mark.parametrize("name", sorted(ALL_MODELS)) +def test_the_doc_fields_match_the_model_fields_in_both_directions(name: str) -> None: + model = ALL_MODELS[name] + sections = extract_model_sections(DOCS.read_text(encoding="utf-8")) + assert name in sections, f"no ### {name} section in docs/PROTOCOL.md" + documented = set(sections[name]) + implemented = set(model.model_fields) + undocumented = implemented - documented + ghost = documented - implemented + assert not undocumented, ( + f"{name}: fields in the model but missing from the doc: {sorted(undocumented)}" + ) + assert not ghost, ( + f"{name}: fields documented but not in the model: {sorted(ghost)}" + ) + + +def test_both_directions_are_covered_by_the_registry() -> None: + assert len(C_TO_S_MODELS) == 9 + assert len(S_TO_C_MODELS) == 8 + assert len(WIRE_MODELS) == 6 + assert set(ALL_MODELS) == set(C_TO_S_MODELS) | set(S_TO_C_MODELS) | set(WIRE_MODELS) + + +def test_the_error_code_table_matches_the_constants() -> None: + cells = extract_section_first_cells(DOCS.read_text(encoding="utf-8"), "## Error codes") + assert len(cells) == len(set(cells)), "duplicate rows in the error-code table" + documented = set(cells) + implemented = { + ErrorCode.UNKNOWN_TYPE, + ErrorCode.BAD_JSON, + ErrorCode.BAD_FIELD, + ErrorCode.TOO_BIG, + ErrorCode.RATE_LIMITED, + ErrorCode.NOT_SEATED, + ErrorCode.NOT_HOST, + ErrorCode.LEVEL_LOCKED, + ErrorCode.WRONG_STATE, + ErrorCode.ROOM_FULL, + ErrorCode.ROOM_GONE, + ErrorCode.PROTOCOL_MISMATCH, + ErrorCode.EVICTED, + ErrorCode.INTERNAL, + } + assert documented == implemented, ( + f"error-code table drift: missing {sorted(implemented - documented)}, " + f"ghosts {sorted(documented - implemented)}" + ) + + +def test_the_close_code_table_matches_the_constants() -> None: + cells = extract_section_first_cells(DOCS.read_text(encoding="utf-8"), "## Close codes") + documented = {int(cell) for cell in cells} + implemented = { + CloseCode.NORMAL, + CloseCode.PROTOCOL_MISMATCH, + CloseCode.ROOM_GONE, + CloseCode.ROOM_FULL, + CloseCode.EVICTED, + CloseCode.ABUSE, + } + assert documented == implemented, ( + f"close-code table drift: missing {sorted(implemented - documented)}, " + f"ghosts {sorted(documented - implemented)}" + ) + + +def test_the_whole_contract_round_trips_through_the_models() -> None: + """Every model here is a real Pydantic model with a `type` discriminator + (C→S) or a defaulted one (S→C) — a smoke over the registry itself.""" + for name, model in ALL_MODELS.items(): + assert isinstance(model, type) + assert issubclass(model, BaseModel) + fields = set(model.model_fields) + if name in C_TO_S_MODELS: + assert "type" in fields, f"{name} has no discriminator" diff --git a/tests/unit/test_protocol_parse.py b/tests/unit/test_protocol_parse.py new file mode 100644 index 0000000..4e315e4 --- /dev/null +++ b/tests/unit/test_protocol_parse.py @@ -0,0 +1,380 @@ +"""Unit: the wire contract's accept/reject surface (phase 06, task 01). + +`parse_client_message` is the whole *shape* check — JSON decode, +discriminator dispatch, per-model field validation, the `hello` `p` gate — +and it must never raise: every bad frame comes back as a `ProtocolError` +with a stable `code`. This table pins the whole surface: the nine message +types parse (including the boundary `move` magnitudes), and every documented +bad shape yields exactly its documented `code` (`bad_json`, `unknown_type`, +`bad_field`, `protocol_mismatch`). + +Also pinned here: the `ErrorCode` / `CloseCode` constant sets (a new code +must be documented before it exists), `ProtocolError.to_err`, and the +server→client models' wire shape (every model encodes with its exact +`model_fields` and the right `type` / `p`). +""" + +from __future__ import annotations + +import pytest +from pydantic import BaseModel + +from undercooked.server.protocol import ( + MIN_SUPPORTED_VERSION, + PROTOCOL_VERSION, + ClientMessage, + CloseCode, + CountdownMsg, + EmoteMsg, + ErrMsg, + ErrorCode, + EventWire, + EvMsg, + HelloMsg, + InputMsg, + LobbyMsg, + NextLevelMsg, + PingMsg, + PlayerScoreWire, + PongMsg, + ProtocolError, + ReadyMsg, + RoundMsg, + SeatWire, + SetLevelMsg, + SnapMsg, + SpectatorWire, + StartMsg, + ToLobbyMsg, + Vec2Msg, + WelcomeMsg, + YouWire, + parse_client_message, + wire_dict, +) + +pytestmark = pytest.mark.unit + +#: A fixture session id (named to keep `S105`/`S106` quiet). +SESSION_ID = "tok-1" + + +def _error_of(result: ClientMessage | ProtocolError) -> ProtocolError | None: + return result if isinstance(result, ProtocolError) else None + + +# ------------------------------------------------------------------- accept + + +ACCEPT_CASES: list[tuple[bytes, type[BaseModel]]] = [ + (b'{"type":"hello","p":1}', HelloMsg), + (b'{"type":"hello","p":1,"token":"tok-1234","name":"Ada"}', HelloMsg), + (b'{"type":"ready"}', ReadyMsg), + (b'{"type":"set_level","level":"01_first_kitchen"}', SetLevelMsg), + (b'{"type":"start"}', StartMsg), + (b'{"type":"input","seq":0,"move":{"x":0.0,"y":0.0},"act":false,"face":null}', InputMsg), + (b'{"type":"input","seq":7,"move":{"x":0.6,"y":0.8},"act":true,"face":null}', InputMsg), + (b'{"type":"input","seq":8,"move":{"x":-1.0,"y":0.0},"act":false,"face":"up"}', InputMsg), + ( + b'{"type":"input","seq":9,"move":{"x":0.7,"y":0.7},"act":false,' + b'"face":"right"}', + InputMsg, + ), + (b'{"type":"emote","id":"wave"}', EmoteMsg), + (b'{"type":"to_lobby"}', ToLobbyMsg), + (b'{"type":"next_level"}', NextLevelMsg), + (b'{"type":"ping","t":0}', PingMsg), + (b'{"type":"ping","t":1234567}', PingMsg), +] + + +@pytest.mark.parametrize(("raw", "expected"), ACCEPT_CASES) +def test_every_message_type_parses(raw: bytes, expected: type[BaseModel]) -> None: + result = parse_client_message(raw) + assert not isinstance(result, ProtocolError) + assert type(result) is expected + + +def test_anonymous_hello_parses_and_carries_no_token() -> None: + result = parse_client_message(b'{"type":"hello","p":1}') + assert isinstance(result, HelloMsg) + assert result.token is None + assert result.name is None + + +def test_a_boundary_move_magnitude_is_accepted() -> None: + # hypot(0.6, 0.8) is 1.0 exactly: the boundary is inclusive. + result = parse_client_message( + b'{"type":"input","seq":1,"move":{"x":0.6,"y":0.8},"act":false,"face":null}' + ) + assert isinstance(result, InputMsg) + assert isinstance(result.move, Vec2Msg) + + +# ------------------------------------------------------------------- reject + + +REJECT_CASES: list[tuple[bytes, str]] = [ + # bad_json — not JSON at all. + (b"{", "bad_json"), + (b"", "bad_json"), + (b"[1, 2]", "bad_field"), + (b'"just a string"', "bad_field"), + (b"123.5", "bad_field"), + # bad_field — valid JSON, wrong shape. + (b"null", "bad_field"), + (b"{}", "bad_field"), + (b'{"type":5}', "bad_field"), + (b'{"type":"hello"}', "bad_field"), # p missing + (b'{"type":"hello","p":"1"}', "bad_field"), # p is a string + (b'{"type":"hello","p":3.5}', "bad_field"), # p is not an int + (b'{"type":"hello","p":1,"token":""}', "bad_field"), # token too short + (b'{"type":"hello","p":1,"token":"' + b"a" * 129 + b'"}', "bad_field"), + (b'{"type":"hello","p":1,"name":"' + b"x" * 65 + b'"}', "bad_field"), + (b'{"type":"input","seq":-1,"move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"), + (b'{"type":"input","seq":3.5,"move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"), + (b'{"type":"input","seq":"3","move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"), + (b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":"true","face":null}', "bad_field"), + (b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":1,"face":null}', "bad_field"), + (b'{"type":"input","seq":3,"move":{"x":50,"y":0},"act":false,"face":null}', "bad_field"), + (b'{"type":"input","seq":3,"move":{"x":1.01,"y":0},"act":false,"face":null}', "bad_field"), + (b'{"type":"input","seq":3,"move":{"x":1,"y":1},"act":false,"face":null}', "bad_field"), + (b'{"type":"input","seq":3,"move":{"x":"1","y":0},"act":false,"face":null}', "bad_field"), + (b'{"type":"input","seq":3,"move":{"x":NaN,"y":0},"act":false,"face":null}', "bad_field"), + (b'{"type":"input","seq":3,"move":{"x":0,"y":Infinity},"act":false,"face":null}', "bad_field"), + (b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,"face":"north"}', "bad_field"), + (b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false}', "bad_field"), # face missing + ( + b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,' + b'"face":null,"extra":1}', + "bad_field", + ), + ( + b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,' + b'"face":null,"pos":{"x":9}}', + "bad_field", + ), + (b'{"type":"ping","t":-1}', "bad_field"), + (b'{"type":"ping","t":"1"}', "bad_field"), + (b'{"type":"emote","id":""}', "bad_field"), + (b'{"type":"set_level","level":""}', "bad_field"), + (b'{"type":"set_level"}', "bad_field"), + # unknown_type — a `type` the server does not know. + (b'{"type":"bogus"}', "unknown_type"), + (b'{"type":"HELLO"}', "unknown_type"), # discriminators are case-sensitive + (b'{"type":"position","x":1}', "unknown_type"), + # protocol_mismatch — `p` outside [MIN_SUPPORTED_VERSION, PROTOCOL_VERSION]. + (b'{"type":"hello","p":0}', "protocol_mismatch"), + (b'{"type":"hello","p":2}', "protocol_mismatch"), + (b'{"type":"hello","p":99}', "protocol_mismatch"), +] + + +@pytest.mark.parametrize(("raw", "expected_code"), REJECT_CASES) +def test_bad_frames_yield_their_documented_code(raw: bytes, expected_code: str) -> None: + result = parse_client_message(raw) + error = _error_of(result) + assert error is not None, f"{raw!r} parsed into a message, expected {expected_code}" + assert error.code == expected_code + + +def test_parse_never_raises_over_the_whole_table() -> None: + for raw, _ in ACCEPT_CASES + REJECT_CASES: + try: + parse_client_message(raw) + except Exception as exc: + pytest.fail(f"parse_client_message raised {exc!r} for {raw!r}") + + +def test_parse_of_a_valid_ping_is_not_an_error() -> None: + result = parse_client_message(b'{"type":"ping","t":1}') + assert not isinstance(result, ProtocolError) + + +def test_the_task_criterion_truncated_frame_is_bad_json() -> None: + error = _error_of(parse_client_message(b"{")) + assert error is not None + assert error.code == "bad_json" + + +def test_unknown_type_error_names_the_offending_type() -> None: + error = _error_of(parse_client_message(b'{"type":"teleport"}')) + assert error is not None + assert "teleport" in error.detail + + +# ------------------------------------------------------------- error model + + +def test_protocol_error_to_err_builds_the_wire_frame() -> None: + error = ProtocolError(code="bad_field", detail="x: input should be a valid number") + err = error.to_err() + assert err.type == "err" + assert err.code == "bad_field" + assert err.msg == "x: input should be a valid number" + assert err.seq is None + with_seq = error.to_err(seq=42) + assert with_seq.seq == 42 + + +def test_protocol_error_without_detail_falls_back_to_the_code() -> None: + err = ProtocolError(code="wrong_state").to_err() + assert err.msg == "wrong_state" + + +def test_the_error_code_set_is_pinned() -> None: + expected = { + "unknown_type", + "bad_json", + "bad_field", + "too_big", + "rate_limited", + "not_seated", + "not_host", + "level_locked", + "wrong_state", + "room_full", + "room_gone", + "protocol_mismatch", + "evicted", + "internal", + } + codes = { + ErrorCode.UNKNOWN_TYPE, + ErrorCode.BAD_JSON, + ErrorCode.BAD_FIELD, + ErrorCode.TOO_BIG, + ErrorCode.RATE_LIMITED, + ErrorCode.NOT_SEATED, + ErrorCode.NOT_HOST, + ErrorCode.LEVEL_LOCKED, + ErrorCode.WRONG_STATE, + ErrorCode.ROOM_FULL, + ErrorCode.ROOM_GONE, + ErrorCode.PROTOCOL_MISMATCH, + ErrorCode.EVICTED, + ErrorCode.INTERNAL, + } + assert codes == expected + + +def test_the_close_code_set_is_pinned() -> None: + assert CloseCode.NORMAL == 1000 + assert CloseCode.PROTOCOL_MISMATCH == 4400 + assert CloseCode.ROOM_GONE == 4401 + assert CloseCode.ROOM_FULL == 4402 + assert CloseCode.EVICTED == 4403 + assert CloseCode.ABUSE == 4404 + + +# ------------------------------------------------------------ server → client + + +def test_the_protocol_version_constants() -> None: + assert PROTOCOL_VERSION == 1 + assert MIN_SUPPORTED_VERSION == 1 + + +def test_welcome_encodes_its_exact_fields() -> None: + seats = [ + SeatWire(index=0, name="Ada", color="#e5484d", ready=True, host=True, connected=True) + ] + msg = WelcomeMsg( + you=YouWire(token=SESSION_ID, seat=0, host=True), + room="ABCDE", + state="lobby", + level="01_first_kitchen", + seats=seats, + ) + dumped = wire_dict(msg) + assert set(dumped) == { + "type", + "p", + "you", + "room", + "state", + "level", + "seats", + "rejoined", + } + assert dumped["type"] == "welcome" + assert dumped["p"] == PROTOCOL_VERSION + assert dumped["you"] == {"token": SESSION_ID, "seat": 0, "host": True} + # A fresh join (the default) is not a rejoin (task 05: `rejoined` + # is `true` only when a `hello` reclaims a disconnected seat). + assert dumped["rejoined"] is False + + +def test_lobby_encodes_its_exact_fields() -> None: + msg = LobbyMsg( + code="ABCDE", + state="lobby", + level=None, + host_seat=0, + can_start=False, + seats=[], + spectators=[SpectatorWire(name="Mallory", color="#3b9eff")], + unlocked=1, + round_no=0, + ) + dumped = wire_dict(msg) + assert set(dumped) == { + "type", "p", "code", "state", "level", "host_seat", "can_start", + "seats", "spectators", "unlocked", "round_no", + } + assert dumped["type"] == "lobby" + + +def test_countdown_snap_round_ev_pong_encode_their_fields() -> None: + countdown = wire_dict(CountdownMsg(n=3)) + assert set(countdown) == {"type", "p", "n"} and countdown["n"] == 3 + + snap = wire_dict(SnapMsg(raw={"t": 4821, "p": []})) + assert set(snap) == {"type", "p", "raw"} + raw_body = snap["raw"] + assert isinstance(raw_body, dict) and raw_body["t"] == 4821 + + round_ = wire_dict( + RoundMsg( + result="won", + score=140, + target=140, + stars=3, + time_left=60.0, + counters={"served": 5, "missed": 0, "wasted": 0}, + longest_streak=5, + players=[ + PlayerScoreWire( + id="tok-1", name="Ada", score=140, dishes_served=5, missed_contrib=0 + ) + ], + level_id="01_first_kitchen", + seed=1234, + ticks=1200, + unlocked="02_double_shift", + ) + ) + assert set(round_) == { + "type", "p", "result", "score", "target", "stars", "time_left", "counters", + "longest_streak", "players", "level_id", "seed", "ticks", "unlocked", + } + + ev = wire_dict(EvMsg(events=[EventWire(kind="served", x=9.0, y=1.0, payload={"i": 17})])) + assert set(ev) == {"type", "p", "events"} + assert ev["events"] == [{"kind": "served", "x": 9.0, "y": 1.0, "payload": {"i": 17}}] + + pong = wire_dict(PongMsg(t=42, now=12.5)) + assert set(pong) == {"type", "p", "t", "now"} and pong["t"] == 42 + + +def test_err_msg_encodes_with_an_optional_seq() -> None: + dumped = wire_dict(ErrMsg(code="bad_field", msg="no", seq=7)) + assert set(dumped) == {"type", "p", "code", "msg", "seq"} and dumped["seq"] == 7 + anonymous = wire_dict(ErrMsg(code="wrong_state", msg="no")) + assert anonymous["seq"] is None + + +def test_event_wire_payload_defaults_to_empty() -> None: + event = EventWire(kind="chopped", x=3.0, y=2.0) + assert event.payload == {} + assert wire_dict(event) == {"kind": "chopped", "x": 3.0, "y": 2.0, "payload": {}} diff --git a/tests/unit/test_protocol_whitelist.py b/tests/unit/test_protocol_whitelist.py new file mode 100644 index 0000000..be8ce51 --- /dev/null +++ b/tests/unit/test_protocol_whitelist.py @@ -0,0 +1,94 @@ +"""Unit: `MESSAGE_WHITELIST` — totality and the pinned matrix (phase 06, task 01). + +The whitelist is the single source of "what is legal when" (PLAN §7 +"Accepted when"), imported by the endpoint and never duplicated. These tests +pin the whole matrix (a silent edit changes the wire contract) and assert +its *totality*: every room state is classified, every classification is a +known client type, and every client type is legal in at least one state — +so a new `ClientMsgType` that is never whitelisted (or a state that is +never classified) fails loudly. +""" + +from __future__ import annotations + +import pytest + +from undercooked.server.protocol import CLIENT_MSG_TYPES, MESSAGE_WHITELIST +from undercooked.server.room import RoomState + +pytestmark = pytest.mark.unit + +#: The pinned matrix — PLAN §7 "Accepted when", one row per room state. +EXPECTED_MATRIX: dict[RoomState, frozenset[str]] = { + RoomState.LOBBY: frozenset({"hello", "ready", "set_level", "start", "emote", "ping"}), + RoomState.COUNTDOWN: frozenset({"hello", "ping"}), + RoomState.PLAYING: frozenset({"hello", "input", "emote", "ping"}), + RoomState.ROUND_END: frozenset({"hello", "to_lobby", "next_level", "ping"}), + RoomState.CLOSED: frozenset({"hello", "ping"}), +} + + +def test_the_matrix_is_pinned() -> None: + actual = {state: frozenset(types) for state, types in MESSAGE_WHITELIST.items()} + assert actual == EXPECTED_MATRIX + + +def test_totality_every_room_state_is_classified() -> None: + assert set(MESSAGE_WHITELIST) == set(RoomState) + + +def test_totality_every_whitelisted_type_is_known() -> None: + for state, allowed in MESSAGE_WHITELIST.items(): + unknown = allowed - CLIENT_MSG_TYPES + assert not unknown, f"{state.value}: unknown types {sorted(unknown)}" + + +def test_totality_every_client_type_is_legal_somewhere() -> None: + union: set[str] = set() + for allowed in MESSAGE_WHITELIST.values(): + union |= set(allowed) + never = CLIENT_MSG_TYPES - union + assert not never, f"client types legal in no state at all: {sorted(never)}" + + +def test_hello_and_ping_are_legal_in_every_state() -> None: + for state, allowed in MESSAGE_WHITELIST.items(): + assert {"hello", "ping"} <= allowed, ( + f"{state.value}: hello (the rejoin handshake) and ping (the " + f"keep-alive) must be legal in every state" + ) + + +def test_input_is_playing_only() -> None: + assert "input" in MESSAGE_WHITELIST[RoomState.PLAYING] + for state in (RoomState.LOBBY, RoomState.COUNTDOWN, RoomState.ROUND_END, RoomState.CLOSED): + assert "input" not in MESSAGE_WHITELIST[state] + + +def test_the_lobby_verbs_are_lobby_only() -> None: + for verb in ("ready", "set_level", "start"): + assert verb in MESSAGE_WHITELIST[RoomState.LOBBY] + for state in (RoomState.COUNTDOWN, RoomState.PLAYING, RoomState.ROUND_END, RoomState.CLOSED): + for verb in ("ready", "set_level", "start"): + assert verb not in MESSAGE_WHITELIST[state] + + +def test_emote_is_lobby_and_playing_only() -> None: + assert "emote" in MESSAGE_WHITELIST[RoomState.LOBBY] + assert "emote" in MESSAGE_WHITELIST[RoomState.PLAYING] + for state in (RoomState.COUNTDOWN, RoomState.ROUND_END, RoomState.CLOSED): + assert "emote" not in MESSAGE_WHITELIST[state] + + +def test_the_round_end_verbs_are_round_end_only() -> None: + for verb in ("to_lobby", "next_level"): + assert verb in MESSAGE_WHITELIST[RoomState.ROUND_END] + for state in (RoomState.LOBBY, RoomState.COUNTDOWN, RoomState.PLAYING, RoomState.CLOSED): + for verb in ("to_lobby", "next_level"): + assert verb not in MESSAGE_WHITELIST[state] + + +def test_countdown_accepts_no_gameplay_verb() -> None: + # The 3 s countdown is a wall: only the handshake and the keep-alive get + # through (a late `ready` or `input` is `wrong_state`). + assert MESSAGE_WHITELIST[RoomState.COUNTDOWN] == {"hello", "ping"} diff --git a/tests/unit/test_send_queue_policy.py b/tests/unit/test_send_queue_policy.py new file mode 100644 index 0000000..cca29de --- /dev/null +++ b/tests/unit/test_send_queue_policy.py @@ -0,0 +1,344 @@ +"""Unit: the send-queue policy and the per-connection guardrail arithmetic +(phase 06 task 02) — the `Connection`'s queue is the only buffer between +the game loop and a slow reader, so it must be bounded and honest: + +* **snapshot droppability under a slow reader** — once the queue is at + `MAX_SEND_QUEUE` a new frame may only enter after the *oldest snapshot* + is dropped; `lobby` / `round` / `err` frames are never dropped and keep + their order, +* **the one-`rate_limited`-err bound** — a flood queues at most one + `rate_limited` `err` (the client is told once; the queue cannot grow + with the flood), +* **no unbounded growth** — the queue caps at `MAX_SEND_QUEUE` for the + dominant (snapshot) traffic; only the never-dropped frames can push it + past that, and only by their own count (game-bounded: one + `lobby` / `round` per state change, one coalesced `rate_limited`), +* **the token bucket** — `rate` tokens per second, a burst of `burst`, + refilled strictly off the injected `Clock` (never a wall clock), +* **the abuse tracker** — 3 *consecutive* rate/oversize violations inside + the 10 s window close `4404` (a clean frame resets the run); 5 shape + violations inside the window close it (a violation older than the + window does not count). +""" + +from __future__ import annotations + +import asyncio +import contextlib +import json +from typing import Any + +import pytest + +from undercooked.server.clock import FakeClock +from undercooked.server.protocol import ( + ErrMsg, + ErrorCode, + LobbyMsg, + RoundMsg, + SnapMsg, +) +from undercooked.server.ws import ( + ABUSE_WINDOW_S, + MAX_SEND_QUEUE, + AbuseTracker, + Connection, + TokenBucket, + pump_out, +) + +pytestmark = pytest.mark.unit + + +class _FakeWs: + """A `WsLike` that records what the pump wrote (no socket).""" + + def __init__(self) -> None: + self.sent: list[dict[str, Any]] = [] + + async def send_text(self, data: str) -> None: + self.sent.append(json.loads(data)) + + +def _conn(clock: FakeClock) -> tuple[Connection, _FakeWs]: + ws = _FakeWs() + return ( + Connection( + ws=ws, + token="tok-unit", # noqa: S106 - a test identity, not a credential + player_id="tok-unit", + seat_index=0, + role="player", + rate=TokenBucket(10.0, 10, clock), + ), + ws, + ) + + +def _snap(index: int) -> SnapMsg: + return SnapMsg(type="snap", raw={"t": index, "p": []}) + + +def _lobby(index: int) -> LobbyMsg: + return LobbyMsg( + type="lobby", + code=f"C{index}", + state="lobby", + can_start=False, + seats=[], + spectators=[], + ) + + +def _round() -> RoundMsg: + return RoundMsg( + type="round", + result="won", + score=1, + target=1, + stars=0, + time_left=0.0, + counters={}, + longest_streak=0, + players=[], + level_id="x", + seed=1, + ticks=0, + ) + + +# ------------------------------------------------------------------ drop policy + + +async def test_slow_reader_drops_oldest_snapshots() -> None: + clock = FakeClock() + conn, _ws = _conn(clock) + # A slow reader: nothing is pumped yet, so the queue fills. + for index in range(3): + conn.enqueue(_lobby(index)) # non-droppable + for index in range(20): + conn.enqueue(_snap(index)) # droppable + assert conn.send_queue.qsize() <= MAX_SEND_QUEUE + # Drain and check: the lobbies are all there, in order; the survivors + # are the NEWEST snapshots. + got: list[Any] = [] + while not conn.send_queue.empty(): + got.append(conn.send_queue.get_nowait()) + types = [item.type for item in got] + assert types == ["lobby"] * 3 + ["snap"] * (MAX_SEND_QUEUE - 3) + survivors = [item.raw["t"] for item in got if item.type == "snap"] + assert survivors == list( + range(20 - (MAX_SEND_QUEUE - 3), 20) + ), "the oldest snapshots must be the ones dropped" + + +async def test_non_droppable_frames_preserve_order() -> None: + clock = FakeClock() + conn, _ws = _conn(clock) + frames = [ + _lobby(0), + _snap(0), + _round(), + _snap(1), + _lobby(1), + _snap(2), + _round(), + _snap(3), + _lobby(2), + _snap(4), + _snap(5), + _snap(6), + _snap(7), + _snap(8), + ] + for frame in frames: + conn.enqueue(frame) + got: list[Any] = [] + while not conn.send_queue.empty(): + got.append(conn.send_queue.get_nowait()) + # Nothing non-droppable was dropped; the snaps that survived are the + # newest, and every frame keeps its relative order. + non_droppable = [item for item in got if item.type in ("lobby", "round")] + assert list(non_droppable) == [ + _lobby(0), + _round(), + _lobby(1), + _round(), + _lobby(2), + ] + snaps = [item for item in got if item.type == "snap"] + assert [s.raw["t"] for s in snaps] == sorted(s.raw["t"] for s in snaps) + + +async def test_queue_cannot_grow_with_a_rate_limited_flood() -> None: + clock = FakeClock() + conn, _ws = _conn(clock) + for index in range(50): # a flood of refusals (e.g. 50 rate_limited errs) + conn.enqueue( + ErrMsg( + type="err", + code=ErrorCode.RATE_LIMITED, + msg=f"refusal {index}", + ) + ) + # At most ONE rate_limited err is queued (coalesced); the queue is + # bounded regardless of the flood's size. + held: list[Any] = [] + while True: + try: + held.append(conn.send_queue.get_nowait()) + except asyncio.QueueEmpty: + break + rate_limited = [item for item in held if item.code == ErrorCode.RATE_LIMITED] + assert len(rate_limited) == 1 + assert len(held) <= MAX_SEND_QUEUE + 1 + + +async def test_queue_caps_at_max_under_snapshot_flood() -> None: + clock = FakeClock() + conn, _ws = _conn(clock) + for index in range(100): # the dominant traffic, a slow reader: the + conn.enqueue(_snap(index)) # queue holds the newest, caps at the max + assert conn.send_queue.qsize() == MAX_SEND_QUEUE + held: list[Any] = [] + while True: + try: + held.append(conn.send_queue.get_nowait()) + except asyncio.QueueEmpty: + break + assert [item.raw["t"] for item in held] == list(range(92, 100)) + + +async def test_all_non_droppable_frames_are_delivered_never_dropped() -> None: + clock = FakeClock() + conn, _ws = _conn(clock) + for index in range(30): + conn.enqueue(_lobby(index)) + # Nothing is ever dropped: once the queue holds no snapshot there is + # nothing to drop, and the queue grows with the non-droppable frames + # the server actually sent (bounded by the game's own event count — + # a lobby/round/err per state change, a coalesced `rate_limited`). + assert conn.send_queue.qsize() == 30 + + +async def test_pump_delivers_frames_in_order_to_a_slow_reader() -> None: + clock = FakeClock() + conn, ws = _conn(clock) + for index in range(15): + conn.enqueue(_snap(index)) + assert conn.send_queue.qsize() == MAX_SEND_QUEUE + pump = asyncio.create_task(pump_out(ws, conn)) + await asyncio.sleep(0) # let the pump run its passes + conn.alive = False + pump.cancel() + with contextlib.suppress(asyncio.CancelledError): + await pump + sent_snaps = [frame["raw"]["t"] for frame in ws.sent if frame["type"] == "snap"] + # Whatever the reader saw is in order and a suffix of the batch (only + # the oldest may have been dropped). + assert sent_snaps == sorted(sent_snaps) + assert sent_snaps == list(range(15 - len(sent_snaps), 15)) + + +# ----------------------------------------------------------------- token bucket + + +def test_bucket_serves_its_burst_then_empty() -> None: + clock = FakeClock() + bucket = TokenBucket(rate=5.0, burst=5, clock=clock) + assert all(bucket.take() for _ in range(5)) + assert not bucket.take() # the sixth, with no time elapsed: empty + + +def test_bucket_refills_off_the_injected_clock() -> None: + clock = FakeClock() + bucket = TokenBucket(rate=5.0, burst=5, clock=clock) + for _ in range(5): + bucket.take() + clock.advance(0.2) # one token (5/s * 0.2 s) + assert bucket.take() + assert not bucket.take() + clock.advance(1.0) # a full second: back to the burst cap + assert all(bucket.take() for _ in range(5)) + assert not bucket.take() + + +def test_bucket_never_refills_past_the_burst_cap() -> None: + clock = FakeClock() + bucket = TokenBucket(rate=5.0, burst=3, clock=clock) + clock.advance(100.0) # a very long idle + assert bucket.tokens() == 3.0 + assert all(bucket.take() for _ in range(3)) + assert not bucket.take() + + +def test_bucket_rejects_impossible_parameters() -> None: + clock = FakeClock() + with pytest.raises(ValueError, match=r"rate must be > 0"): + TokenBucket(rate=0.0, burst=1, clock=clock) + with pytest.raises(ValueError, match=r"burst must be >= 1"): + TokenBucket(rate=1.0, burst=0, clock=clock) + + +def test_bucket_tokens_reports_the_refilled_count() -> None: + clock = FakeClock() + bucket = TokenBucket(rate=2.0, burst=10, clock=clock) + for _ in range(5): + bucket.take() + assert bucket.tokens() == 5.0 + clock.advance(0.5) # +1 (2/s * 0.5 s) + assert bucket.tokens() == 6.0 + + +# ------------------------------------------------------------- the abuse tracker + + +def test_rate_close_after_three_consecutive_violations() -> None: + clock = FakeClock() + tracker = AbuseTracker(clock) + assert not tracker.note_rate_violation() + assert not tracker.note_rate_violation() + assert tracker.note_rate_violation() # the third, inside the window + + +def test_clean_frame_resets_the_consecutive_run() -> None: + clock = FakeClock() + tracker = AbuseTracker(clock) + assert not tracker.note_rate_violation() + tracker.note_clean() + assert not tracker.note_rate_violation() + assert not tracker.note_rate_violation() # the run only has two now + clock.advance(1.0) + assert tracker.note_rate_violation() # three consecutive again + + +def test_rate_violations_outside_the_window_do_not_close() -> None: + clock = FakeClock() + tracker = AbuseTracker(clock) + assert not tracker.note_rate_violation() # t=0 + clock.advance(ABUSE_WINDOW_S + 0.001) # the first stamp falls out + assert not tracker.note_rate_violation() # t=10.001: the run is two, one in-window + assert not tracker.note_rate_violation() # t=10.001: the run is three, two in-window + clock.advance(0.1) + assert tracker.note_rate_violation() # now three fit in the window + + +def test_shape_close_after_five_in_the_window() -> None: + clock = FakeClock() + tracker = AbuseTracker(clock) + for _ in range(4): + assert not tracker.note_shape_violation() + assert tracker.note_shape_violation() # the fifth + + +def test_shape_violations_outside_the_window_do_not_close() -> None: + clock = FakeClock() + tracker = AbuseTracker(clock) + for _ in range(4): + assert not tracker.note_shape_violation() # four at t=0 + assert tracker.note_shape_violation() # the fifth closes (all in-window) + # Age the whole batch past the window, then it must not count again. + clock.advance(ABUSE_WINDOW_S + 0.001) + for _ in range(4): + assert not tracker.note_shape_violation() # one..four in-window + assert tracker.note_shape_violation() # the fifth in-window diff --git a/tests/unit/test_ws_input_path.py b/tests/unit/test_ws_input_path.py new file mode 100644 index 0000000..5ea5182 --- /dev/null +++ b/tests/unit/test_ws_input_path.py @@ -0,0 +1,561 @@ +"""Unit: the input-ingestion path (phase 06, task 03) — `ws.to_player_input`, +`ws.seq_verdict`, the `_dispatch_input` layers (seat check, seq discipline, +clamp, gap counting) and the room's per-tick queue (capacity `4 x seats`, +drop-oldest-for-that-player, `stats()` census). + +The sim-side backstop of the clamp — a `(1000, 1000)` intent driven through +`to_player_input` + `Room.queue_input` + `tick_once` for 20 ticks never moves +a cook more than `MAX_SPEED x 0.05 x 1.01` per tick — is the hostile-speed +proof the live-socket suite in `tests/integration/test_ws_input.py` mirrors +at the wire layer (the wire's own `Vec2Msg` rejection of magnitude > 1 is the +early, explicit half of the rule; `movement.intent_direction()` is the +backstop the sim keeps behind both). +""" + +from __future__ import annotations + +import math +from typing import Any + +import pytest + +from undercooked.config import Settings +from undercooked.server.clock import FakeClock +from undercooked.server.protocol import ErrMsg, ErrorCode, InputMsg, Vec2Msg +from undercooked.server.room import Room, RoomState +from undercooked.server.ws import ( + TOLERATED_SEQ_GAP, + Connection, + TokenBucket, + WsLike, + _dispatch_input, + seq_verdict, + to_player_input, +) +from undercooked.sim.content import ContentBundle +from undercooked.sim.level import Level, LevelDef +from undercooked.sim.model import Facing, ItemForm, ItemKind, StationKind, Vec2 +from undercooked.sim.movement import MAX_SPEED +from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step +from undercooked.sim.state_build import SeatSpec, build_state +from undercooked.sim.step import STEP_DT, PlayerInput + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------- helpers + + +class _FakeWs(WsLike): + """A socket stand-in: records the text frames it was sent.""" + + def __init__(self) -> None: + self.sent: list[str] = [] + + async def send_text(self, data: str) -> None: + self.sent.append(data) + + +def make_room(**settings: Any) -> tuple[Room, FakeClock]: + """A room on a `FakeClock`, one seated (host) player, `PLAYING` (the + `queue_input` path under test needs the state, not the countdown).""" + clock = FakeClock() + room = Room(code="INPT1", settings=Settings(**settings), clock=clock) + room.add_seat("tok-a", "Ada", True) + room.state = RoomState.PLAYING + return room, clock + + +def make_conn( + token: str, clock: FakeClock, *, seat_index: int = 0 +) -> tuple[Connection, _FakeWs]: + ws = _FakeWs() + conn = Connection( + ws=ws, + token=token, + player_id=token, + seat_index=seat_index, + role="player", + rate=TokenBucket(40, 40, clock), + ) + return conn, ws + + +def make_input( + seq: int, + x: float = 0.0, + y: float = 0.0, + act: object = False, + face: object = None, +) -> InputMsg: + """A `InputMsg` via `model_construct` — bypasses the strict validation + on purpose: the clamp layer under test must stand whatever the wire + layer already rejected (the `(1000, 1000)` hostile frame, the non-bool + `act`, the unknown `face` all live here).""" + return InputMsg.model_construct( + type="input", + seq=seq, + move=Vec2Msg.model_construct(x=x, y=y), + act=act, + face=face, + ) + + +# ---------------------------------------------------------------- seq_verdict + + +@pytest.mark.parametrize( + ("last_seq", "seq", "expected"), + [ + (0, 1, "accept"), + (5, 6, "accept"), + (5, 5, "duplicate"), + (5, 4, "stale"), + (5, 0, "stale"), + ], +) +def test_seq_verdict_is_the_task_03_matrix( + last_seq: int, seq: int, expected: str +) -> None: + assert seq_verdict(last_seq, seq) == expected + + +def test_the_tolerated_gap_is_three() -> None: + assert TOLERATED_SEQ_GAP == 3 + + +# ------------------------------------------------------- to_player_input (clamp) + + +def test_a_hostile_move_is_clamped_to_the_unit_disc() -> None: + _, clock = make_room() + conn, _ = make_conn("tok-a", clock) + frame = to_player_input(conn, make_input(1, x=1000.0, y=1000.0), tick=7) + assert frame.player_id == "tok-a" + assert frame.seq == 1 + assert frame.received_at_tick == 7 + length = math.hypot(frame.move.x, frame.move.y) + assert length <= 1.0 + 1e-9, f"the clamp let a magnitude of {length} through" + # A unit diagonal (the (1000, 1000) direction, normalised) — not a boost. + assert abs(frame.move.x - frame.move.y) < 1e-9 + assert frame.move.x == pytest.approx(0.707, abs=1e-3) + + +def test_a_unit_move_passes_through_unclamped() -> None: + _, clock = make_room() + conn, _ = make_conn("tok-a", clock) + frame = to_player_input(conn, make_input(1, x=1.0, y=0.0), tick=0) + assert (frame.move.x, frame.move.y) == (1.0, 0.0) + + +def test_move_is_quantized_to_three_decimals() -> None: + _, clock = make_room() + conn, _ = make_conn("tok-a", clock) + frame = to_player_input(conn, make_input(1, x=0.123456, y=-0.654321), tick=0) + assert frame.move.x == round(0.123456, 3) + assert frame.move.y == round(-0.654321, 3) + + +def test_act_is_coerced_to_bool() -> None: + _, clock = make_room() + conn, _ = make_conn("tok-a", clock) + assert to_player_input(conn, make_input(1, act=1), tick=0).action is True + assert to_player_input(conn, make_input(1, act=0), tick=0).action is False + + +# ------------------------------------------------- face derivation (task 03) + + +@pytest.mark.parametrize( + ("x", "y", "expected"), + [ + (1.0, 0.0, Facing.RIGHT), + (-1.0, 0.0, Facing.LEFT), + (0.0, -1.0, Facing.UP), + (0.0, 1.0, Facing.DOWN), + (0.7, 0.7, Facing.RIGHT), # a 45° press: the dominant (horizontal) axis + (0.7, -0.7, Facing.RIGHT), + (-0.7, 0.7, Facing.LEFT), + (-0.7, -0.7, Facing.LEFT), + ], +) +def test_face_is_derived_from_move_when_null( + x: float, y: float, expected: Facing +) -> None: + _, clock = make_room() + conn, _ = make_conn("tok-a", clock) + frame = to_player_input(conn, make_input(1, x=x, y=y, face=None), tick=0) + assert frame.face is expected + + +def test_face_is_derived_from_move_when_unknown() -> None: + """An unknown `face` is unreachable over the wire (`InputMsg` rejects it + as `bad_field` first) — but `to_player_input` is the clamp layer, and a + directly-constructed one must derive, not raise.""" + _, clock = make_room() + conn, _ = make_conn("tok-a", clock) + frame = to_player_input( + conn, make_input(1, x=1.0, y=0.0, face="backwards"), tick=0 + ) + assert frame.face is Facing.RIGHT + + +def test_a_zero_move_derives_no_facing() -> None: + _, clock = make_room() + conn, _ = make_conn("tok-a", clock) + frame = to_player_input(conn, make_input(1, x=0.0, y=0.0), tick=0) + assert frame.face is None + + +def test_an_explicit_known_face_wins_over_move() -> None: + _, clock = make_room() + conn, _ = make_conn("tok-a", clock) + frame = to_player_input(conn, make_input(1, x=1.0, y=0.0, face="up"), tick=0) + assert frame.face is Facing.UP + + +# --------------------------------------------------- _dispatch_input: seat check + + +def test_a_spectator_input_is_refused_not_seated() -> None: + """A token with no seat (a spectator, or none) is `err not_seated`; + nothing is queued, and `last_seq` is untouched.""" + room, clock = make_room() + room.add_spectator("tok-spec", "Spec") + conn, _ = make_conn("tok-spec", clock) + + _dispatch_input(conn, room, make_input(1, x=1.0, y=0.0)) + assert not room._queued_inputs + assert conn.last_seq == 0 + (err,) = conn.send_queue._queue # type: ignore[reportAttributeAccessIssue] + assert isinstance(err, ErrMsg) + assert err.code == ErrorCode.NOT_SEATED + + +def test_a_disconnected_seat_is_silently_ignored() -> None: + room, clock = make_room() + room.mark_seat_disconnected("tok-a") + conn, ws = make_conn("tok-a", clock) + + _dispatch_input(conn, room, make_input(1, x=1.0, y=0.0)) + assert not room._queued_inputs + assert conn.last_seq == 0 # not accepted: the stat path never ran + assert room.stats()["inputs_received"] == 0 + assert not ws.sent # no err, nothing + + +def test_the_room_layer_repeats_the_disconnected_guard() -> None: + """`Room.queue_input` is the sim-side backstop: a `DISCONNECTED` seat's + frame is ignored there too (no event, no queue, no stat).""" + room, _ = make_room() + room.mark_seat_disconnected("tok-a") + assert ( + room.queue_input( + "tok-a", + PlayerInput(player_id="tok-a", seq=1, move=Vec2(1.0, 0.0)), + ) + is False + ) + assert not room._queued_inputs + assert not room.events_since_snapshot + assert room.stats()["inputs_received"] == 0 + + +# ------------------------------------------- _dispatch_input: the seq discipline + + +def test_an_accepted_input_advances_last_seq_and_queues() -> None: + room, clock = make_room() + conn, _ = make_conn("tok-a", clock) + + _dispatch_input(conn, room, make_input(1, x=1.0, y=0.0)) + assert conn.last_seq == 1 + assert len(room._queued_inputs) == 1 + assert room._queued_inputs[0].received_at_tick == 0 # the sim tick (None state) + assert room.stats()["inputs_received"] == 1 + assert room.stats()["input_gaps"] == 0 + + +def test_an_equal_seq_is_silently_dropped() -> None: + room, clock = make_room() + conn, _ = make_conn("tok-a", clock) + + _dispatch_input(conn, room, make_input(1, x=1.0, y=0.0)) + _dispatch_input(conn, room, make_input(1, x=1.0, y=0.0)) # the resend + assert conn.last_seq == 1 + assert len(room._queued_inputs) == 1 # the first one only + assert room.stats()["inputs_received"] == 1 + assert room.stats()["stale_seqs"] == 0 + assert not [ + event + for event in room.events_since_snapshot + if event.kind == "resync" + ] + + +def test_a_backwards_seq_is_refused_with_seq_and_a_resync_event() -> None: + room, clock = make_room() + conn, _ = make_conn("tok-a", clock) + + _dispatch_input(conn, room, make_input(5, x=1.0, y=0.0)) + _dispatch_input(conn, room, make_input(3, x=1.0, y=0.0)) # behind 5 + assert conn.last_seq == 5 # never lowered + assert len(room._queued_inputs) == 1 + assert room.stats()["stale_seqs"] == 1 + (err,) = conn.send_queue._queue # type: ignore[reportAttributeAccessIssue] + assert isinstance(err, ErrMsg) + assert err.code == ErrorCode.BAD_FIELD + assert err.seq == 3 # the frame's seq: what the client resyncs off + resyncs = [ + event + for event in room.events_since_snapshot + if event.kind == "resync" + ] + assert len(resyncs) == 1 + assert resyncs[0].payload == {"player_id": "tok-a", "seq": 3} + + +def test_a_gap_of_three_or_less_is_tolerated_silently() -> None: + room, clock = make_room() + conn, _ = make_conn("tok-a", clock) + + _dispatch_input(conn, room, make_input(1, x=1.0, y=0.0)) + _dispatch_input(conn, room, make_input(4, x=1.0, y=0.0)) # a jump of 3 + assert conn.last_seq == 4 + assert len(room._queued_inputs) == 2 + assert room.stats()["input_gaps"] == 0 + + +def test_a_larger_gap_is_accepted_and_counted() -> None: + room, clock = make_room() + conn, _ = make_conn("tok-a", clock) + + _dispatch_input(conn, room, make_input(1, x=1.0, y=0.0)) + _dispatch_input(conn, room, make_input(50, x=1.0, y=0.0)) # a jump of 49 + assert conn.last_seq == 50 + assert len(room._queued_inputs) == 2 + assert room.stats()["input_gaps"] == 1 + + +def test_a_hostile_move_counts_clamped_moves_and_clamps() -> None: + room, clock = make_room() + conn, _ = make_conn("tok-a", clock) + + _dispatch_input(conn, room, make_input(1, x=1000.0, y=1000.0)) + assert room.stats()["clamped_moves"] == 1 + (frame,) = room._queued_inputs + assert math.hypot(frame.move.x, frame.move.y) <= 1.0 + 1e-9 + + +def test_input_in_a_non_playing_room_is_dropped_silently() -> None: + room, clock = make_room() + room.state = RoomState.LOBBY + conn, _ = make_conn("tok-a", clock) + + _dispatch_input(conn, room, make_input(1, x=1.0, y=0.0)) + assert not room._queued_inputs + assert room.stats()["inputs_received"] == 0 + + +# --------------------------------------------------- the room's per-tick queue + + +def _fill(room: Room, token: str, count: int, start_seq: int = 1) -> None: + for offset in range(count): + assert room.queue_input( + token, + PlayerInput( + player_id=token, seq=start_seq + offset, move=Vec2(1.0, 0.0) + ), + ) + + +def test_the_queue_capacity_is_four_times_the_seats() -> None: + room, _ = make_room() # one seat: capacity 4 + _fill(room, "tok-a", 4) + assert len(room._queued_inputs) == 4 + # The 5th (same player): the oldest of *that player's* entries drops. + assert room.queue_input( + "tok-a", PlayerInput(player_id="tok-a", seq=5, move=Vec2(1.0, 0.0)) + ) + assert len(room._queued_inputs) == 4 + assert room._queued_inputs[0].seq == 2 # seq 1 was the drop + assert room.stats()["inputs_dropped"] == 1 + assert room.stats()["inputs_received"] == 5 + + +def test_overflow_never_drops_another_players_entries() -> None: + """The queue is full of player A's frames: player B's first frame is + the drop (a spammer cannot clear another player's tick); A's oldest + still drops for A's own next frame.""" + room, _ = make_room() + room.add_seat("tok-b", "Bob", False) + _fill(room, "tok-a", 8) # 2 seats: capacity 8; 8 of A's frames + assert ( + room.queue_input( + "tok-b", PlayerInput(player_id="tok-b", seq=1, move=Vec2(1.0, 0.0)) + ) + is False + ) + assert not any(frame.player_id == "tok-b" for frame in room._queued_inputs) + assert room.stats()["inputs_dropped"] == 1 + # A's own 9th frame evicts A's oldest, as usual. + assert room.queue_input( + "tok-a", PlayerInput(player_id="tok-a", seq=9, move=Vec2(1.0, 0.0)) + ) + assert room._queued_inputs[0].seq == 2 + assert len(room._queued_inputs) == 8 + + +def test_an_unknown_or_spectator_id_is_a_buffered_err_event() -> None: + room, _ = make_room() + room.add_spectator("tok-spec", "Spec") + assert ( + room.queue_input( + "tok-spec", PlayerInput(player_id="tok-spec", seq=1) + ) + is False + ) + assert ( + room.queue_input("tok-none", PlayerInput(player_id="tok-none", seq=1)) + is False + ) + reasons = [ + event.payload.get("reason") + for event in room.events_since_snapshot + if event.kind == "err" + ] + assert reasons == ["not_seated", "unknown_player"] + assert room.stats()["inputs_received"] == 0 + + +def test_note_input_stat_rejects_a_misspelling() -> None: + room, _ = make_room() + with pytest.raises(KeyError): + room.note_input_stat("inputs_received_typo") + with pytest.raises(KeyError): + room.note_input_stat("") + + +def test_stats_is_a_fixed_shape_census_of_zeroes_fresh() -> None: + room, _ = make_room() + assert room.stats() == { + "inputs_received": 0, + "inputs_dropped": 0, + "stale_seqs": 0, + "clamped_moves": 0, + "input_gaps": 0, + "events_dropped": 0, + } + + +def test_note_stale_seq_counts_and_buffers_the_resync_event() -> None: + room, _ = make_room() + room.note_stale_seq("tok-a", 3) + assert room.stats()["stale_seqs"] == 1 + resyncs = [ + event for event in room.events_since_snapshot if event.kind == "resync" + ] + assert len(resyncs) == 1 + assert resyncs[0].payload == {"player_id": "tok-a", "seq": 3} + + +# --------------------------------- the hostile-speed proof (sim-side, per tick) + + +def test_a_hostile_intent_moved_through_the_whole_path_never_exceeds_max_speed() -> None: + """`(1000, 1000)` every tick, through `to_player_input` + + `Room.queue_input` + `tick_once`: the per-tick distance stays within + `MAX_SPEED x dt x 1.01`. This is the clamp chain's proof (the wire + layer's `Vec2Msg` rejection is a separate, earlier line of defence — + the live suite asserts it there).""" + # A bare, open 9x4 kitchen (no walls to lean on): the cook's motion is + # the velocity's, so the distance bound is the velocity bound. The + # `LevelDef`'s own guards (`target_score > 0`, a non-empty `pool`) + # want a recipe, so `soup` rides along (a high `target` keeps the + # 20-tick window from ever ending the round). + level = Level.build( + LevelDef.model_validate( + { + "id": "hostile_1", + "name": "Hostile", + "order": 1, + "time_limit": 30.0, + "target_score": 1000, + "grid": [ + "#########", + "#.......#", + "#....SV.#", + "#.......#", + "#########", + ], + "tiles": { + ".": {"kind": "floor"}, + "#": {"kind": "wall"}, + "S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"}, + "V": {"kind": "floor", "station": "serve"}, + }, + "spawns": [[4, 2]], + "orders": { + "pool": ["soup"], + "concurrent": 1, + "spawn_interval": [10, 10], + "deadline": 30, + }, + } + ) + ) + bundle = ContentBundle( + recipe_book=RecipeBook( + recipes={ + "soup": Recipe( + id="soup", + name="Soup", + base_score=40, + ingredients=[ + Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW) + ], + steps=[ + Step(kind="serve", station=StationKind.SERVE, seconds=0.0) + ], + ) + }, + ingredients=[ItemKind.TOMATO], + ), + levels=(level,), + ) + clock = FakeClock() + room = Room(code="INPT1", settings=Settings(), clock=clock, content=bundle) + room.add_seat("tok-a", "Ada", True) + room.state = RoomState.PLAYING + state = build_state( + level, + [SeatSpec(player_id="tok-a", seat=0, display_name="Ada", color="")], + 7, + book=bundle.recipe_book, + ) + room.sim_state = state + conn, _ = make_conn("tok-a", clock) + room.tick_once() # warm-up: sets `_last_at` (a 0-`dt` tick advances + # nothing — with the substep guard it also keeps the queue) + previous: Vec2 | None = None + worst = 0.0 + for tick in range(1, 21): + _dispatch_input( + conn, + room, + make_input(tick, x=1000.0, y=1000.0, act=True), + ) + assert len(room._queued_inputs) == 1, "one frame per tick, one drop max" + clock.advance(0.05) + room.tick_once() + assert room.sim_state is not None + (player,) = room.sim_state.players + if previous is not None: + distance = (player.pos - previous).length + worst = max(worst, distance) + previous = player.pos + bound = MAX_SPEED * STEP_DT * 1.01 + assert worst <= bound, ( + f"the cook moved {worst:.4f} tiles in one tick; the bound is {bound:.4f}" + )