phase: 06_realtime_websocket_protocol

All green. Final verification complete — writing the report.

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

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

Next pending phase: `07_canvas_client_and_hud`.
This commit is contained in:
2026-10-09 11:13:25 -04:00
parent f51ba919af
commit f26849c1f3
44 changed files with 11525 additions and 72 deletions
+9 -5
View File
@@ -111,7 +111,8 @@
`server/headers.py` to **every** response, including errors. FastAPI's `/docs`,
`/redoc` and `/openapi.json` stay disabled because their bundled HTML pulls Swagger
assets from `cdn.jsdelivr.net`; the wire contract lives in `docs/PROTOCOL.md`
(written by phase 06) instead.
(written by phase 06; its message tables are kept equal to
`server/protocol.py`'s models by `tests/unit/test_protocol_doc_sync.py`) instead.
- **Docs are guarded like code.** `tests/unit/test_docs_commands.py` requires every
command line in a `bash` fence of `README.md`/`docs/*.md` to start with a known-safe
prefix (`uv `, `podman `, `curl `, `cd `, `bash .agents/validate.sh`, `#`, or a
@@ -180,17 +181,20 @@
| Configuration (env vars → typed `Settings`), debugpy bootstrap | `src/undercooked/config.py`, `.env.example`, `src/undercooked/debug.py` |
| Pure game rules (movement, stations, orders, scoring, hazards) | `src/undercooked/sim/` |
| The fixed-step tick: `step`/`build_state`, the seeded RNG, the subsystem hooks (contract: `docs/SIMULATION.md`) | `src/undercooked/sim/step.py`, `state_build.py`, `rng.py`, `movement.py`, `interact.py`, `carry.py`, `stations.py`, `freshness.py`, `orders.py`, `hazards.py`, `round_state.py` |
| Movement/collision: velocity integration, facing, stun, soft push, and the pure geometry under it (what blocks, the swept stop, the body footprint) | `src/undercooked/sim/movement.py`, `src/undercooked/sim/collision.py` (rules in `docs/SIMULATION.md` §"Movement and collision") |
| Movement/collision: velocity integration, facing, stun, soft push, the ghost park (a disconnected cook's stale input does not move it; a stun that runs out on a disconnected cook revives it `DISCONNECTED`, not `IDLE`), and the pure geometry under it (what blocks, the swept stop, the body footprint) | `src/undercooked/sim/movement.py`, `src/undercooked/sim/collision.py` (rules in `docs/SIMULATION.md` §"Movement and collision") |
| The action button: reach-zone targeting, the verb ladder, named `DENIED` reasons, the one-press-one-event gate, contested arbitration and the long-press trash | `src/undercooked/sim/interact.py` (rules in `docs/SIMULATION.md` §"The action button") |
| The hands: one-item-per-hand carry, the marked set-down and the counter hand-off, the always-available long-press trash (contract: `docs/SIMULATION.md` §"The hand") | `src/undercooked/sim/carry.py` |
| The ghost: what a dropped cook's state and hands do (`sim/ghost.py` — the sim's half, pure; the wall-clock grace is the room's): `mark_ghost` (→ `connected=False`, `DISCONNECTED`; idempotent), `restore_player` (→ `connected=True`, `IDLE`; keeps a still-carried item; idempotent), `release_ghost_carry` (the park's release — once, idempotent: onto the station on the cook's tile via `place_item`/`DROPPED`, or into `counters["lost_items"]` + `GHOST_PARK`; an item is never deleted silently), `station_at`, `PARK_AFTER_S = 3.0` (guards: `tests/unit/test_ghost_parking.py`; the room's half in `tests/integration/test_ws_disconnect.py`) | `src/undercooked/sim/ghost.py` |
| The station pipeline: the acceptance matrix, the transition timers, the worker hold and release, dirty props and the plate/pot stock (contract: `docs/SIMULATION.md` §"The station pipeline") | `src/undercooked/sim/stations.py` |
| Freshness (stage 4b): the burn countdown (tick-exact, 8.0 s), the board's linear decay (20 s), the half-rate carry, the burnt `WASTED` push-out (a usable raw is never overwritten), and the dirty loop's stock (use dirties, a wash cleans, `…_clean + …_dirty` conserved; the no-dead-end guarantee) (contract: `docs/SIMULATION.md` §"Freshness") | `src/undercooked/sim/freshness.py` |
| Serve resolution and scoring: the dish's named rejections (`NO_ORDERS`/`WRONG_RECIPE`/`INCOMPLETE`/`BURNT`/`NO_TARGET`), FIFO order matching, `round(base × quality × streak × time_bonus)` with the capped streak and the deadline share, the miss penalty and streak reset, the per-player contribution (rules: `docs/SIMULATION.md` §"Serve scoring") | `src/undercooked/sim/scoring.py` |
| Level/recipe content | `levels/*.yaml` |
| Content schema, ASCII-grid parser, content loader, playability validator (contract: `docs/LEVELS.md`) | `src/undercooked/sim/recipe.py`, `level.py`, `content.py`, `validation.py` |
| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py` (the supervisor: registry, capacity, the one-token-one-room index, the 5 s GC, `start`/`shutdown`), `room.py` (the guarded FSM: the `RoomState`/`ROOM_TRANSITIONS` table, the round data, the per-tick input queue, the one owned deadline-scheduled tick task and its crash isolation, `tick_once` — contract: `docs/ROOMS.md`), `clock.py` (injectable time — the only `server/` module that imports `time`; a grep test keeps the wall clock out of the rest), `codes.py` (Crockford code minting + input normalisation), `seats.py` (the lobby's data: `Seat`/`Spectator`, the name boundary, the 4-colour palette, the host handoff, the emote whitelist, the named lobby refusals), `routes_rooms.py` (`POST /api/rooms`, `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join`, `GET /api/session` — the lifecycle over HTTP before the phase-06 transport), `sessions.py` (`SessionRecord` + `BestRun` + `mint_token` + the bounded LRU `SessionStore`: `record_result`'s win-only unlock bump (monotonic), best-score replacement, `is_unlocked` / `next_level_id`, `to_public` — in memory; a process restart clears all progression) |
| Wire messages + validation | `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md` |
| HTTP app (`/`, `/healthz`, `/static`), lifespan, startup content gate | `src/undercooked/server/app.py` |
| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py` (the supervisor: registry, capacity, the one-token-one-room index, the 5 s GC, `start`/`shutdown` — a room created while the GC loop is running already gets its tick task), `room.py` (the guarded FSM: the `RoomState`/`ROOM_TRANSITIONS` table, the round data, the per-tick input queue (capacity `INPUT_QUEUE_PER_SEAT x len(seats)`, overflow drops the sender's oldest frame, a zero-substep tick keeps the queue so a frame is never dropped without being applied), `stats()` (the lobby/round counters plus `inputs_received`/`inputs_dropped`/`stale_seqs`/`clamped_moves`/`large_seq_gaps`), the one owned deadline-scheduled tick task and its crash isolation, `tick_once`, the ghost and seat-grace verbs (`on_disconnect` — mark + one `GHOST_ON` + the sim switch, a no-op if already a ghost; `on_reconnect` — mark + one `GHOST_OFF` only when it was a ghost + the sim restore; `_park_ghosts` — the per-`PLAYING`-tick park off `seat.last_seen`, one release per token per round; `_abandon_expired`/`_abandon_seat` — the `SeatAbandoned` at `guest_idle_timeout_s` in every state: forced release + `GHOST_OFF` + the seat out; the `on_close` hook awaited by `close()`) — contract: `docs/ROOMS.md`), `clock.py` (injectable time — the only `server/` module that imports `time`; a grep test keeps the wall clock out of the rest), `codes.py` (Crockford code minting + input normalisation), `seats.py` (the lobby's data: `Seat`/`Spectator`, the name boundary, the 4-colour palette, the host handoff, the emote whitelist, the named lobby refusals), `routes_rooms.py` (`POST /api/rooms`, `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join`, `GET /api/session` — the lifecycle over HTTP before the phase-06 transport), `sessions.py` (`SessionRecord` + `BestRun` + `mint_token` + the bounded LRU `SessionStore`: `record_result`'s win-only unlock bump (monotonic), best-score replacement, `is_unlocked` / `next_level_id`, `to_public` — in memory; a process restart clears all progression) |
| Wire messages + validation | `src/undercooked/server/protocol.py` (the message models, `parse_client_message` — never raises, the `ErrorCode`/`CloseCode` constants, `MESSAGE_WHITELIST`), `docs/PROTOCOL.md` (the contract in prose; `tests/unit/test_protocol_doc_sync.py` keeps the two equal) |
| The snapshot encoder (task 04): `encode_snapshot` (the PLAN §4 shape, quantized, explicit field maps per type — no `pickle`/`asdict`; no `token`/`display_name`/`seed`/`rng_state`/station-internal-id ever reach the wire), the delta mode (diff vs the previous frame, `gone` tombstones), `encode_events` (the stable `k` names + the `EVENT_PAYLOAD_SHORT` map, `MAX_EVENTS_PER_FRAME = 24`), `snapshot_size` (the UTF-8 budget hook; a 4p/4o/3h frame ≤ 1.5 KB, a 64×64 level ≤ 4 KB), the `SnapshotFanout` (encode-once/fan-out-many, the per-audience `prev` + the `FULL_SNAPSHOT_EVERY_TICKS = 20` cadence, the `needs_full` resync, the non-destructive event cursor, the `events_dropped` count) in `server/ws.py`, the room's pacing seam (`_snapshot_pacing_tick`, the seq-tagged `event_buffer`, the `countdown`/`round`/`ev` broadcasts) in `server/room.py` (contract: `docs/PROTOCOL.md` "The `snap` frame"; guards: `tests/unit/test_encoding_no_leak.py`, `tests/integration/test_encoding.py`) | `src/undercooked/server/encoding.py` |
| The WebSocket transport: the pre-accept origin check, the `hello` handshake (timeout → `4400`), the `p` gate, the supervisor seat/spectate (`4401`/`4402`; a `CLOSED` room is an `err room_gone` + close `4401` before any seat), the `welcome`'s `rejoined` flag (judged before the `join`: a token that holds a *disconnected* seat is a rejoin), the `ping`/`pong` keep-alive (any inbound frame refreshes `last_seen`) and the heartbeat watchdog (`HEARTBEAT_DEAD_S = 12` s of silence → the server closes `1000` and the standard teardown runs — the room sees the same drop as a hard close), the `input` ingestion (the `seq` discipline: duplicates dropped, stale → `err` + a `resync` event, gaps ≤ 3 tolerated, larger gaps accepted and counted; `move` clamped to unit length and quantized, `face` derived, `act` coerced, `received_at_tick` stamped server-side; a `DISCONNECTED` seat's frames are silently ignored, spectators get `not_seated`), the per-connection guardrails (size cap → `too_big`, the token bucket → `rate_limited`, the shape check, the per-state whitelist → `wrong_state`; 3 consecutive rate/oversize or 5 shape violations → `4404`; a *binary* frame is a frame the cap and the parser apply to — a `too_big` / a `bad_json`, never a stack exception), one-socket-per-token eviction (`4403`, the evicted socket never marks the seat disconnected), the capped send queue (the oldest snapshot drops first; `lobby`/`round`/`err` never; a `rate_limited` coalesces to one) and the once-only `on_disconnect` teardown | `src/undercooked/server/ws.py` (the `Connection`/`ConnectionRegistry`/`TokenBucket`/`AbuseTracker`, the `seq_verdict`/`to_player_input` input path, the `pump_out`/`read_in`/`heartbeat_watchdog` tasks, the registry's census (`stats()`: `sockets`, the `send_queue_max` high-water mark, `memory_mb` — the phase-06 abuse/budget numbers); contract: `docs/PROTOCOL.md` incl. the manual probe, "Heartbeats, drops, and seat grace" and "Testing the protocol" (the test seams + the budget table with the measured values); `tests/integration/test_ws_handshake.py` drives every guardrail over live sockets, `tests/integration/test_ws_input.py` the input rules over live sockets, `tests/integration/test_ws_disconnect.py` the heartbeat/disconnect/grace/rejoin end to end, `tests/integration/test_ws_round_two_clients.py` the complete two-client round (the `live_server`/`ws_client` fixtures, the `levels_round` campaign), `tests/integration/test_ws_abuse.py` the guardrails at scale (2000 malformed frames, the 500-burst, the 64 KB / 4 KB size cap, the host-check flood, 200 handshakes), `tests/integration/test_ws_budgets.py` the `slow` budgets (tick cost over 400 real ticks, `snap` size, downlink, echo), `tests/unit/test_send_queue_policy.py` the queue policy + bucket arithmetic, `tests/unit/test_ws_input_path.py` the ingestion unit) |
| HTTP app (`/`, `/healthz`, `/static`), lifespan, startup content gate, the `room_close` hook (a closing room closes its own sockets with `err room_gone` + close `4401`) and the test-only seam `POST /api/test/kill_socket/{code}/{token}` (registered only when `UNDERCOOKED_TEST_MODE=1`) | `src/undercooked/server/app.py` |
| Content HTTP API (`/api/levels`, `/api/levels/{id}`, `/api/config`) + strict response models | `src/undercooked/server/routes_content.py` |
| Author CLI `undercooked-levels` (`validate`, `list`, `show`, `new`, `axes`); workflow in `docs/LEVELS.md` | `src/undercooked/tools/levels.py` |
| The replay harness: input scripts, golden hashes, `run_script`/`compare`, the `record`/`check` CLI (contract: `docs/SIMULATION.md` "Replaying and re-blessing goldens") | `src/undercooked/sim/replay.py`, `tests/fixtures/replays/`, `tests/goldens/`, `tests/sim/test_replay.py`, `tests/sim/test_perf_smoke.py` (`slow`) |
+145 -17
View File
@@ -17,8 +17,12 @@ movement and collision, the action button, the hands, the replay goldens),
serve scoring, freshness and the dirty loop, the round verdict with stars)
and **05** (anonymous lobbies and room lifecycle: the `RoomSupervisor`,
the guarded `Room` FSM, the in-memory session store, the lifecycle
integration suite) are **complete**; the next pending phase is
`06_realtime_websocket_protocol`. What
integration suite) and **06** (the realtime WebSocket protocol: the
`/ws/{code}` transport, the versioned JSON wire contract with inbound
guardrails, input ingestion with sequence discipline, the quantized
snapshot stream, heartbeats and seat grace — `docs/PROTOCOL.md`)
are **complete**; the next pending phase is
`07_canvas_client_and_hud`. What
runs today:
- **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
@@ -184,7 +188,8 @@ runs today:
(empty rooms past a 30 s grace, idle lobbies past `lobby_idle_timeout_s`, stale
tokens). Everything runs on an injected clock (`server/clock.py`), so the tests
drive the garbage collector with a `FakeClock` and never sleep. The lifecycle is
testable over HTTP before the phase-06 WebSocket transport exists:
testable over HTTP, and it now also rides the phase-06 WebSocket
transport (`/ws/{code}`; below):
`POST /api/rooms` (201, the session from the `X-Undercooked-Token`
header or the body's `token` — or a server-minted token, returned
once),
@@ -216,19 +221,131 @@ runs today:
criterion 4), the seat-grace and lobby-idle expiries through the GC, and
one room's crash closing only itself (the test-only `_test_step_override`
seam on `Room`; contract: `docs/ROOMS.md` "Failure and expiry" and
"The lifecycle walkthrough"). The client
buttons still answer
"Not implemented until phase 07." until phases 06/07 wire the transport.
"The lifecycle walkthrough"). The client buttons in the page still
answer "Not implemented until phase 07." until the phase-07 client
wiring replaces them.
- **The WebSocket transport (phase 06, task 02).** The room core is on
the wire: `GET /api/stats` answers the ops census (`rooms` / `players`
— the occupied seats / `sockets` — live WebSocket connections /
`tick_hz`), and `WS /ws/{code}` is the game socket — an origin check
*before* the accept (a disallowed `Origin` is an HTTP `403`, never a
close frame that looks like a network error), a `hello` within
`hello_timeout_s` (else close `4400`), a `p` gate (`protocol_mismatch`
+ `4400` before any seat is taken), the supervisor's seat/spectate
(`room_gone` + `4401` for an unknown code, `room_full` + `4402` for a
fresh token on a full room), the `welcome` + the current `lobby`, and
the two per-connection tasks (a non-blocking `pump_out` over a capped
send queue — the *oldest snapshot* drops first, `lobby` / `round` /
`err` never — and the `read_in` guardrails: the `max_message_bytes`
cap → `too_big`, the `max_inputs_per_s` token bucket →
`rate_limited`, the shape check, the per-state whitelist →
`wrong_state`; 3 consecutive rate/oversize violations or 5 shape
violations close `4404`). One socket per token: a second `hello` with
the same token evicts the first (`evicted` + `4403`, marked dead
before the close so no ghost frames) — and an evicted socket never
marks the seat disconnected, so the crown stays put. A *genuine*
disconnect reaches the room exactly once (`mark_disconnected`),
releases the registry slot, and leaves no error in the log — an
abrupt transport close (no close handshake) included. The contract in
prose is `docs/PROTOCOL.md` (message tables kept equal to the models
by `tests/unit/test_protocol_doc_sync.py`, close/error codes, the
limits, and a manual probe that reproduces the handshake without a
browser); the guardrails are driven by real sockets in
`tests/integration/test_ws_handshake.py` and the queue policy by
`tests/unit/test_send_queue_policy.py`.
- **Input ingestion (phase 06, task 03).** The `input` message is on the
wire: the `seq` discipline (equal → dropped as an idempotent resend,
backwards → `err` + a `resync` event the client hard-snapshots to,
gaps ≤ 3 tolerated UDP-style, larger gaps accepted and counted in the
room stats), the anti-cheat clamp (`move` clamped to unit length and
quantized to 3 decimals server-side — a client sending `(1000, 1000)`
walks at `MAX_SPEED` like everyone else, and the position never
arrives because the message has no position field and `extra="forbid"`
rejects one), `face` derived from `move` when absent, `act` coerced,
and `received_at_tick` stamped by the server, never the client. The
room's per-tick input queue caps at `4 x len(seats)` (overflow drops
the sender's oldest frame, `inputs_dropped` bumped), a `DISCONNECTED`
seat's frames are silently ignored, a spectator's get `not_seated`,
and `Room.stats()` carries the input counters. A wall-clock tick too
short to fill one substep keeps the queue, so a frame is never
dropped without being applied. The rules are in `docs/PROTOCOL.md`
("Input rules") and driven by real sockets in
`tests/integration/test_ws_input.py` (the hostile-move bound included).
- **The snapshot stream (phase 06, task 04).** `PLAYING` now broadcasts:
quantized snapshots (an explicit field map — no `pickle`/`asdict`; the
grid, station ids, spawn coordinates and the round seed never leave
the server) at `SNAPSHOT_HZ` for players and half that for
spectators, encoded once per (frame, audience) and fanned out across
every connection of it. The stream runs in delta mode (only the
changed entities, plus a `gone` tombstone for each vanished one; a
typical idle-kitchen delta is ≥ 40% smaller than a full), with a
full frame on a fresh connection's first frame, on a dropped
snapshot's next frame, and on the full cadence (every 20 encoded
frames), so a client can always resync cheaply. The boundary's
events ride the frame (the stable `k` names, an explicit short-key
payload map, capped at 24 per frame with `events_dropped` on
overflow); outside `PLAYING` they flush as the standalone `ev`
message, and the `countdown` / `round` state frames reach every
connection. `UNDERCOOKED_SIM_LATENCY_MS` applies the server-side send
delay the netcode tests use to simulate RTT. The shape, the delta
rules and a captured frame are `docs/PROTOCOL.md` ("The `snap`
frame"), the no-leak/quantization guard is
`tests/unit/test_encoding_no_leak.py`, and the budgets (a 4-player /
4-order frame ≤ 1.5 KB, a 64×64 level ≤ 4 KB, the 100-tick downlink
≤ 60 KB/s) are measured over live sockets in
`tests/integration/test_encoding.py`.
- **Disconnects, the ghost, and the rejoin (phase 06, task 05).** A
dropped socket (an abrupt close, a heartbeat timeout, or the
test-only `kill_socket` seam) keeps the seat for
`UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (default 45 s): the other
players see one `ghost_on` event and a `connected: false` seat, the
sim's cook switches to `disconnected`, and after 3 s the cook's
carried item is released — onto the station on the cook's tile (a
marked set-down, grabbable), or into the `lost_items` counter when
no station is under the tile (an item is never deleted silently). A
`hello` with the same token inside the window rejoins the same seat
(`welcome.rejoined: true`, one `ghost_off`, the snapshot stream
resumes within one tick); past it the seat is abandoned (one
`ghost_off`, the spectate/join rules apply, an emptied room closes
through the 30 s seat grace / the GC). The client keeps the socket
honest with a `ping` at ≥ 5 Hz; 12 s of inbound silence is a dead
client (the server closes `1000` — the room sees the same drop as an
abrupt close), and a closing room closes its sockets with
`err room_gone` + close `4401`, so a client can tell "the room
died" apart from "you left" (`1000`). The grace, the park, the
ghost events and the close-code decision table are
`docs/PROTOCOL.md` ("Heartbeats, drops, and seat grace"); the
park's rules are `sim/ghost.py` (driven in
`tests/unit/test_ghost_parking.py`), the drop/rejoin/seam paths in
`tests/integration/test_ws_disconnect.py`.
- **The protocol, tested (phase 06, task 06).** The wire contract's
final proof is three real-socket suites in `tests/integration/`:
`test_ws_round_two_clients.py` (two scripted clients play a complete
round — the countdown, the `snap` stream, the scripted soup, the
shared `served` event and the byte-identical `round` frames, a
mid-round spectator, the `to_lobby` return), `test_ws_abuse.py`
(2000 malformed frames, the 500-message burst, the 64 KB refusal /
4 KB boundary, the host-check and emote-throttle flood, 200
concurrent handshakes) and `test_ws_budgets.py` (`slow`: the tick
cost over 400 real ticks, the `snap` size, the downlink rate, the
input→snapshot echo). The `live_server` / `ws_client` fixtures
(`tests/integration/conftest.py`) drive them — a real `uvicorn`
server on an ephemeral loopback port and a scripted client with a
merged snapshot view (`expect` / `collect` / `send_model`). The
contract, the test seams and the budget table with the measured
values are `docs/PROTOCOL.md` ("Testing the protocol").
- **The rules are tests, not promises.** The no-external-asset rule is
`tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is
`tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by
`tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
phase pipeline runs after every task.
What does not exist yet: the hazards (phase 10), the
WebSocket
protocol (06 — the lobby buttons answer "Not implemented until phase 07.") and the
playable Canvas renderer (07). Execute phases with the `phased-execution` skill
What does not exist yet: the hazards (phase 10) and the
in-browser
playable Canvas renderer (07 — the phase-06 WebSocket transport, including
the snapshot stream, is live but the client still answers "Not
implemented until phase 07."). Execute phases with the
`phased-execution` skill
(`auto-phase.sh`); it always takes the lowest-numbered directory still in
`.agents/phases/todo/`.
@@ -245,13 +362,18 @@ keeps only a random per-tab token and the last code you typed, both in `sessionS
The campaign does remember per token — unlocks and best scores live server-side,
in memory, and a server restart clears all progression (A6: no database).
What actually works today is the shell of that plus the server-side room shell:
What actually works today is the shell of that plus the server-side room
shell and the phase-06 WebSocket transport:
the page loads, normalises a typed code to five uppercase alphanumerics (codes
themselves are Crockford base32, minted by the room supervisor — `server/rooms.py`,
phase 05, which is live and testable over `POST /api/rooms` / `GET /api/rooms/{code}` /
`POST /api/rooms/{code}/join` / `GET /api/session`), and the lobby buttons still answer "Not implemented
until phase 07." until the phase-06 WebSocket transport and the phase-07 client
wiring replace them. The ready-up, the 20 Hz tick ownership, the session store
`POST /api/rooms/{code}/join` / `GET /api/session`), a `WS /ws/{code}`
client can already create a room over HTTP, handshake (`hello` → `welcome`
+ `lobby`), ready up and keep alive — the full contract, including the
guardrails and the close codes, is `docs/PROTOCOL.md` — but the lobby
buttons in the page still answer "Not implemented
until phase 07." until the phase-07 client
wiring replaces them. The ready-up, the 20 Hz tick ownership, the session store
(the unlocks and best scores, in memory — a server restart clears all
progression) and the lifecycle integration suite are live. Movement keys and
touch controls are defined by the phase-07/08 client and documented in
@@ -388,6 +510,8 @@ every variable with its default; tests keep both in sync with `config.py`.
| `UNDERCOOKED_MAX_MESSAGE_BYTES` | `4096` | Hard cap on inbound WS frame size |
| `UNDERCOOKED_MAX_INPUTS_PER_S` | `40` | Per-connection inbound message rate limit |
| `UNDERCOOKED_SIM_LATENCY_MS` | `0` | Injected server-side downlink latency (netcode tests) |
| `UNDERCOOKED_HELLO_TIMEOUT_S` | `5` | A WebSocket that sends no `hello` within this many seconds is closed (code 4400) |
| `UNDERCOOKED_TEST_MODE` | `0` | `1` = register the test-only seam route (`POST /api/test/kill_socket/...`); never set in production |
| `UNDERCOOKED_ALLOWED_ORIGINS` | *(empty)* | Comma-separated `Origin`s accepted on the WebSocket handshake; empty = same-host only |
| `DEBUGPY` | `0` | `1` = open a debugpy listener on `DEBUGPY_PORT` at startup |
| `DEBUGPY_PORT` | `5678` | debugpy listen port |
@@ -460,9 +584,13 @@ tests/ unit/, integration/, e2e/ (one Playwright suite per stor
levels; `_bad/` holds the deliberately-broken campaign the `_`
discovery rule ignores); conftest.py
docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract),
SIMULATION.md (the tick contract), TESTING.md (how this project
is verified), DEPLOYMENT.md; PROTOCOL / CLIENT land with their
own phases (the index is PLAN.md §13)
SIMULATION.md (the tick contract), ROOMS.md (rooms &
lobby lifecycle), PROTOCOL.md (the v1 wire contract —
message tables, error/close codes, limits, the full-round
sequence, the test seams and the budget table), TESTING.md
(how this project
is verified), DEPLOYMENT.md; CLIENT lands with
phase 07 (the index is PLAN.md §13)
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
```
+907
View File
@@ -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": <the stable `EventKind` name>,
"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": <player_id>, "seq": <n>}` 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": <player_id>, "seq": <n>}` 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.
+41 -13
View File
@@ -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
+1 -1
View File
@@ -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
+19
View File
@@ -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),
+117 -5
View File
@@ -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."""
+390
View File
@@ -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"))
+616
View File
@@ -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"}),
}
+493 -19
View File
@@ -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},
+31 -3
View File
@@ -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:
File diff suppressed because it is too large Load Diff
+156
View File
@@ -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),
},
),
),
)
+4
View File
@@ -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)
+19 -2
View File
@@ -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(
+45
View File
@@ -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.
+80
View File
@@ -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]
+59
View File
@@ -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.
+80
View File
@@ -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]
+443
View File
@@ -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
+870
View File
@@ -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
+7 -3
View File
@@ -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 == []
+9 -4
View File
@@ -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)
+450
View File
@@ -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
+318
View File
@@ -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)
+686
View File
@@ -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)
+477
View File
@@ -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:<port>) 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)
)
+517
View File
@@ -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,
)
@@ -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()
+1
View File
@@ -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",
+434
View File
@@ -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
+303
View File
@@ -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",
}
+247
View File
@@ -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"
+380
View File
@@ -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": {}}
+94
View File
@@ -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"}
+344
View File
@@ -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
+561
View File
@@ -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}"
)