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:
@@ -111,7 +111,8 @@
|
|||||||
`server/headers.py` to **every** response, including errors. FastAPI's `/docs`,
|
`server/headers.py` to **every** response, including errors. FastAPI's `/docs`,
|
||||||
`/redoc` and `/openapi.json` stay disabled because their bundled HTML pulls Swagger
|
`/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`
|
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
|
- **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
|
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
|
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` |
|
| 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/` |
|
| 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` |
|
| 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 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 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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) |
|
| 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`, `docs/PROTOCOL.md` |
|
| 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) |
|
||||||
| HTTP app (`/`, `/healthz`, `/static`), lifespan, startup content gate | `src/undercooked/server/app.py` |
|
| 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` |
|
| 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` |
|
| 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`) |
|
| 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`) |
|
||||||
|
|||||||
@@ -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)
|
serve scoring, freshness and the dirty loop, the round verdict with stars)
|
||||||
and **05** (anonymous lobbies and room lifecycle: the `RoomSupervisor`,
|
and **05** (anonymous lobbies and room lifecycle: the `RoomSupervisor`,
|
||||||
the guarded `Room` FSM, the in-memory session store, the lifecycle
|
the guarded `Room` FSM, the in-memory session store, the lifecycle
|
||||||
integration suite) are **complete**; the next pending phase is
|
integration suite) and **06** (the realtime WebSocket protocol: the
|
||||||
`06_realtime_websocket_protocol`. What
|
`/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:
|
runs today:
|
||||||
|
|
||||||
- **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
|
- **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
|
(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
|
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
|
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`
|
`POST /api/rooms` (201, the session from the `X-Undercooked-Token`
|
||||||
header or the body's `token` — or a server-minted token, returned
|
header or the body's `token` — or a server-minted token, returned
|
||||||
once),
|
once),
|
||||||
@@ -216,19 +221,131 @@ runs today:
|
|||||||
criterion 4), the seat-grace and lobby-idle expiries through the GC, and
|
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`
|
one room's crash closing only itself (the test-only `_test_step_override`
|
||||||
seam on `Room`; contract: `docs/ROOMS.md` "Failure and expiry" and
|
seam on `Room`; contract: `docs/ROOMS.md` "Failure and expiry" and
|
||||||
"The lifecycle walkthrough"). The client
|
"The lifecycle walkthrough"). The client buttons in the page still
|
||||||
buttons still answer
|
answer "Not implemented until phase 07." until the phase-07 client
|
||||||
"Not implemented until phase 07." until phases 06/07 wire the transport.
|
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
|
- **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_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_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
|
`tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
|
||||||
phase pipeline runs after every task.
|
phase pipeline runs after every task.
|
||||||
|
|
||||||
What does not exist yet: the hazards (phase 10), the
|
What does not exist yet: the hazards (phase 10) and the
|
||||||
WebSocket
|
in-browser
|
||||||
protocol (06 — the lobby buttons answer "Not implemented until phase 07.") and the
|
playable Canvas renderer (07 — the phase-06 WebSocket transport, including
|
||||||
playable Canvas renderer (07). Execute phases with the `phased-execution` skill
|
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
|
(`auto-phase.sh`); it always takes the lowest-numbered directory still in
|
||||||
`.agents/phases/todo/`.
|
`.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,
|
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).
|
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
|
the page loads, normalises a typed code to five uppercase alphanumerics (codes
|
||||||
themselves are Crockford base32, minted by the room supervisor — `server/rooms.py`,
|
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}` /
|
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
|
`POST /api/rooms/{code}/join` / `GET /api/session`), a `WS /ws/{code}`
|
||||||
until phase 07." until the phase-06 WebSocket transport and the phase-07 client
|
client can already create a room over HTTP, handshake (`hello` → `welcome`
|
||||||
wiring replace them. The ready-up, the 20 Hz tick ownership, the session store
|
+ `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
|
(the unlocks and best scores, in memory — a server restart clears all
|
||||||
progression) and the lifecycle integration suite are live. Movement keys and
|
progression) and the lifecycle integration suite are live. Movement keys and
|
||||||
touch controls are defined by the phase-07/08 client and documented in
|
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_MESSAGE_BYTES` | `4096` | Hard cap on inbound WS frame size |
|
||||||
| `UNDERCOOKED_MAX_INPUTS_PER_S` | `40` | Per-connection inbound message rate limit |
|
| `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_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 |
|
| `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` | `0` | `1` = open a debugpy listener on `DEBUGPY_PORT` at startup |
|
||||||
| `DEBUGPY_PORT` | `5678` | debugpy listen port |
|
| `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 `_`
|
levels; `_bad/` holds the deliberately-broken campaign the `_`
|
||||||
discovery rule ignores); conftest.py
|
discovery rule ignores); conftest.py
|
||||||
docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract),
|
docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract),
|
||||||
SIMULATION.md (the tick contract), TESTING.md (how this project
|
SIMULATION.md (the tick contract), ROOMS.md (rooms &
|
||||||
is verified), DEPLOYMENT.md; PROTOCOL / CLIENT land with their
|
lobby lifecycle), PROTOCOL.md (the v1 wire contract —
|
||||||
own phases (the index is PLAN.md §13)
|
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
|
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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`) |
|
| `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 |
|
| `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 |
|
| `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) |
|
| `snapshot_seq` | the room's snapshot counter (the phase 06 transport stamps it on frames) |
|
||||||
|
|
||||||
## One 20 Hz tick task, deadline-scheduled
|
## 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.
|
at zero, fire `countdown_done` and build the round.
|
||||||
- **`PLAYING`** — `sim.step(sim_state, dt, queued, recipe_book)`: the kernel's
|
- **`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
|
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
|
costs at most two substeps). The per-tick input queue is drained into
|
||||||
step (the sim stamps `received_at_tick`); the sim's events are appended to
|
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
|
`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
|
**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
|
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.
|
floored at 0, so a backwards `Clock` never un-runs the sim.
|
||||||
|
|
||||||
Inputs: `queue_input` accepts a `sim.model.PlayerInput` only while `PLAYING`
|
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
|
and for a seated, connected token: an unknown token is an `err` event with
|
||||||
in-band error channel; a dropped input outside playing is a silent no-op, the
|
`reason=not_seated` (the player is not a seat at all) or `reason=unknown_player`
|
||||||
client is not at fault). The queue is per-tick: drained into the step, cleared
|
(the token is a seat but the sim id is not its) — the room's only in-band
|
||||||
after it, never carried between ticks.
|
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
|
## 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
|
`join`s while `CLOSED` (the window before the drop) is a named 410
|
||||||
`room_closed`.
|
`room_closed`.
|
||||||
|
|
||||||
**Disconnected seats.** A dropped connection marks the seat
|
**Disconnected seats (task 05).** A dropped connection marks the seat
|
||||||
`connected=false`; the seat is *kept* for the rejoin path (a token
|
`connected=false`; the seat is *kept* for the rejoin path. The park
|
||||||
`join`s its own room again, the seat is marked connected, the sim keeps
|
comes first: after `PARK_AFTER_S` (3 s) the cook's carried item is
|
||||||
its player). The grace that drops such a seat is
|
released (onto the station on the tile, or `counters["lost_items"]`),
|
||||||
`UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (45 s) — that rule lands with phase 12
|
and the sim's cook sits in `disconnected`. The grace that drops the
|
||||||
(reconnect resilience); until then a disconnected seat lingers until the
|
seat is `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (45 s), enforced by the
|
||||||
room empties and the seat grace runs.
|
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
|
**The crash policy** is the crash-isolation section above, restated as
|
||||||
lifecycle: a crashed room fires `close` from inside its own tick (it
|
lifecycle: a crashed room fires `close` from inside its own tick (it
|
||||||
|
|||||||
+1
-1
@@ -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/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/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/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
|
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
|
directories. A route asserted as a pure function (header values, status codes, CLI
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ ENV_KEYS: Mapping[str, str] = {
|
|||||||
"max_message_bytes": f"{ENV_PREFIX}MAX_MESSAGE_BYTES",
|
"max_message_bytes": f"{ENV_PREFIX}MAX_MESSAGE_BYTES",
|
||||||
"max_inputs_per_s": f"{ENV_PREFIX}MAX_INPUTS_PER_S",
|
"max_inputs_per_s": f"{ENV_PREFIX}MAX_INPUTS_PER_S",
|
||||||
"sim_latency_ms": f"{ENV_PREFIX}SIM_LATENCY_MS",
|
"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",
|
"allowed_origins": f"{ENV_PREFIX}ALLOWED_ORIGINS",
|
||||||
|
"test_mode": f"{ENV_PREFIX}TEST_MODE",
|
||||||
"debugpy": DEBUGPY_ENV_KEY,
|
"debugpy": DEBUGPY_ENV_KEY,
|
||||||
"debugpy_port": DEBUGPY_PORT_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_MESSAGE_BYTES = 4096
|
||||||
DEFAULT_MAX_INPUTS_PER_S = 40
|
DEFAULT_MAX_INPUTS_PER_S = 40
|
||||||
DEFAULT_SIM_LATENCY_MS = 0
|
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 = False
|
||||||
DEFAULT_DEBUGPY_PORT = 5678
|
DEFAULT_DEBUGPY_PORT = 5678
|
||||||
DEFAULT_ALLOWED_ORIGINS: tuple[str, ...] = ()
|
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
|
# Accepted ranges (PLAN.md A4/A10 §10/§11); outside them the process refuses
|
||||||
# to start rather than run a broken simulation.
|
# 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_message_bytes: hard cap on an inbound WebSocket frame.
|
||||||
max_inputs_per_s: per-connection inbound message rate limit.
|
max_inputs_per_s: per-connection inbound message rate limit.
|
||||||
sim_latency_ms: injected server-side downlink latency for netcode tests.
|
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: open a debugger listener at startup (A18).
|
||||||
debugpy_port: debugger listen port.
|
debugpy_port: debugger listen port.
|
||||||
allowed_origins: `Origin` values accepted on the WebSocket handshake;
|
allowed_origins: `Origin` values accepted on the WebSocket handshake;
|
||||||
@@ -153,6 +166,8 @@ class Settings:
|
|||||||
max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES
|
max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES
|
||||||
max_inputs_per_s: int = DEFAULT_MAX_INPUTS_PER_S
|
max_inputs_per_s: int = DEFAULT_MAX_INPUTS_PER_S
|
||||||
sim_latency_ms: int = DEFAULT_SIM_LATENCY_MS
|
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: bool = DEFAULT_DEBUGPY
|
||||||
debugpy_port: int = DEFAULT_DEBUGPY_PORT
|
debugpy_port: int = DEFAULT_DEBUGPY_PORT
|
||||||
allowed_origins: tuple[str, ...] = DEFAULT_ALLOWED_ORIGINS
|
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
|
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),
|
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=_bool(env, DEBUGPY_ENV_KEY, DEFAULT_DEBUGPY),
|
||||||
debugpy_port=_int(env, DEBUGPY_PORT_ENV_KEY, DEFAULT_DEBUGPY_PORT, PORT_MIN, PORT_MAX),
|
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),
|
allowed_origins=_origins(env, f"{prefix}ALLOWED_ORIGINS", DEFAULT_ALLOWED_ORIGINS),
|
||||||
|
|||||||
@@ -2,12 +2,18 @@
|
|||||||
|
|
||||||
This is the process later phases attach to: the room supervisor
|
This is the process later phases attach to: the room supervisor
|
||||||
(`server/rooms.py`, phase 05), the WebSocket endpoint (`/ws/{code}`,
|
(`server/rooms.py`, phase 05), the WebSocket endpoint (`/ws/{code}`,
|
||||||
phase 06) and the level manifest (phase 02) all register on the app built
|
phase 06, in `server/ws.py`) and the level manifest (phase 02) all
|
||||||
here. The contract so far:
|
register on the app built here. The contract so far:
|
||||||
|
|
||||||
* ``GET /healthz`` — liveness probe for containers and tests; reports the
|
* ``GET /healthz`` — liveness probe for containers and tests; reports the
|
||||||
live room count from the supervisor,
|
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``,
|
* ``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),
|
* ``/static/*`` — the client's ES modules, CSS and art (never a CDN, A12),
|
||||||
* ``/api/levels``, ``/api/levels/{id}``, ``/api/config`` — the content API
|
* ``/api/levels``, ``/api/levels/{id}``, ``/api/config`` — the content API
|
||||||
(:mod:`undercooked.server.routes_content`), fed by the bundle loaded once
|
(: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
|
already refuses to build without a validated content bundle, so a process
|
||||||
with rooms is a process with working content. The supervisor's
|
with rooms is a process with working content. The supervisor's
|
||||||
``start``/``shutdown`` ride the lifespan (GC loop, room tick tasks).
|
``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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import APIRouter, FastAPI, Request
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from undercooked import __version__
|
from undercooked import __version__
|
||||||
from undercooked.config import ConfigError, Settings, get_settings
|
from undercooked.config import ConfigError, Settings, get_settings
|
||||||
from undercooked.debug import maybe_start_debugpy
|
from undercooked.debug import maybe_start_debugpy
|
||||||
from undercooked.server.clock import SystemClock
|
from undercooked.server.clock import SystemClock
|
||||||
|
from undercooked.server.codes import normalize_code
|
||||||
from undercooked.server.headers import SecurityHeadersMiddleware
|
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.rooms import RoomSupervisor
|
||||||
from undercooked.server.routes_content import content_router, load_validated_content
|
from undercooked.server.routes_content import content_router, load_validated_content
|
||||||
from undercooked.server.routes_rooms import rooms_router
|
from undercooked.server.routes_rooms import rooms_router
|
||||||
|
from undercooked.server.ws import ConnectionRegistry, SnapshotFanout, websocket_endpoint
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"STATIC_DIR",
|
"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
|
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:
|
def settings_log_line(settings: Settings) -> str:
|
||||||
"""A single-line, fixed-vocabulary summary of the resolved settings."""
|
"""A single-line, fixed-vocabulary summary of the resolved settings."""
|
||||||
return " ".join(f"{name}={getattr(settings, name)}" for name in LOGGED_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.settings = resolved
|
||||||
application.state.content = content # loaded + validated once, above
|
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:
|
# The supervisor exists only because the content gate above passed:
|
||||||
# that is its "feature flag" — no rooms in a process without content.
|
# 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.add_middleware(SecurityHeadersMiddleware)
|
||||||
application.include_router(content_router)
|
application.include_router(content_router)
|
||||||
application.include_router(rooms_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")
|
@application.get("/healthz")
|
||||||
async def healthz() -> dict[str, object]:
|
async def healthz() -> dict[str, object]:
|
||||||
@@ -152,6 +250,20 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
supervisor: RoomSupervisor = application.state.supervisor
|
supervisor: RoomSupervisor = application.state.supervisor
|
||||||
return {"status": "ok", "version": __version__, "rooms": len(supervisor.all_rooms())}
|
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("/")
|
@application.get("/")
|
||||||
async def index() -> FileResponse:
|
async def index() -> FileResponse:
|
||||||
"""The client shell: one HTML file, then the browser loads ES modules."""
|
"""The client shell: one HTML file, then the browser loads ES modules."""
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -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
@@ -72,6 +72,29 @@ comes from — and `round_over`'s half of the settle
|
|||||||
(`_record_round_result`) calls `store.record_result` for **every seated
|
(`_record_round_result`) calls `store.record_result` for **every seated
|
||||||
player**. A room without a store records nothing; spectators are not
|
player**. A room without a store records nothing; spectators are not
|
||||||
players.
|
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
|
from __future__ import annotations
|
||||||
@@ -79,6 +102,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import secrets
|
import secrets
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@@ -101,10 +125,19 @@ from undercooked.server.seats import (
|
|||||||
UnknownLevel,
|
UnknownLevel,
|
||||||
)
|
)
|
||||||
from undercooked.server.sessions import SessionRecord
|
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.round_state import SETTLE_S, abort, result_payload
|
||||||
from undercooked.sim.state_build import SeatSpec, build_state
|
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:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
from collections.abc import Awaitable, Callable, Mapping, Sequence
|
||||||
@@ -124,6 +157,12 @@ if TYPE_CHECKING:
|
|||||||
[KitchenState, float, Sequence[PlayerInput], RecipeBook | None],
|
[KitchenState, float, Sequence[PlayerInput], RecipeBook | None],
|
||||||
tuple[KitchenState, tuple[Event, ...]],
|
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__ = [
|
__all__ = [
|
||||||
"COUNTDOWN_S",
|
"COUNTDOWN_S",
|
||||||
@@ -242,6 +281,7 @@ class Room:
|
|||||||
seed: int | None = None,
|
seed: int | None = None,
|
||||||
sessions: SessionLookup | None = None,
|
sessions: SessionLookup | None = None,
|
||||||
store: SessionStore | None = None,
|
store: SessionStore | None = None,
|
||||||
|
on_close: Callable[[Room], Awaitable[None]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.code = code
|
self.code = code
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
@@ -257,6 +297,11 @@ class Room:
|
|||||||
#: reads its `next_level_id`, and `round_over` records the result
|
#: reads its `next_level_id`, and `round_over` records the result
|
||||||
#: into every seated player's session through it.
|
#: into every seated player's session through it.
|
||||||
self._store = store
|
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
|
#: Late joins (PLAN §6 "SPECTATING"): tokens that arrived while the
|
||||||
#: room was already past `LOBBY`. No seat, `role="spectator"`.
|
#: room was already past `LOBBY`. No seat, `role="spectator"`.
|
||||||
self.spectators: list[Spectator] = []
|
self.spectators: list[Spectator] = []
|
||||||
@@ -298,12 +343,50 @@ class Room:
|
|||||||
self.round_seed: int | None = None
|
self.round_seed: int | None = None
|
||||||
#: Why the last round ended in a crash (`None` when it did not).
|
#: Why the last round ended in a crash (`None` when it did not).
|
||||||
self.abort_reason: str | None = None
|
self.abort_reason: str | None = None
|
||||||
#: Sim events since the last `take_events()` — the phase-06 `ev`
|
#: Sim events since the last boundary (the phase-06 `ev`/snapshot
|
||||||
#: buffer; drained exactly once per 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] = []
|
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.
|
#: How many snapshot boundaries have passed since the room started.
|
||||||
self.snapshot_seq: int = 0
|
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] = []
|
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._settle_left: float | None = None
|
||||||
self._last_at: float | None = None
|
self._last_at: float | None = None
|
||||||
#: The test-only seam (the `UNDERCOOKED_TEST_HOOK`): a `Callable`
|
#: The test-only seam (the `UNDERCOOKED_TEST_HOOK`): a `Callable`
|
||||||
@@ -423,7 +506,9 @@ class Room:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def mark_seat_disconnected(self, token: str, now: float | None = None) -> bool:
|
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
|
When the marked seat held the crown, `transfer_host` runs — a
|
||||||
dropped host connection is a host *leave* for the crown's purposes
|
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:
|
def mark_seat_connected(self, token: str, now: float | None = None) -> bool:
|
||||||
"""Mark a seat's connection restored (the rejoin path, US-02).
|
"""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
|
Deliberately does **not** run `transfer_host`: a returning host does
|
||||||
not automatically regain the crown (docs/ROOMS.md "Host rules" —
|
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."""
|
"""Stamp the last meaningful event — what the lobby-idle GC measures."""
|
||||||
self.last_activity = self._clock.now() if now is None else now
|
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
|
# ------------------------------------------------------------ the FSM
|
||||||
|
|
||||||
def _fire(self, event: str) -> RoomState:
|
def _fire(self, event: str) -> RoomState:
|
||||||
@@ -508,7 +757,9 @@ class Room:
|
|||||||
self.round_seed = self._seed_cursor
|
self.round_seed = self._seed_cursor
|
||||||
self.countdown_left = COUNTDOWN_S
|
self.countdown_left = COUNTDOWN_S
|
||||||
self._queued_inputs = []
|
self._queued_inputs = []
|
||||||
|
self._parked.clear()
|
||||||
self._settle_left = None
|
self._settle_left = None
|
||||||
|
self._countdown_last_n = None
|
||||||
|
|
||||||
def request_start(self) -> None:
|
def request_start(self) -> None:
|
||||||
"""The host's *Start*: ≥ 1 ready non-host, or a solo host (PLAN §6),
|
"""The host's *Start*: ≥ 1 ready non-host, or a solo host (PLAN §6),
|
||||||
@@ -869,7 +1120,7 @@ class Room:
|
|||||||
return False
|
return False
|
||||||
recent.append(when)
|
recent.append(when)
|
||||||
self._emote_times[token] = recent
|
self._emote_times[token] = recent
|
||||||
self.events_since_snapshot.append(
|
self._buffer_event(
|
||||||
Event(
|
Event(
|
||||||
kind=EMOTE_EVENT_KIND,
|
kind=EMOTE_EVENT_KIND,
|
||||||
payload={"emote": emote_id, "player_id": token},
|
payload={"emote": emote_id, "player_id": token},
|
||||||
@@ -923,29 +1174,182 @@ class Room:
|
|||||||
|
|
||||||
# -------------------------------------------------------------- input
|
# -------------------------------------------------------------- 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:
|
def queue_input(self, player_id: str, frame: PlayerInput) -> bool:
|
||||||
"""Queue one frame of intent for the next tick.
|
"""Queue one frame of intent for the next tick.
|
||||||
|
|
||||||
Only legal in `PLAYING` (elsewhere the frame is dropped — no error:
|
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
|
a client that sent late is not at fault); a `DISCONNECTED` seat is
|
||||||
buffered `err` event (`unknown_player`), never an exception. The room
|
silently ignored (task 03 — the connection that dropped is not the
|
||||||
stamps `received_at_tick` with the sim's current tick, so input
|
one the frame arrived on, and the sim parks the cook anyway); an id
|
||||||
ordering never depends on asyncio scheduling or the client's counter.
|
no seat holds is a buffered `err` event (`unknown_player`), never an
|
||||||
The queue is per-tick: `tick_once` passes it to `step` and clears it.
|
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:
|
Returns:
|
||||||
True when the frame was queued; False when it was dropped
|
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:
|
if self.state is not RoomState.PLAYING:
|
||||||
return False
|
return False
|
||||||
if self.seat_for_token(player_id) is None:
|
seat = self.seat_for_token(player_id)
|
||||||
|
if seat is None:
|
||||||
reason = (
|
reason = (
|
||||||
"not_seated"
|
"not_seated"
|
||||||
if self.spectator_for_token(player_id) is not None
|
if self.spectator_for_token(player_id) is not None
|
||||||
else "unknown_player"
|
else "unknown_player"
|
||||||
)
|
)
|
||||||
self.events_since_snapshot.append(
|
self._buffer_event(
|
||||||
Event(
|
Event(
|
||||||
kind=EventKind.ERROR,
|
kind=EventKind.ERROR,
|
||||||
payload={
|
payload={
|
||||||
@@ -956,19 +1360,40 @@ class Room:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return False
|
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
|
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._queued_inputs.append(replace(frame, received_at_tick=tick))
|
||||||
|
self.note_input_stat("inputs_received")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def take_events(self) -> tuple[Event, ...]:
|
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
|
The buffer is drained exactly once per call: what is returned is
|
||||||
everything since the last call, and the next call starts empty.
|
everything since the last call, and the next call starts empty
|
||||||
`snapshot_seq` counts the boundary.
|
(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)
|
events = tuple(self.events_since_snapshot)
|
||||||
self.events_since_snapshot.clear()
|
self.events_since_snapshot.clear()
|
||||||
|
self._event_seqs.clear()
|
||||||
self.snapshot_seq += 1
|
self.snapshot_seq += 1
|
||||||
return events
|
return events
|
||||||
|
|
||||||
@@ -1003,6 +1428,8 @@ class Room:
|
|||||||
if self.state is not RoomState.CLOSED:
|
if self.state is not RoomState.CLOSED:
|
||||||
self._fire("close")
|
self._fire("close")
|
||||||
self.closed_at = self._clock.now()
|
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():
|
if self._task is not None and not self._task.done():
|
||||||
self._task.cancel()
|
self._task.cancel()
|
||||||
if self._task is not None and asyncio.current_task() is not self._task:
|
if self._task is not None and asyncio.current_task() is not self._task:
|
||||||
@@ -1012,6 +1439,7 @@ class Room:
|
|||||||
self.spectators.clear()
|
self.spectators.clear()
|
||||||
self._color_memory.clear()
|
self._color_memory.clear()
|
||||||
self._emote_times.clear()
|
self._emote_times.clear()
|
||||||
|
self._parked.clear()
|
||||||
|
|
||||||
async def tick_loop(self) -> None:
|
async def tick_loop(self) -> None:
|
||||||
"""The room's one task: one `tick_once` per period until closed.
|
"""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:
|
def _tick(self, dt: float, when: float) -> None:
|
||||||
"""One tick, by state (the body `tick_once` crash-isolates)."""
|
"""One tick, by state (the body `tick_once` crash-isolates)."""
|
||||||
state = self.state
|
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:
|
if state is RoomState.COUNTDOWN:
|
||||||
self.countdown_left = max(0.0, self.countdown_left - dt)
|
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:
|
if self.countdown_left <= _EPS_S:
|
||||||
self._fire("countdown_done")
|
self._fire("countdown_done")
|
||||||
self._build_sim_state()
|
self._build_sim_state()
|
||||||
@@ -1067,6 +1513,24 @@ class Room:
|
|||||||
if self.sim_state is None or self._content is None:
|
if self.sim_state is None or self._content is None:
|
||||||
msg = f"room {self.code} is PLAYING with no built state (internal invariant)"
|
msg = f"room {self.code} is PLAYING with no built state (internal invariant)"
|
||||||
raise RuntimeError(msg)
|
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)
|
queued = tuple(self._queued_inputs)
|
||||||
self._queued_inputs = []
|
self._queued_inputs = []
|
||||||
stepper = (
|
stepper = (
|
||||||
@@ -1077,7 +1541,16 @@ class Room:
|
|||||||
self.sim_state, events = stepper(
|
self.sim_state, events = stepper(
|
||||||
self.sim_state, dt, queued, self._content.recipe_book
|
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:
|
if self.sim_state.result is not None:
|
||||||
# The settle (sim/round_state.SETTLE_S): the first tick the
|
# The settle (sim/round_state.SETTLE_S): the first tick the
|
||||||
# verdict lands opens the window; it closes SETTLE_S of wall
|
# 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.last_result = result_payload(self.sim_state)
|
||||||
self._record_round_result()
|
self._record_round_result()
|
||||||
self._fire("round_over")
|
self._fire("round_over")
|
||||||
|
self._broadcast_round()
|
||||||
return
|
return
|
||||||
# LOBBY or ROUND_END: the idle checks, the same rules and numbers the
|
# 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
|
# 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__}"
|
self.abort_reason = f"round {self.round_no} tick crashed: {type(exc).__name__}"
|
||||||
if self.sim_state is not None:
|
if self.sim_state is not None:
|
||||||
self.sim_state = abort(self.sim_state, self.abort_reason)
|
self.sim_state = abort(self.sim_state, self.abort_reason)
|
||||||
self.events_since_snapshot.append(
|
self._buffer_event(
|
||||||
Event(
|
Event(
|
||||||
kind=EventKind.ERROR,
|
kind=EventKind.ERROR,
|
||||||
payload={"reason": "tick_crash", "round": self.round_no},
|
payload={"reason": "tick_crash", "round": self.round_no},
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ loaded). It owns:
|
|||||||
`settings.lobby_idle_timeout_s` is closed, already-closed rooms are
|
`settings.lobby_idle_timeout_s` is closed, already-closed rooms are
|
||||||
dropped, and stale token-index entries are purged,
|
dropped, and stale token-index entries are purged,
|
||||||
* **teardown** — `start()` runs the GC loop and every room's tick task;
|
* **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
|
* **the session store** — a bounded LRU `SessionStore` (task 04): every
|
||||||
token that created or joined a room is remembered there, and the
|
token that created or joined a room is remembered there, and the
|
||||||
progression (`unlocked_level`, the best scores, the counters) is 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;
|
code_rng: a `random.Random` for deterministic code minting in tests;
|
||||||
`None` draws with `secrets.choice`.
|
`None` draws with `secrets.choice`.
|
||||||
sleep: the GC loop's sleep; `None` = `asyncio.sleep`.
|
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__(
|
def __init__(
|
||||||
@@ -116,12 +127,16 @@ class RoomSupervisor:
|
|||||||
*,
|
*,
|
||||||
code_rng: random.Random | None = None,
|
code_rng: random.Random | None = None,
|
||||||
sleep: Callable[[float], Awaitable[None]] | 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:
|
) -> None:
|
||||||
self._settings = settings
|
self._settings = settings
|
||||||
self._clock = clock
|
self._clock = clock
|
||||||
self._content = content
|
self._content = content
|
||||||
self._code_rng = code_rng
|
self._code_rng = code_rng
|
||||||
self._sleep = sleep if sleep is not None else asyncio.sleep
|
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._rooms: dict[str, Room] = {}
|
||||||
self._token_index: dict[str, str] = {}
|
self._token_index: dict[str, str] = {}
|
||||||
self._sessions = SessionStore(
|
self._sessions = SessionStore(
|
||||||
@@ -191,6 +206,7 @@ class RoomSupervisor:
|
|||||||
content=self._content,
|
content=self._content,
|
||||||
sessions=self.session_for,
|
sessions=self.session_for,
|
||||||
store=self._sessions,
|
store=self._sessions,
|
||||||
|
on_close=self._room_close,
|
||||||
)
|
)
|
||||||
room.add_seat(
|
room.add_seat(
|
||||||
session.token,
|
session.token,
|
||||||
@@ -205,6 +221,16 @@ class RoomSupervisor:
|
|||||||
self._sessions.put(session)
|
self._sessions.put(session)
|
||||||
self._rooms[code] = room
|
self._rooms[code] = room
|
||||||
self._token_index[session.token] = code
|
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
|
return room
|
||||||
|
|
||||||
def join(
|
def join(
|
||||||
@@ -238,7 +264,7 @@ class RoomSupervisor:
|
|||||||
now = self._clock.now()
|
now = self._clock.now()
|
||||||
existing = room.seat_for_token(token)
|
existing = room.seat_for_token(token)
|
||||||
if existing is not None:
|
if existing is not None:
|
||||||
existing.mark_connected(now)
|
room.on_reconnect(token, now=now)
|
||||||
if display_name is not None:
|
if display_name is not None:
|
||||||
existing.display_name = sanitize_name(display_name, existing.seat_index)
|
existing.display_name = sanitize_name(display_name, existing.seat_index)
|
||||||
room.note_activity(now)
|
room.note_activity(now)
|
||||||
@@ -324,7 +350,7 @@ class RoomSupervisor:
|
|||||||
code = self._lookup(raw_code)
|
code = self._lookup(raw_code)
|
||||||
room = self._rooms[code]
|
room = self._rooms[code]
|
||||||
now = self._clock.now()
|
now = self._clock.now()
|
||||||
if room.mark_seat_disconnected(token, now=now):
|
if room.on_disconnect(token, now=now):
|
||||||
return
|
return
|
||||||
if room.mark_spectator_disconnected(token, now=now):
|
if room.mark_spectator_disconnected(token, now=now):
|
||||||
return
|
return
|
||||||
@@ -439,6 +465,8 @@ class RoomSupervisor:
|
|||||||
return
|
return
|
||||||
self._gc_task = asyncio.create_task(self.gc_loop(), name="room-gc")
|
self._gc_task = asyncio.create_task(self.gc_loop(), name="room-gc")
|
||||||
for room in self._rooms.values():
|
for room in self._rooms.values():
|
||||||
|
if self._room_ready is not None:
|
||||||
|
self._room_ready(room)
|
||||||
room.start_ticking()
|
room.start_ticking()
|
||||||
|
|
||||||
async def shutdown(self) -> None:
|
async def shutdown(self) -> None:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -362,6 +362,10 @@ class EventKind(StrEnum):
|
|||||||
TRASHED_ALREADY_EMPTY = "trashed_already_empty" # an empty hand at threshold (sim/carry.py)
|
TRASHED_ALREADY_EMPTY = "trashed_already_empty" # an empty hand at threshold (sim/carry.py)
|
||||||
TICK_SLOW = "tick_slow"
|
TICK_SLOW = "tick_slow"
|
||||||
ERROR = "err" # a malformed or unattributable input, never an exception
|
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)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
@@ -234,7 +234,14 @@ def apply_stun(
|
|||||||
updated.append(
|
updated.append(
|
||||||
player
|
player
|
||||||
if player.state is not PlayerState.STUNNED
|
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
|
continue
|
||||||
if player.state is not PlayerState.STUNNED:
|
if player.state is not PlayerState.STUNNED:
|
||||||
@@ -253,7 +260,12 @@ def apply_stun(
|
|||||||
player,
|
player,
|
||||||
stun=0.0 if revived else remaining,
|
stun=0.0 if revived else remaining,
|
||||||
vel=Vec2(),
|
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)
|
return tuple(updated), tuple(events)
|
||||||
@@ -428,6 +440,11 @@ def resolve_moves(
|
|||||||
if player.stun > 0.0:
|
if player.stun > 0.0:
|
||||||
moved.append(player)
|
moved.append(player)
|
||||||
continue
|
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))
|
vel = desired_velocity(player, want, dt, tractions.get(player.id))
|
||||||
moved.append(
|
moved.append(
|
||||||
replace(
|
replace(
|
||||||
|
|||||||
@@ -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
@@ -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]
|
||||||
@@ -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
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -669,15 +669,19 @@ def test_queue_input_rejects_an_unknown_player_with_an_err_event() -> None:
|
|||||||
"player_id": "tok-stranger",
|
"player_id": "tok-stranger",
|
||||||
"seq": 0,
|
"seq": 0,
|
||||||
}
|
}
|
||||||
# A known seat is queued, stamped with the sim's tick, and the queue is
|
# A known seat is queued, stamped with the sim's tick, and the queue
|
||||||
# per-tick: the next `tick_once` consumes and clears it.
|
# 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)
|
frame = PlayerInput(player_id="tok-a", seq=7)
|
||||||
assert room.queue_input("tok-a", frame) is True
|
assert room.queue_input("tok-a", frame) is True
|
||||||
state = room.sim_state
|
state = room.sim_state
|
||||||
assert state is not None
|
assert state is not None
|
||||||
assert room._queued_inputs[-1].received_at_tick == state.tick
|
assert room._queued_inputs[-1].received_at_tick == state.tick
|
||||||
assert room._queued_inputs[-1].seq == 7
|
assert room._queued_inputs[-1].seq == 7
|
||||||
room.tick_once()
|
room.tick_once(now=3.0 + 0.05)
|
||||||
assert room._queued_inputs == []
|
assert room._queued_inputs == []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -287,11 +287,16 @@ def test_one_room_fails_without_affecting_the_other(
|
|||||||
bring_both_to_playing(room_a, room_b, clock)
|
bring_both_to_playing(room_a, room_b, clock)
|
||||||
|
|
||||||
inject_the_fault(room_a)
|
inject_the_fault(room_a)
|
||||||
# The crash tick — isolated: it must not propagate. The wall
|
# The crash tick — isolated: it must not propagate. The task-03
|
||||||
# time is not advanced: A takes this tick at the last wall time
|
# substep guard makes a dt-0 tick skip `step` entirely (a
|
||||||
# (dt 0), so B's cadence below stays exactly one 0.05 s frame
|
# zero-substep tick would drop queued inputs without applying
|
||||||
# per substep.
|
# 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_a.tick_once()
|
||||||
|
room_b._last_at = clock.now()
|
||||||
|
|
||||||
check_room_a_is_aborted(room_a, caplog, ada.token, bob.token)
|
check_room_a_is_aborted(room_a, caplog, ada.token, bob.token)
|
||||||
check_room_b_keeps_ticking(room_b, clock)
|
check_room_b_keeps_ticking(room_b, clock)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -210,6 +210,7 @@ def test_the_docs_on_disk_are_the_shipped_documents() -> None:
|
|||||||
"docs/DESIGN.md",
|
"docs/DESIGN.md",
|
||||||
"docs/DEPLOYMENT.md",
|
"docs/DEPLOYMENT.md",
|
||||||
"docs/LEVELS.md",
|
"docs/LEVELS.md",
|
||||||
|
"docs/PROTOCOL.md",
|
||||||
"docs/ROOMS.md",
|
"docs/ROOMS.md",
|
||||||
"docs/SIMULATION.md",
|
"docs/SIMULATION.md",
|
||||||
"docs/TESTING.md",
|
"docs/TESTING.md",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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": {}}
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user