diff --git a/.agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/00_phase.md b/.agents/phases/complete/05_anonymous_lobbies_and_room_lifecycle/00_phase.md similarity index 100% rename from .agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/00_phase.md rename to .agents/phases/complete/05_anonymous_lobbies_and_room_lifecycle/00_phase.md diff --git a/.agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/01_room_supervisor_and_codes.md b/.agents/phases/complete/05_anonymous_lobbies_and_room_lifecycle/01_room_supervisor_and_codes.md similarity index 100% rename from .agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/01_room_supervisor_and_codes.md rename to .agents/phases/complete/05_anonymous_lobbies_and_room_lifecycle/01_room_supervisor_and_codes.md diff --git a/.agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/02_room_state_machine.md b/.agents/phases/complete/05_anonymous_lobbies_and_room_lifecycle/02_room_state_machine.md similarity index 100% rename from .agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/02_room_state_machine.md rename to .agents/phases/complete/05_anonymous_lobbies_and_room_lifecycle/02_room_state_machine.md diff --git a/.agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/03_lobby_state_and_presence.md b/.agents/phases/complete/05_anonymous_lobbies_and_room_lifecycle/03_lobby_state_and_presence.md similarity index 100% rename from .agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/03_lobby_state_and_presence.md rename to .agents/phases/complete/05_anonymous_lobbies_and_room_lifecycle/03_lobby_state_and_presence.md diff --git a/.agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/04_session_store_and_progression.md b/.agents/phases/complete/05_anonymous_lobbies_and_room_lifecycle/04_session_store_and_progression.md similarity index 100% rename from .agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/04_session_store_and_progression.md rename to .agents/phases/complete/05_anonymous_lobbies_and_room_lifecycle/04_session_store_and_progression.md diff --git a/.agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/05_lifecycle_integration_suite.md b/.agents/phases/complete/05_anonymous_lobbies_and_room_lifecycle/05_lifecycle_integration_suite.md similarity index 100% rename from .agents/phases/todo/05_anonymous_lobbies_and_room_lifecycle/05_lifecycle_integration_suite.md rename to .agents/phases/complete/05_anonymous_lobbies_and_room_lifecycle/05_lifecycle_integration_suite.md diff --git a/AGENTS.md b/AGENTS.md index 59661ea..a79c8ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,13 @@ which is the only writer of `os.environ` (so a uvicorn `--reload` child re-reads the same values). `DEBUGPY=1` binds a debugger listener on `0.0.0.0` — dev-only, never on a public host. +- **Time flows through an injected `Clock`.** Every time-dependent object in + `server/` (supervisor, rooms, the session store) receives a + `Clock` (`server/clock.py`: `SystemClock`, `FakeClock`) and no module + outside `clock.py` may import `time`; `tests/unit/test_server_clock_discipline.py` + greps for the `time.monotonic()`/`time.time()` call literals and for stray + `time` imports, and the GC loop's sleep is injectable — tests drive the + whole lifecycle with a `FakeClock` and never sleep. - **No external CDNs or remote assets.** `tests/unit/test_no_external_assets.py` fails the build on `http://`/`https://`/`//host` references, `@import url(`, `srcset`, an absolute-URL `fetch()`/`import`, an absolute-URL `WebSocket`, or @@ -181,7 +188,7 @@ | Serve resolution and scoring: the dish's named rejections (`NO_ORDERS`/`WRONG_RECIPE`/`INCOMPLETE`/`BURNT`/`NO_TARGET`), FIFO order matching, `round(base × quality × streak × time_bonus)` with the capped streak and the deadline share, the miss penalty and streak reset, the per-player contribution (rules: `docs/SIMULATION.md` §"Serve scoring") | `src/undercooked/sim/scoring.py` | | Level/recipe content | `levels/*.yaml` | | Content schema, ASCII-grid parser, content loader, playability validator (contract: `docs/LEVELS.md`) | `src/undercooked/sim/recipe.py`, `level.py`, `content.py`, `validation.py` | -| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py`, `room.py` | +| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py` (the supervisor: registry, capacity, the one-token-one-room index, the 5 s GC, `start`/`shutdown`), `room.py` (the guarded FSM: the `RoomState`/`ROOM_TRANSITIONS` table, the round data, the per-tick input queue, the one owned deadline-scheduled tick task and its crash isolation, `tick_once` — contract: `docs/ROOMS.md`), `clock.py` (injectable time — the only `server/` module that imports `time`; a grep test keeps the wall clock out of the rest), `codes.py` (Crockford code minting + input normalisation), `seats.py` (the lobby's data: `Seat`/`Spectator`, the name boundary, the 4-colour palette, the host handoff, the emote whitelist, the named lobby refusals), `routes_rooms.py` (`POST /api/rooms`, `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join`, `GET /api/session` — the lifecycle over HTTP before the phase-06 transport), `sessions.py` (`SessionRecord` + `BestRun` + `mint_token` + the bounded LRU `SessionStore`: `record_result`'s win-only unlock bump (monotonic), best-score replacement, `is_unlocked` / `next_level_id`, `to_public` — in memory; a process restart clears all progression) | | Wire messages + validation | `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md` | | HTTP app (`/`, `/healthz`, `/static`), lifespan, startup content gate | `src/undercooked/server/app.py` | | Content HTTP API (`/api/levels`, `/api/levels/{id}`, `/api/config`) + strict response models | `src/undercooked/server/routes_content.py` | diff --git a/README.md b/README.md index 9e991ab..7760df6 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,22 @@ email, no password: you create a room, share the 5-character code, play. Phases **01** (runnable service scaffold), **02** (level content, schema and validation), **03** (the deterministic simulation kernel: fixed-step tick, -movement and collision, the action button, the hands, the replay goldens) -and **04** (the kitchen pipeline: station state machines, the ticket rail, +movement and collision, the action button, the hands, the replay goldens), +**04** (the kitchen pipeline: station state machines, the ticket rail, serve scoring, freshness and the dirty loop, the round verdict with stars) -are **complete**; the next pending phase is -`05_anonymous_lobbies_and_room_lifecycle`. What +and **05** (anonymous lobbies and room lifecycle: the `RoomSupervisor`, +the guarded `Room` FSM, the in-memory session store, the lifecycle +integration suite) are **complete**; the next pending phase is +`06_realtime_websocket_protocol`. What runs today: - **The service.** The Python toolchain is locked (`uv.lock`), configuration is a single typed, validated `Settings` object (`DEBUGPY` support included), and `uv run undercooked` starts a FastAPI server that serves the client shell at `/`, the browser assets from `app/static/`, and `GET /healthz` — which answers - `{"status":"ok","version":"0.1.0","rooms":0}` (`rooms` stays `0` until phase 05 owns - a supervisor) — with the security headers (CSP `default-src 'self'`) on every - response. + `{"status":"ok","version":"0.1.0","rooms":}` from the room + supervisor (phase 05) — with the security headers (CSP `default-src 'self'`) on + every response. - **The content.** The recipe book and the first two campaign levels live in `levels/`; `sim/level.py` parses them into typed models, `sim/validation.py` runs PLAN §8's named rules, and `create_app` loads + validates that bundle at startup and @@ -174,15 +176,58 @@ runs today: byte-stability across two identical runs and across the settle window); the contract is `docs/SIMULATION.md` §"Round end — the verdict, the settle, the stars". +- **The room supervisor (phase 05).** The server-side lobby shell is + live: `server/rooms.py` mints 5-character Crockford base32 codes (collision- + checked, never reused), enforces `max_rooms` / `max_players` capacity, keeps a + one-token-one-room index (a token joining a second room is evicted from the + first; a *disconnected* seat is kept for the rejoin path), and runs the 5 s GC + (empty rooms past a 30 s grace, idle lobbies past `lobby_idle_timeout_s`, stale + tokens). Everything runs on an injected clock (`server/clock.py`), so the tests + drive the garbage collector with a `FakeClock` and never sleep. The lifecycle is + testable over HTTP before the phase-06 WebSocket transport exists: + `POST /api/rooms` (201, the session from the `X-Undercooked-Token` + header or the body's `token` — or a server-minted token, returned + once), + `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join` and + `GET /api/session` — seats never leak + other players' tokens, a malformed code 404s exactly like an unknown one + (US-02 criterion 4), and a full room or a full process is a 503 + `{"code": "room_full"}` (US-01 criterion 7). The `Room` itself is the guarded + FSM behind those routes — five states, a table of fourteen legal + transitions, one 20 Hz deadline-scheduled tick task per room and crash + isolation that closes only the room that crashed (contract: `docs/ROOMS.md`), + and the ready-up/host flow rides on it. The in-memory **session store** + (`server/sessions.py`) is live on top: per token it remembers the campaign + progression — `unlocked_level` (a `WON` round raises it to `order + 1`, + never decreases it), `best[level_id]` (a `BestRun` of `score`/`stars`/ + `completed_at`, replaced only when the score is higher), the + `rounds_played` / `dishes_served` counters — LRU-capped at 10 000 (evicting + the stalest, never the session being created) and wiped on restart (A6); + `Room.set_level` asks the store's `is_unlocked`, `round_over` records the + result into every seated player's session, and `GET /api/session` answers + `to_public` (a fresh token reads `unlocked: 1` with an empty `best`; 400 + `no_token` without the header) (contract: `docs/ROOMS.md` "Progression"). + The **lifecycle integration suite** (phase 05 task 05) drives the whole + lifecycle over HTTP plus direct `tick_once`: create → 3 joins → ready-up + → the host's start → the countdown → a scripted full-pipeline `WON` → the + `ROUND_END` payload → `next_level` → back to `LOBBY` with the same seats + and colours; plus a solo win with the unlock advanced, the 5th join's and + the process-capacity 503 `room_full`, malformed ≡ unknown codes (US-02 + criterion 4), the seat-grace and lobby-idle expiries through the GC, and + one room's crash closing only itself (the test-only `_test_step_override` + seam on `Room`; contract: `docs/ROOMS.md` "Failure and expiry" and + "The lifecycle walkthrough"). The client + buttons still answer + "Not implemented until phase 07." until phases 06/07 wire the transport. - **The rules are tests, not promises.** The no-external-asset rule is `tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is `tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by `tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the phase pipeline runs after every task. -What does not exist yet: the hazards (phase 10), rooms and the +What does not exist yet: the hazards (phase 10), the WebSocket -protocol (05-06 — the lobby buttons answer "Not implemented until phase 05.") and the +protocol (06 — the lobby buttons answer "Not implemented until phase 07.") and the playable Canvas renderer (07). Execute phases with the `phased-execution` skill (`auto-phase.sh`); it always takes the lowest-numbered directory still in `.agents/phases/todo/`. @@ -195,13 +240,21 @@ readies up, and the kitchen starts. Each cook has one movement control and **one button** that picks up, uses and puts down whatever is in reach — one carried item at a time. Orders appear in the HUD with a shrinking deadline; assemble the dish and serve it at the window before the timer runs out. Levels escalate: more recipes, fewer stations, -hostile floors. Nothing is saved between rounds and nothing identifies you: the browser +hostile floors. Nothing identifies you: the browser keeps only a random per-tab token and the last code you typed, both in `sessionStorage`. +The campaign does remember per token — unlocks and best scores live server-side, +in memory, and a server restart clears all progression (A6: no database). -What actually works today is the shell of that: the page loads, normalises a typed code to -five uppercase alphanumerics (codes themselves are Crockford base32, and only get issued -once phase 05 runs the room supervisor), and answers "Not implemented until phase 05." -Movement keys and touch controls are defined by the phase-07/08 client and documented in +What actually works today is the shell of that plus the server-side room shell: +the page loads, normalises a typed code to five uppercase alphanumerics (codes +themselves are Crockford base32, minted by the room supervisor — `server/rooms.py`, +phase 05, which is live and testable over `POST /api/rooms` / `GET /api/rooms/{code}` / +`POST /api/rooms/{code}/join` / `GET /api/session`), and the lobby buttons still answer "Not implemented +until phase 07." until the phase-06 WebSocket transport and the phase-07 client +wiring replace them. The ready-up, the 20 Hz tick ownership, the session store +(the unlocks and best scores, in memory — a server restart clears all +progression) and the lifecycle integration suite are live. Movement keys and +touch controls are defined by the phase-07/08 client and documented in `docs/CLIENT.md` once that phase writes it. ## Quickstart (development) @@ -217,6 +270,9 @@ uv run undercooked --reload # dev reload uv run undercooked --levels-dir levels # point at another content dir curl -fsS http://127.0.0.1:8000/api/levels # the campaign cards the lobby will show curl -fsS http://127.0.0.1:8000/api/config # client-safe settings + protocol version +curl -fsS -X POST http://127.0.0.1:8000/api/rooms # create a room: 5-char code + your token (201) +curl -fsS http://127.0.0.1:8000/api/rooms/ # inspect it: the seat list, no tokens +curl -fsS -H 'X-Undercooked-Token: ' http://127.0.0.1:8000/api/session # your session: unlocked, best (in memory; a restart clears it) ``` `python -m undercooked` is the same command (the container uses it). A flag @@ -363,7 +419,29 @@ src/undercooked/sim/replay.py the replay harness: the input-script format, the src/undercooked/tools/levels.py the `undercooked-levels` author CLI — validate, list, show [--grid], new, axes; same loader, same rules, no hard-coded content list (docs/LEVELS.md "Authoring workflow") -src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`, mounts the content API +src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`, mounts the + content API and the room API; installs the supervisor +src/undercooked/server/clock.py injectable time: `Clock`, `SystemClock`, `FakeClock` + (the only server module that imports `time`; a grep + test keeps the wall clock out of the rest of `server/`) +src/undercooked/server/codes.py Crockford room codes: the alphabet, collision-checked + minting (`new_code`), input normalisation (confusables, + malformed ≡ unknown, US-02 criterion 4) +src/undercooked/server/room.py `Room`: code, seats (the rejoin path keeps + disconnected seats), `RoomState`, one owned tick task +src/undercooked/server/rooms.py `RoomSupervisor`: the registry, capacity, the + one-token-one-room index, the 5 s GC, `start`/`shutdown` +src/undercooked/server/sessions.py `SessionRecord` + `BestRun` + `mint_token` + + the bounded LRU `SessionStore` (`record_result`'s win-only + unlock bump, best-score replacement, `is_unlocked` / + `next_level_id`, `to_public` — in memory; a process + restart clears all progression) +src/undercooked/server/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 WebSocket transport (session + from the `X-Undercooked-Token` header or the body's + `token`, minted otherwise; tokens returned once; seats never + leak tokens) src/undercooked/server/routes_content.py `/api/levels`, `/api/levels/{id}`, `/api/config`, plus the startup gate: broken content refuses startup src/undercooked/server/headers.py the security-header middleware diff --git a/app/static/js/app.js b/app/static/js/app.js index a103e4f..4f5ec1a 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1,8 +1,9 @@ // Undercooked — client shell entry point (phase 01). // -// There is no game logic here on purpose. Phase 05 replaces the placeholder -// button handlers with the lobby flow, phase 06 adds net/ (WebSocket + snapshots), -// phase 07 adds render/ (Canvas 2D). What this file owns today: +// There is no game logic here on purpose. Phase 05 shipped the server-side +// room shell (supervisor, room FSM, session store); phase 06 adds the WebSocket +// transport and phase 07 replaces the placeholder button handlers with the lobby +// flow and adds render/ (Canvas 2D). What this file owns today: // * the anonymous per-tab session token (PLAN.md A16), // * the last room code typed in this tab, so a reload can rejoin fast, // * mounting the lobby screen and announcing state into #status. @@ -12,7 +13,7 @@ const TOKEN_KEY = "uc.token"; const LAST_CODE_KEY = "uc.lastCode"; const CODE_LENGTH = 5; -const NOT_IMPLEMENTED = "Not implemented until phase 05."; +const NOT_IMPLEMENTED = "Not implemented until phase 07."; const READY_MESSAGE = "Pick a kitchen to start."; /** Base64url without padding, so the token is safe in a query string or header. */ @@ -92,7 +93,8 @@ export function mount(root = document) { if (createButton) { createButton.addEventListener("click", () => { - // Phase 05: POST to the room supervisor and open /ws/{code}. + // Phase 07 (on the phase-06 transport): POST to the room supervisor, + // open /ws/{code}. announce(NOT_IMPLEMENTED, root); }); } @@ -108,7 +110,8 @@ export function mount(root = document) { return; } rememberCode(code); - // Phase 05: join the room over the WebSocket. + // Phase 07 (on the phase-06 transport): join the room over the + // WebSocket. announce(NOT_IMPLEMENTED, root); }); } diff --git a/docs/ROOMS.md b/docs/ROOMS.md new file mode 100644 index 0000000..ca077ae --- /dev/null +++ b/docs/ROOMS.md @@ -0,0 +1,465 @@ +# Rooms — the lobby-to-result state machine + +## Purpose + +This is the contract for the `Room` (`src/undercooked/server/room.py`) and the +`RoomSupervisor` (`src/undercooked/server/rooms.py`): the explicit finite state +machine that turns a level plus a set of seats into a running simulation and a +result, the room's data, its one 20 Hz deadline-scheduled tick task, its crash +isolation, the supervisor's bookkeeping, and what a restart loses. Read it +before touching either module; the wire messages that cross this boundary are +phase 06's (`docs/PROTOCOL.md`). + +The unit that turns a level plus a set of seats into a running simulation and +back is the `Room` (`src/undercooked/server/room.py`), watched over by the +`RoomSupervisor` (`src/undercooked/server/rooms.py`). This file states what +each state accepts from the client in words; phase 06 pins the per-state +message whitelist on top of it. + +## The state machine + +Five states, nine events, a table of exactly fourteen legal pairs. The table is +data (`ROOM_TRANSITIONS`), not `if`-ladders: `Room._fire(event)` is a dict +lookup — anything not in the table raises `IllegalTransition`, and every state +change in the room goes through that one door (a test greps the room module for +ad-hoc state writes and fails on a bypass). + +``` + host_start + +--------+ (>=1 ready non-host, +-----------+ + | LOBBY | or solo host, and a | COUNTDOWN | + +--------+ selected level) +-----------+ + ^ ^ (3.0 s) + | | to_lobby / retry / countdown_done + | | next_level (builds the round) + | +------------------------->| + | +---------+| + | | PLAYING | + | +---------+ + | round_over + | +-----------+ + +---------------- | ROUND_END | + +-----------+ + (any state) close + | + +--------+ + | CLOSED | (absorbing) + +--------+ +``` + +| From \ Event | `host_start` | `countdown_done` | `round_over` | `to_lobby` | `retry` | `next_level` | `close` | `all_seats_empty` | `idle_expired` | +|---|---|---|---|---|---|---|---|---|---| +| `LOBBY` | `COUNTDOWN` | | | | | | `CLOSED` | `CLOSED` | `CLOSED` | +| `COUNTDOWN` | | `PLAYING` | | | | | `CLOSED` | | | +| `PLAYING` | | | `ROUND_END` | | | | `CLOSED` | | | +| `ROUND_END` | | | | `LOBBY` | `COUNTDOWN` | `COUNTDOWN` | `CLOSED` | `CLOSED` | `CLOSED` | +| `CLOSED` | | | | | | | (none) | (none) | (none) | + +The public verbs are thin wrappers over the table: `request_start` checks the +lobby preconditions (a selected level, and either ≥ 1 ready non-host or a solo +host — `StartRefused` names which one is missing), resets the round via +`_begin_round`, and fires `host_start`; `retry` and `next_level` fire first +(retry re-rolls the level's seed, `next_level` resolves the level in the +campaign first) and share the same round reset; `close` fires `close` (a no-op +already `CLOSED`) and stops the tick task. + +## The room's data + +| Field | Meaning | +|---|---| +| `code` | the Crockford code the supervisor minted (5 chars, unique, never reused) | +| `seats` / `is_full` / `add_seat` / `remove_seat` | the seated players: `token`, `display_name` (sanitized at the boundary), `host`, `player_id` (the sim identity), `color` (the first-free palette colour, remembered per token — "The lobby"), ready-up flags; `add_seat` takes the lowest free slot, `ValueError` when full | +| `level` / `level_id` | the campaign level a round is built from; `select_level` resolves an id in the room's content (a data change, not a transition) | +| `state` / `state_since` | the FSM state and when it was entered (the injected `Clock`) | +| `countdown_left` | the 3.0 s of `COUNTDOWN`, decremented by wall time | +| `sim_state` | the round's `KitchenState` — the single source of truth while playing; the server's copy only, it never leaves the process | +| `last_result` | the `sim.round_state.result_payload` of the last finished round (`verdict`, `round_no`, `score`, `time_left`, `deadline_share`) | +| `round_no` / `round_seed` | the round counter (1, 2, …) and its seed: round 1 plays the base seed, every later round steps the LCG cursor — a fresh, and from one base pinned, seed | +| `events_since_snapshot` / `take_events` | the sim events (scored, wasted, burnt, …) since the last client snapshot; drained, never accumulated | +| `snapshot_seq` | the room's snapshot counter (the phase 06 transport stamps it on frames) | + +## One 20 Hz tick task, deadline-scheduled + +Every live room owns exactly **one** asyncio task — the +`RoomSupervisor.start()` contract is "the GC loop and every room's tick task". +`Room.start()` creates it (and refuses a double `start`); `Room.close()` +cancels it and awaits its unwind (an idempotent, ordered teardown: a second +`close()` is a no-op, a `CLOSED` tick returns early, and `CancelledError` +passes through the crash isolation untouched). + +The loop never `sleep`s a fixed period. It schedules on the injected +`Clock`'s `now()`: each iteration waits `deadline - now`, where `deadline` +starts at the first tick and advances by exactly `1/tick_hz`; after the +`Clock` has jumped (a lid, a test advancing a `FakeClock`) the deadline +resynchronises to `now + period`, so at most one catch-up tick can happen and +it is clamped to `MAX_DT` by the sim — no catch-up burst. One +`Room.tick_once(now)` per deadline; with a `FakeClock` the whole cadence is a +deterministic test (100 ticks over exactly 5.0 s, no drift). + +`tick_once`'s semantics per state (the body the loop crash-isolates): + +- **`COUNTDOWN`** — decrement `countdown_left` by the wall `dt` (floored at 0); + at zero, fire `countdown_done` and build the round. +- **`PLAYING`** — `sim.step(sim_state, dt, queued, recipe_book)`: the kernel's + own `STEP_DT` substeps, `dt` clamped to `[0, MAX_DT]` by the sim (a jump + costs at most two substeps). The per-tick input queue is drained into the + step (the sim stamps `received_at_tick`); the sim's events are appended to + `events_since_snapshot`. When the sim's `result` lands, the room opens the + **settle** — `sim.round_state.SETTLE_S` (1.0 s) of wall time, the sim in its + frozen stage-7 machine in between — and only then captures + `last_result = result_payload(sim_state)` and fires `round_over`. +- **`LOBBY` / `ROUND_END`** — the room's own idle checks (the same rules and + numbers the supervisor's GC applies externally, so a room closes itself even + if the GC is a cycle late): `all_seats_empty` once the room has been empty + past `SEAT_GRACE_S` (30 s), `idle_expired` once `now - last_activity` passed + `lobby_idle_timeout_s`. `remove_seat`/`set_ready`/`queue_input` stamp + `last_activity` (an empty room stamps `empty_since`). +- the first tick of a room's life carries `dt = 0` (the prime); `dt` is + floored at 0, so a backwards `Clock` never un-runs the sim. + +Inputs: `queue_input` accepts a `sim.model.PlayerInput` only while `PLAYING` +and for a seated token (an unknown token is an `err` event — the room's only +in-band error channel; a dropped input outside playing is a silent no-op, the +client is not at fault). The queue is per-tick: drained into the step, cleared +after it, never carried between ticks. + +## Crash isolation + +The whole `tick_once` body is wrapped: any exception (a sim bug, a bad level +payload) is **logged with the room code and round — never a player token** — +and the room aborts: `sim.round_state.abort` marks the sim (`abort_reason`), +an `err` event is buffered, `close` fires, and the exception **never +propagates** out of the loop. One crashing room closes only that room: +the supervisor's other rooms keep ticking, the GC loop keeps running, the +server stays up. A `CLOSED` room refuses new seats and inputs (the phase 06 +whitelist enforces the same on the wire). + +## The supervisor + +`RoomSupervisor` (one per process, built by `create_app`) owns: + +- the **code minting** — `server/codes.py`'s Crockford alphabet, 5 chars, + no ambiguous set, unique against the live registry (a collision re-rolls); +- **capacity** — at most `max_rooms` (default 200) rooms at once; `create_room` + past the cap is refused, and a room that is `full` refuses new seats; +- the **one-token-one-room** index — a token sits in at most one room, ever; + `create_room` rejects a token that already has a room, and a room's + `remove_seat` clears it (joining another room while seated elsewhere is + impossible by construction, not by a check); +- the **5 s GC** — a `FakeClock`-driven loop that closes empty rooms past + `SEAT_GRACE_S` and idle rooms past `lobby_idle_timeout_s`, and prunes the + room index of `CLOSED` rooms; +- `start()` (the GC loop + every room's tick task) and `shutdown()` (cancel + the GC, close every room); +- the **session store** — a bounded LRU `SessionStore` (task 04): every + token that created or joined a room is remembered there, and the + progression (`unlocked_level`, the best scores, the counters) is the + store's to own; `session_for` reads through it (task 03's `set_level` + unlock check and `lobby_payload`'s `unlocked` read the session's + `unlocked_level` through this), the rooms get it as their `store`, + and `POST /api/rooms` / `GET /api/session` run on it. In-memory only: + a process restart clears all progression (A6); + +## Failure and expiry + +The three ways a room leaves the registry, and the windows that govern each +(all timestamps from the injected `Clock`; the tests drive them with a +`FakeClock` — `tests/integration/test_lifecycle_end_to_end.py`): + +- **The seat grace (`SEAT_GRACE_S`, 30 s).** A room whose *last seat left* + more than `SEAT_GRACE_S` ago is closed, in any state (an orphaned room is + an orphaned room). `SEAT_GRACE_S` is a module constant in `server/room.py` + — the room's own tick check and the supervisor's GC read the one number. + Exactly at 30 s the room is still up (the rule is strictly past the grace); + one `gc_pass()` after it closes and drops the room, its code no longer + resolves (a 404) and its tokens are purged from the index. +- **The lobby idle (`UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S`, 900 s).** An + *occupied* `LOBBY` room (the `ROUND_END` state runs the same check) whose + `last_activity` is past the timeout is closed; exactly at the timeout it + is still up. `last_activity` is stamped by any meaningful event (a join, + a leave, a ready-up, an emote), so a lobby that is being used never + expires. +- **The GC period (`GC_INTERVAL_S`, 5 s).** One `gc_pass()` every 5 s (the + loop is cancellable; the sleep is injectable, so a test advances the + `FakeClock` and never waits). One pass, in order: drop rooms already + `CLOSED` (their teardown is done), close rooms past the seat grace, close + idle lobbies past the timeout, and purge token-index entries whose room + is gone or whose seat is empty. A closed room is *dropped*, not just + closed: the code stops resolving immediately, and a room a player + `join`s while `CLOSED` (the window before the drop) is a named 410 + `room_closed`. + +**Disconnected seats.** A dropped connection marks the seat +`connected=false`; the seat is *kept* for the rejoin path (a token +`join`s its own room again, the seat is marked connected, the sim keeps +its player). The grace that drops such a seat is +`UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` (45 s) — that rule lands with phase 12 +(reconnect resilience); until then a disconnected seat lingers until the +room empties and the seat grace runs. + +**The crash policy** is the crash-isolation section above, restated as +lifecycle: a crashed room fires `close` from inside its own tick (it +becomes `CLOSED` with its sim `ABORTED` and one `tick_crash` err event), +the *next* `gc_pass()` drops it (a `CLOSED` room is rule 1 — the pass +does not close it again, it reaps it), the other rooms keep ticking, and +the server keeps serving. One crashing room is one `CLOSED` room; the +supervisor's stats, the other rooms and `/healthz` are untouched. + +## The lifecycle walkthrough (HTTP before the transport) + +The phase-05 contract is walked end to end over HTTP plus direct `Room` +calls — `tests/integration/test_lifecycle_end_to_end.py` is this section, +step for step (a `FakeClock` and manual `tick_once`; no test sleeps): + +1. **Create.** `POST /api/rooms` (no body — the bare `curl -X POST` + criterion) takes (or mints) the 24-byte session token (` + X-Undercooked-Token` header or body `token`; the header wins), mints + the 5-character Crockford code, seats the creator at seat 0 as host + with the campaign's first level, and returns the token **once** in + `you`. 503 `room_full` when the process is at `max_rooms` (US-01 + criterion 7; the existing rooms are untouched). +2. **Join (×3).** `POST /api/rooms/{code}/join` seats the next player in + the lowest free slot; a typed code is normalized (confusables mapped, + a malformed code 404s exactly like an unknown one — US-02 criterion + 4, no stack trace in the body). A 5th join on a four-seat room is a + 503 `room_full` and the code is retained. A `join` after `LOBBY` + would become a spectator ("The lobby"). +3. **Ready-up.** The ready flags (the phase-06 `ready` message is the + transport copy of `Room.set_ready`). The start precondition: a solo + host, or ≥ 1 ready non-host — `can_start` renders it for the client's + button. +4. **Start.** `Room.start(token)` — `LOBBY` only, host only (the + preconditions named: `NotInLobby`, `NotHost`, `NothingToStart`). Fires + `host_start` → `COUNTDOWN`: `round_no` 1, a fresh seed, the 3.0 s + countdown. +5. **Countdown → the round.** 60 frames of 0.05 s (no drift — the tick + cadence section); at zero `countdown_done` builds the round (level + + seats + seed + the room's shelf/prop seeding) and the room is + `PLAYING`. +6. **`PLAYING` → the verdict.** One `tick_once` per 0.05 s frame: the + kernel's `step` with the per-tick input queue. A scripted full-pipeline + round (the phase-04 policy) reaches the verdict; the room then settles + for `SETTLE_S` (1.0 s, the sim's frozen stage-7 machine) before + `round_over` fires and `last_result = result_payload(state)` lands. +7. **`ROUND_END`.** The payload is the phase-04 contract's exact key set + (`result`, `score`, `target`, `stars`, `time_left`, `counters`, + `longest_streak`, `players` — one per seat, `level_id`, `seed`, + `ticks`). The result is recorded into **every seated player's + session** through the store ("Progression"): a `WON` round raises + `unlocked_level` to `order + 1` (the solo path's "unlock + advanced"), stores the `best` run, counts the round and the served + dishes. +8. **`next_level`.** The host's next campaign pick (the value the store's + `next_level_id` hands back — after a win, `order + 1`'s level; after + a loss, the level just played): `select_level` + `round_no` 2 + a + fresh seed + `COUNTDOWN`. `retry` is the same level with a fresh + seed; `to_lobby` returns without a new round. +9. **Back to `LOBBY`.** The ready flags reset (ready up again), the same + seats keep the same tokens and colours (a token's colour is remembered + for the life of the room), the crown is where the host rules left it, + and waiting spectators are promoted into the free seats. The room + answers `GET /api/rooms/{code}` from the lobby with all its seats. + +**The gap (closed by phase 06).** HTTP has *create, join, inspect and +session* — it has no `start`, no `ready`, no `set_level`, no +`next_level`/`to_lobby` and no inputs: those verbs exist on `Room` (this +walkthrough drives them by direct call) and arrive as wire messages with +the phase-06 per-state whitelist ("Per-state client acceptance" above; +`docs/PROTOCOL.md`). That is why the `curl` walk-through stops at +`POST /api/rooms` → `POST .../join` → `GET /api/rooms/{code}` → +`GET /api/session`, and the ready-up/start/round legs are the +integration suite's direct calls. + +## The lobby (task 03's verbs) + +`Room` (`server/room.py`) is the FSM; the lobby's *data* — `Seat`, +`Spectator`, `sanitize_name`, the palette, the emote whitelist, the named +refusals — lives in `server/seats.py`. + +### Seats and ready-up + +`Seat` is one occupied seat (an empty seat is the *absence* of an entry): +the token, a display name sanitized at the boundary, the seat index (the +lowest free slot), the ready-up flag, the host flag, the connection flag. +`toggle_ready` / `mark_connected` / `mark_disconnected` are the seat's own +verbs. `ready` is what `request_start` / `start` read: a **solo host**, or +≥ 1 ready non-host, and a selected level. `can_start` is the same +precondition plus a crown holder — the client's Start button renders off it. + +**Colours.** The four `SEAT_PALETTE` colours are handed out first-free, in +seat order (seats by seat index, then spectators in join order). A token's +colour is remembered for the life of the room: a reconnect (a seat +reclaimed, a spectator re-added) keeps the same colour when it is still +free; a fresh token takes the first free colour. When all four colours +are taken, a late entry has none. + +### Names + +`sanitize_name(raw, seat_index)` is the one place a player-typed name +becomes a stored name: strip Unicode control and format characters +(zero-width marks, RTL overrides, soft hyphen) and `<>"'`, collapse runs +of whitespace, trim, cap at 16; an empty result falls back to +`Cook #{seat_index + 1}`. Names are non-unique by design; the client +renders them via `textContent` (never HTML), and the server never stores +an unsanitized name (the fallback is always available). + +### The host rules + +The creator's seat 0 is the host. When the crown holder **leaves or +their connection drops** (a dropped host connection is a host leave): + +| State | What happens | +|---|---| +| `LOBBY` / `ROUND_END` | the crown moves to the lowest occupied, **connected** seat; when no connected seat remains, the host slot is empty | +| `COUNTDOWN` / `PLAYING` | the host slot is emptied and the round continues — **no mid-round election** (the crown stays empty until a later host-leave in `LOBBY`/`ROUND_END`) | + +A host that **returns does not automatically regain the crown** — that is +explicit, and it avoids flip-flopping. A `LOBBY` with no host cannot +start (`can_start` is `false`); the idle rules (the 30 s grace, the lobby +timeout) reap it. `start(token)` is `LOBBY` only (`NotInLobby`), host only +(`NotHost`), and raises `NothingToStart` — a `StartRefused`, the reason +(`"no_level"` / `"no_ready_players"`) rides along — when the precondition +is not met. + +### Level picking and unlocks + +`set_level(token, level_id)` is the host's pick (PLAN §6 "limited to +unlocked"): `LOBBY` only (`NotInLobby`), host only (`NotHost`), the id in +the campaign (`UnknownLevel`) and its `order` within the requesting +session's `unlocked_level` (`LevelLocked`; a token without a session +record counts as unlocked 1 — the campaign's floor). The supervisor keeps +the token → `SessionRecord` map and hands the room a `session_for` lookup; +task 04's `SessionStore` owns the progression. + +### `lobby_payload` + +The body of the phase-06 `lobby` message, per viewer: `code`, `state`, +`level`, `host_seat` (the crown holder's seat index, or `null`), +`can_start`, `seats[{index, name, color, ready, host, connected}]` (in +seat order), `spectators[{name, color}]` (join order), `unlocked` (the +viewer's `unlocked_level`, or `null` for an anonymous viewer), `round_no`. +Tokens never leave the room in this payload. + +### Spectators + +A `join` while the room is `COUNTDOWN` / `PLAYING` / `ROUND_END` adds a +spectator (PLAN §6: "late joins wait in `SPECTATING`" until `LOBBY`): no +seat, no sim player, `role="spectator"`, a colour from the same first-free +palette (seats take first; a full room's late watcher has none). A full +room never stops accepting spectators. A spectator's `input` is ignored as +a named `err not_seated` (distinct from `unknown_player`); a `leave` +removes the entry, a dropped connection keeps it; `to_lobby` promotes +them into the free seats (if the room is full, the surplus stays +watching). + +### Emotes + +`emote(token, id)` is the `LOBBY`/`PLAYING` emote (PLAN §7: no chat in +v1; elsewhere it is dropped). A seated player or a spectator may emote; +the id must be in the `EMOTES` whitelist — +`{wave, cheer, thumbs_up, thumbs_down, angry, star}` (an unknown id is an +`UnknownEmote`, never a silent no-op). The throttle is **2 per 1.0 s per +connection** (a third emote in the window is dropped, not queued). An +accepted emote re-broadcasts as an `ev` event (kind `"emote"`; the id and +the token ride in the payload). + +## Progression (the session store, task 04) + +The `SessionStore` (`server/sessions.py`) is the process-wide, +LRU-capped holder of every session's campaign progression (PLAN §6: "in +memory, bounded; LRU-capped at 10 000 entries; wiped on process +restart — acceptable per A6"). The supervisor owns exactly one (the +app's); a `Room` holds a reference to it (a test-built room may hold +none — such a room records nothing, and its `set_level` falls back to +its `sessions` lookup). + +**The record.** One `SessionRecord` per token (the 24-byte +`secrets.token_urlsafe(24)` identity, A16): `display_name` (sanitized, +non-unique), `unlocked_level` (the highest campaign `order` the session +may play — starts at 1, the campaign's floor), `best[level_id]` (a +`BestRun`: `score`, `stars`, `completed_at` — the injected clock's +seconds; a monotonic-relative number, never a wall timestamp, never an +IP / user-agent / header — `tests/unit/test_session_privacy.py` locks +the surface), `rounds_played` / `dishes_served` (counters, across +rooms), `created_at` / `last_seen` (the LRU's stamps) and `endless_best` +(phase 13's; `None` until it lands). + +**The verbs.** `get_or_create(token)` — an unknown token auto-creates at +`unlocked_level=1` (a `get` never creates: a lookup is not an +identity). `put(record)` — a caller-minted record; a token the store +already knows is left alone (a re-creator keeps their unlocks). +`record_result(token, level, result_payload)` — the room's `round_over` +calls it for **every seated player** (spectators are not players): +`rounds_played += 1` and `dishes_served += counters.served` on *every* +result; only a `WON` result touches the progression — `unlocked_level` +rises to `level.order + 1` (never decreases) and `best[level_id]` +updates **when the score beats the stored one** (a tie keeps the older +run and its `completed_at`; the stars are the winning run's). +`is_unlocked(token, level_id)` — `order <= unlocked_level` (an id +outside the campaign the store was built with is `False` — the room +checks `UnknownLevel` first). `next_level_id(token, content)` — the +lowest campaign `order` that is unlocked and has no best run yet: after +a win, `order + 1`'s level; after a loss, the level just played +(retry); `None` when every unlocked level has been won (the campaign is +done for that session) — the value the room's `next_level` resolves +through (`Room.next_level_id`). `to_public(token)` — the +`GET /api/session` body: exactly `display_name`, `unlocked`, `best`, +`rounds_played`, `dishes_served`, `endless_best` — nothing beyond that +shape (no token, no internal stamps). `stats()` — the diagnostics +census (`sessions`, `max_entries`, `evictions`). + +**The LRU.** Eviction runs on a *new* insert only (a touch never +evicts): the oldest `last_seen` goes — **never the token that just +touched the store** (never the current session). Ties break on the +token (deterministic). The cap is `max_entries` (10 000 by default, +PLAN §6); 10 001 sessions stay bounded at the cap. + +**The wiring.** The supervisor owns the store (the campaign mapping — +`level_id -> order` — comes from the content bundle at construction); +`POST /api/rooms` takes the session from the `X-Undercooked-Token` +header or the body's `token` (the header wins; both absent → the server +mints one, returned once in `you`) — a *known* token's progression is +never reset; `GET /api/session` returns `to_public` (200 for a fresh +token — `unlocked: 1`, empty `best`; 400 `no_token` without the +header); `Room.set_level` asks the store's `is_unlocked` (a test-built +room without a store falls back to its `sessions` lookup: a token +without a record counts as unlocked 1); `Room.next_level_id(token)` +reads the store's `next_level_id`; `round_over` records the result into +every seated player's session. + +## What is lost on restart + +Everything in a room — the FSM state, the seats, the `sim_state`, the +`last_result`, the event queue, the snapshot counter — is in-process memory. +A restart closes every room and the supervisor's index with it; there is no +persistence and nothing to recover (PLAN §7: results/scoreboards die with the +process by design). The session store is part of that: **a process +restart clears all progression** — every unlock, best score and counter +dies, and the next process starts every session at the campaign floor +(A6; the module and the README both say so). What *is* rebuilt is +content: levels and recipes are re-read from `levels/*.yaml` through the +validated Pydantic models at startup +(the same startup gate `create_app` runs — a bad file refuses to boot, it +never serves a half-loaded campaign). + +## Per-state client acceptance (words; the whitelist is phase 06) + +- **`LOBBY`** — join (a seat), ready-up (`toggle_ready` / `set_ready`), + start (`start(token)`, the host), level pick (`set_level`, the host), + emotes. No movement, no input, no snapshot of a sim (there is none yet). +- **`COUNTDOWN`** — the client observes (the 3.0 s); a `join` becomes a + spectator, start/level pick are refused (`NotInLobby`), emotes are + dropped; the room no longer accepts start (the table says so). +- **`PLAYING`** — inputs (`queue_input` — a spectator's input is an + `err not_seated`), snapshots (`take_events` + the phase 06 frame), + emotes; ready is irrelevant (the round is running). +- **`ROUND_END`** — observe the result; `retry` / `next_level` (the host) / + `to_lobby` (spectators are promoted into the free seats); the ready + flags reset at `to_lobby`; emotes are dropped. +- **`CLOSED`** — nothing. The room refuses seats, inputs and transitions; the + client is told the room closed (phase 06's close frame) and the transport + drops. + +Phase 06 turns these into the per-state message whitelist on the wire +(`docs/PROTOCOL.md`); the room's own guards above are what the whitelist +sits on. diff --git a/src/undercooked/server/app.py b/src/undercooked/server/app.py index c33d67e..397efeb 100644 --- a/src/undercooked/server/app.py +++ b/src/undercooked/server/app.py @@ -1,23 +1,32 @@ """The FastAPI application factory — the HTTP surface of the server. -This is the process later phases attach to: rooms (`server/room.py`), the -WebSocket endpoint (`/ws/{code}`, phase 06) and the level manifest (phase 02) -all register on the app built here. Nothing game-like exists yet; the contract -is only: +This is the process later phases attach to: the room supervisor +(`server/rooms.py`, phase 05), the WebSocket endpoint (`/ws/{code}`, +phase 06) and the level manifest (phase 02) all register on the app built +here. The contract so far: -* ``GET /healthz`` — liveness probe for containers and tests, +* ``GET /healthz`` — liveness probe for containers and tests; reports the + live room count from the supervisor, * ``GET /`` — the client shell from ``app/static/index.html``, * ``/static/*`` — the client's ES modules, CSS and art (never a CDN, A12), * ``/api/levels``, ``/api/levels/{id}``, ``/api/config`` — the content API (:mod:`undercooked.server.routes_content`), fed by the bundle loaded once here: content that will not load or fails PLAN §8 validation refuses startup with the report on stderr instead of serving a half-working lobby, +* ``POST /api/rooms``, ``GET /api/rooms/{code}``, + ``POST /api/rooms/{code}/join`` — the room lifecycle over HTTP + (:mod:`undercooked.server.routes_rooms`), testable before the phase-06 + WebSocket transport exists, * security headers on every response (:mod:`undercooked.server.headers`). ``create_app`` takes a :class:`~undercooked.config.Settings` as plain data, so a test can build an app without touching the environment; the module-level ``app`` used by uvicorn is built lazily on first attribute access (PEP 562) so that -importing this module stays side-effect free. +importing this module stays side-effect free. The ``RoomSupervisor`` is +installed on ``app.state.supervisor`` — and only there: ``create_app`` +already refuses to build without a validated content bundle, so a process +with rooms is a process with working content. The supervisor's +``start``/``shutdown`` ride the lifespan (GC loop, room tick tasks). """ from __future__ import annotations @@ -34,8 +43,11 @@ from fastapi.staticfiles import StaticFiles from undercooked import __version__ from undercooked.config import ConfigError, Settings, get_settings from undercooked.debug import maybe_start_debugpy +from undercooked.server.clock import SystemClock from undercooked.server.headers import SecurityHeadersMiddleware +from undercooked.server.rooms import RoomSupervisor from undercooked.server.routes_content import content_router, load_validated_content +from undercooked.server.routes_rooms import rooms_router __all__ = [ "STATIC_DIR", @@ -109,7 +121,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: async def lifespan(_app: FastAPI) -> AsyncIterator[None]: logger.info("undercooked %s starting: %s", __version__, settings_log_line(resolved)) maybe_start_debugpy(resolved) + await _app.state.supervisor.start() yield + await _app.state.supervisor.shutdown() logger.info("undercooked %s stopped", __version__) application = FastAPI( @@ -125,14 +139,18 @@ def create_app(settings: Settings | None = None) -> FastAPI: ) application.state.settings = resolved application.state.content = content # loaded + validated once, above - application.state.rooms = None # phase 05 installs the RoomSupervisor here + # The supervisor exists only because the content gate above passed: + # that is its "feature flag" — no rooms in a process without content. + application.state.supervisor = RoomSupervisor(resolved, SystemClock(), content) application.add_middleware(SecurityHeadersMiddleware) application.include_router(content_router) + application.include_router(rooms_router) @application.get("/healthz") async def healthz() -> dict[str, object]: - """Liveness probe. `rooms` is a literal until phase 05 owns a supervisor.""" - return {"status": "ok", "version": __version__, "rooms": 0} + """Liveness probe; `rooms` is the supervisor's live room count.""" + supervisor: RoomSupervisor = application.state.supervisor + return {"status": "ok", "version": __version__, "rooms": len(supervisor.all_rooms())} @application.get("/") async def index() -> FileResponse: diff --git a/src/undercooked/server/clock.py b/src/undercooked/server/clock.py new file mode 100644 index 0000000..89f542d --- /dev/null +++ b/src/undercooked/server/clock.py @@ -0,0 +1,60 @@ +"""Injectable time for the server layer (PLAN.md §6, phase 05 task 01). + +Every time-dependent server object (the supervisor, a room, the session store) +receives a :class:`Clock` instead of reaching for a wall clock, so tests run +on a `FakeClock` and never sleep. This module is the *only* file in +`server/` that imports `time`: it holds a **reference** to the clock +function (swappable in tests) and never *calls* the wall clock — +`tests/unit/test_server_clock_discipline.py` greps for the call literals +and fails the suite if one leaks into the rest of `server/`. +""" + +from __future__ import annotations + +import time +from collections.abc import Callable +from typing import Protocol + +__all__ = ["Clock", "FakeClock", "SystemClock"] + + +class Clock(Protocol): + """The contract every time-dependent server object is built against.""" + + def now(self) -> float: + """The current time in seconds (monotonic flavour: only deltas matter).""" + ... + + +class SystemClock: + """The real clock: `time.monotonic`, held as a reference, not called here. + + Holding the function (rather than calling it) is what keeps the grep + guard honest — this file contains no wall-clock call literal — and it + makes a test substitute trivial + (`SystemClock(lambda: fake.now())`). + """ + + def __init__(self, time_func: Callable[[], float] | None = None) -> None: + self._time = time_func if time_func is not None else time.monotonic + + def now(self) -> float: + return self._time() + + +class FakeClock: + """A test clock: starts at `start`, moves only when `advance` says so.""" + + def __init__(self, start: float = 0.0) -> None: + self._now = float(start) + + def now(self) -> float: + return self._now + + def advance(self, seconds: float) -> float: + """Move forward by `seconds` (it never goes backwards) and return the new time.""" + if seconds < 0: + msg = f"FakeClock cannot go backwards (got {seconds})" + raise ValueError(msg) + self._now += seconds + return self._now diff --git a/src/undercooked/server/codes.py b/src/undercooked/server/codes.py new file mode 100644 index 0000000..094ba1b --- /dev/null +++ b/src/undercooked/server/codes.py @@ -0,0 +1,111 @@ +"""Room codes: minting, collision retries and user-typed normalisation (A15). + +A code is 5 characters of Crockford base32 — 32 symbols, so ~33.5 M rooms — +with `I`, `L`, `O` and `U` excluded: no letter can be mistaken for a digit +or another letter when it is shouted across a kitchen or thumb-typed on a +phone. `new_code` draws with `secrets.choice` (or an injected `random.Random` +in tests) and retries up to 100 times on a collision with an already-issued +code; a full space is a `CodeSpaceFull`, never a silently-reused code. + +`normalize_code` is the entry for code *input* (typed, spoken, pasted): it +upper-cases, strips spaces and dashes, and maps the excluded characters to +the symbols they are most likely misreads of (`I`/`l` → `1`, `O` → `0`, +`U` → `V`). Anything else that is not exactly 5 alphabet characters yields +`None` — and the caller treats `None` exactly like "no such room" (US-02 +criterion 4): a wrong format must never be distinguishable from an unknown +code, or the API becomes a room-existence oracle. +""" + +from __future__ import annotations + +import random +import secrets +import string +from collections.abc import Container +from typing import Final + +__all__ = [ + "CODE_LEN", + "CROCKFORD", + "CodeSpaceFull", + "new_code", + "normalize_code", +] + +#: The 32 minting symbols, in Crockford order — digits first, then the +#: letters that survive the I/L/O/U exclusion. +CROCKFORD: Final = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +#: Every code is exactly this many characters (shoutable, thumb-typeable). +CODE_LEN: Final = 5 + +#: How many collisions `new_code` retries before giving up. 100 draws from a +#: ~33.5 M space never exhausts it in a healthy process; a hit means the +#: process has minted essentially every code and should say so. +MAX_MINT_RETRIES: Final = 100 + +#: Confusables excluded from the alphabet, mapped to the symbol a person most +#: likely meant when they typed the excluded character. +_CONFUSABLE_MAP: Final = { + "I": "1", # and lowercase `l`, which upper-cases to `I` first + "L": "1", + "O": "0", + "U": "V", +} + +#: Every character the normaliser may strip: ASCII whitespace plus the +#: three dash a keyboard offers (`-`, U+2013, U+2014) — the separators +#: people insert when a code is read out loud. +_STRIP_CHARS: Final = frozenset(string.whitespace + "-\u2013\u2014") + + +class CodeSpaceFull(RuntimeError): # noqa: N818 - name fixed by the phase task file + """`new_code` retried 100 times and every draw collided: stop minting.""" + + +def new_code(taken: Container[str], rng: random.Random | None = None) -> str: + """Mint a code that is not in `taken`, or raise `CodeSpaceFull`. + + Args: + taken: any `Container[str]` of already-issued codes (the supervisor + passes its registry keys). + rng: a `random.Random` for deterministic tests; `None` draws with + `secrets.choice`, the process's real entropy. + + Raises: + CodeSpaceFull: all 100 draws collided. + """ + pick = rng.choice if rng is not None else secrets.choice + for _ in range(MAX_MINT_RETRIES): + code = "".join(pick(CROCKFORD) for _ in range(CODE_LEN)) + if code not in taken: + return code + msg = f"room code space exhausted: {MAX_MINT_RETRIES} draws all collided" + raise CodeSpaceFull(msg) + + +def _map_confusables(text: str) -> str: + return "".join(_CONFUSABLE_MAP.get(char, char) for char in text) + + +def normalize_code(raw: str) -> str | None: + """Canonicalise user-typed code input; `None` when it is not a code. + + Upper-cases, strips spaces/dashes, maps the excluded confusables + (`I`/`l` → `1`, `O` → `0`, `U` → `V`), then requires exactly + `CODE_LEN` characters, every one of them in :data:`CROCKFORD`. + + The contract for callers: `None` means "no such room", full stop. A + bad format is never reported as such (US-02 criterion 4), so this + function must stay a pure format check — it must not consult the + registry, or "unknown" and "malformed" would diverge. + """ + cleaned = "".join( + char for char in raw.upper() if char not in _STRIP_CHARS + ) + if len(cleaned) != CODE_LEN: + return None + mapped = _map_confusables(cleaned) + if any(char not in CROCKFORD for char in mapped): + return None + return mapped diff --git a/src/undercooked/server/room.py b/src/undercooked/server/room.py new file mode 100644 index 0000000..8b0c949 --- /dev/null +++ b/src/undercooked/server/room.py @@ -0,0 +1,1191 @@ +"""One room: identity, seats, a guarded state machine, and one owned tick task. + +A `Room` turns a level plus a set of seats into a running simulation and a +result. It is an **explicit, guarded FSM** (PLAN §6): every state change goes +through `_fire(event)`, which looks the `(state, event)` pair up in +`ROOM_TRANSITIONS` — data, not code — and raises `IllegalTransition` for +anything not in the table. There are no ad-hoc state writes; the one documented +exception is `close()`, the universal teardown, which takes the `close` +transition (legal from every live state) and then cancels the tick task and +frees the seats. + +The room owns **exactly one asyncio tick task** (PLAN §6 "every room owns +exactly one asyncio tick task; task cancellation is the only teardown"): +`start_ticking()` creates it, `close()` cancels it, and nothing else ever stops the +room. The loop body is deadline-scheduled off the injected `Clock` — it +computes the next deadline instead of sleeping `1/tick_hz`, so the cadence +cannot drift, and a clock jump resynchronises instead of paying a catch-up +burst (the sim's `dt` clamp makes a burst worthless anyway). `tick_once(now)` +is the synchronous core the loop calls, which is also what tests drive +manually: `PLAYING` advances `sim.step` with the per-tick input queue and +drains the events into `events_since_snapshot`; `COUNTDOWN` decrements +`countdown_left` (3 s, PLAN §6) and fires `countdown_done` at zero; +`LOBBY`/`ROUND_END` run the idle-expiry checks (the empty-grace and the +`lobby_idle_timeout_s` rules, the same numbers the supervisor's GC applies +externally). + +**The settle.** A sim state that carries a `result` is a finished round: the +room keeps ticking it for `sim.round_state.SETTLE_S` (1.0 s) — the sim's +frozen stage-7-and-tick machine, no events, no movement — before firing +`round_over` and storing `result_payload(state)` in `last_result`. That is +the window `docs/SIMULATION.md` promises: the verdict substep's events land in +the buffer, and the result is a function of the state at the verdict alone. + +**Crash isolation.** `tick_once` wraps the whole body: any exception is logged +(code + round number — never a player token), the sim is marked `ABORTED` +(`sim.round_state.abort`), an `err` event is buffered, `close` fires, and the +exception never propagates out of the loop (one room must not kill the +server). A round that already won or lost keeps its verdict — a crash after +the buzzer cannot un-win it. The fault the integration suite injects arrives +through the test-only `_test_step_override` seam (the `UNDERCOOKED_TEST_HOOK`): +a `Callable` with `step`'s signature that the `PLAYING` branch calls in the +kernel's place when set — tests set it, the network never reads it. + + +**The lobby's verbs (task 03).** The seat/spectator *data* and the pure +helpers live in `server/seats.py`; this room is what walks them. `start(token)` +is the host's *Start* (US-03): `LOBBY` only, host only, then task 02's +preconditions — a solo host, or ≥ 1 ready non-host, and a selected level +(`NothingToStart`, a `StartRefused`, names the cause). `set_level(token, +level_id)` is the host's pick (PLAN §6 "limited to unlocked"): `LOBBY` only, +host only, the id in the campaign (`UnknownLevel`) and its `order` within +the requesting session's `unlocked_level` (`LevelLocked`). The host rules +(`transfer_host`): when the crown holder leaves or their connection drops in +`LOBBY`/`ROUND_END` the crown moves to the lowest occupied, *connected* seat; +in `COUNTDOWN`/`PLAYING` the host slot is emptied and **no mid-round election +happens** (the round continues, the crown stays empty); a returning host does +not automatically regain it. Spectators (PLAN §6 "late joins wait in +SPECTATING") are `join`ed tokens in `COUNTDOWN`/`PLAYING`/`ROUND_END` — no +seat, their inputs are an `err not_seated`, `to_lobby` promotes them into +free seats. `emote` validates against the `EMOTES` whitelist, throttles to 2 +per 1.0 s per token, and re-broadcasts as an `ev` event. `lobby_payload` +builds the phase-06 `lobby` message body (seats + spectators + `can_start` + +the viewer's `unlocked`). + +**Session progression (task 04).** The room holds a reference to the +supervisor's bounded LRU `SessionStore` (`store`; `None` for a test-built +room): `set_level` asks `store.is_unlocked(token, level_id)` when it owns +one (falling back to the `sessions` lookup: a token without a record +counts as unlocked 1), `next_level_id(token)` reads +`store.next_level_id(token, content)` — the value the `next_level` pick +comes from — and `round_over`'s half of the settle +(`_record_round_result`) calls `store.record_result` for **every seated +player**. A room without a store records nothing; spectators are not +players. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import secrets +from dataclasses import replace +from enum import StrEnum +from typing import TYPE_CHECKING, Final + +from undercooked.server.seats import ( + EMOTE_EVENT_KIND, + EMOTE_MAX_PER_WINDOW, + EMOTE_WINDOW_S, + EMOTES, + SEAT_PALETTE, + LevelLocked, + NothingToStart, + NotHost, + NotInLobby, + Seat, + Spectator, + StartRefused, + UnknownEmote, + UnknownLevel, +) +from undercooked.server.sessions import SessionRecord +from undercooked.sim.model import Event, EventKind, Item, ItemForm, KitchenState, StationKind +from undercooked.sim.round_state import SETTLE_S, abort, result_payload +from undercooked.sim.state_build import SeatSpec, build_state +from undercooked.sim.step import PlayerInput, step + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable, Mapping, Sequence + + from undercooked.config import Settings + from undercooked.server.clock import Clock + from undercooked.server.sessions import SessionStore + from undercooked.sim.content import ContentBundle + from undercooked.sim.level import Level + from undercooked.sim.recipe import RecipeBook + + SessionLookup = Callable[[str], SessionRecord | None] + #: The `step` signature: the test-only `_test_step_override` seam must + #: stand in for the kernel exactly (the same four arguments, the same + #: return). + StepFn = Callable[ + [KitchenState, float, Sequence[PlayerInput], RecipeBook | None], + tuple[KitchenState, tuple[Event, ...]], + ] + +__all__ = [ + "COUNTDOWN_S", + "ROOM_EVENTS", + "ROOM_TRANSITIONS", + "SEAT_GRACE_S", + "IllegalTransition", + "Room", + "RoomState", + "RoomTransition", + "Seat", + "StartRefused", +] + +logger = logging.getLogger(__name__) + +#: How long the countdown runs (PLAN §6: "COUNTDOWN(3s)"); one `n` per whole +#: second left (3…2…1), so the broadcast is `ceil(countdown_left)`. +COUNTDOWN_S = 3.0 + +#: Seconds an empty room survives before it (or the GC) closes it (PLAN §6 +#: "grace"). Defined here — the room's own tick check and the supervisor's GC +#: both read this one constant. +SEAT_GRACE_S = 30.0 + +#: Float-noise tolerances (repeated `0.05` s subtraction does not land exactly +#: on 0.0 — 3.0 - 60 x 0.05 keeps a 2.3e-15 residue; the same trick as the +#: sim's `TIME_EPS`/`TICK_EPS`). +_EPS_S: Final[float] = 1e-9 + +#: Splitmix64-style constants: the room's seed cursor is stepped with them, so +#: consecutive rounds always draw different seeds (freshness on retry) and a +#: test can pin the whole sequence from one starting value. +_SEED_MODULUS: Final[int] = 2**63 +_SEED_MULTIPLIER: Final[int] = 6364136223847 +_SEED_INCREMENT: Final[int] = 1442695040888963407 + + +class RoomState(StrEnum): + """The room's lifecycle state (PLAN §6 FSM).""" + + LOBBY = "lobby" + COUNTDOWN = "countdown" + PLAYING = "playing" + ROUND_END = "round_end" + CLOSED = "closed" + + +class IllegalTransition(RuntimeError): # noqa: N818 - name fixed by the phase task file + """`_fire` was given a `(state, event)` pair that is not in the table.""" + + def __init__(self, state: RoomState, event: str) -> None: + super().__init__(f"no transition from {state.value} on {event!r}") + self.state = state + self.event = event + + +#: The nine events the FSM knows (PLAN §6 plus the two idle/close rules). +ROOM_EVENTS: Final[tuple[str, ...]] = ( + "host_start", + "countdown_done", + "round_over", + "to_lobby", + "retry", + "next_level", + "close", + "all_seats_empty", + "idle_expired", +) + +#: The transition table as data: `(from state, event) -> to state`. Anything +#: not listed is an `IllegalTransition` — there is no other way to change the +#: state (except `close()`, which takes the `close` entry). +RoomTransition = dict[tuple[RoomState, str], RoomState] +ROOM_TRANSITIONS: Final[RoomTransition] = { + (RoomState.LOBBY, "host_start"): RoomState.COUNTDOWN, + (RoomState.COUNTDOWN, "countdown_done"): RoomState.PLAYING, + (RoomState.PLAYING, "round_over"): RoomState.ROUND_END, + (RoomState.ROUND_END, "to_lobby"): RoomState.LOBBY, + (RoomState.ROUND_END, "retry"): RoomState.COUNTDOWN, + (RoomState.ROUND_END, "next_level"): RoomState.COUNTDOWN, + (RoomState.LOBBY, "close"): RoomState.CLOSED, + (RoomState.COUNTDOWN, "close"): RoomState.CLOSED, + (RoomState.PLAYING, "close"): RoomState.CLOSED, + (RoomState.ROUND_END, "close"): RoomState.CLOSED, + (RoomState.LOBBY, "all_seats_empty"): RoomState.CLOSED, + (RoomState.ROUND_END, "all_seats_empty"): RoomState.CLOSED, + (RoomState.LOBBY, "idle_expired"): RoomState.CLOSED, + (RoomState.ROUND_END, "idle_expired"): RoomState.CLOSED, +} + + +class Room: + """One match: code + seats + a guarded FSM + one owned tick task. + + Construction is the supervisor's alone (it mints the code); the room + knows its settings (seats cap, tick rate), its `Clock`, and — when the + supervisor gives it the content bundle — its campaign, so it can resolve + level ids into `Level` objects (`select_level`/`next_level`) and build + the round's state. A room built without a bundle (task-01 style) lives in + the lobby only: it cannot start a round it has no level for. + + Every timestamp comes from the injected `Clock`; the loop's sleep is + injectable too, so tests advance a `FakeClock` and never sleep. + """ + + def __init__( + self, + *, + code: str, + settings: Settings, + clock: Clock, + level_id: str | None = None, + sleep: Callable[[float], Awaitable[None]] | None = None, + content: ContentBundle | None = None, + seed: int | None = None, + sessions: SessionLookup | None = None, + store: SessionStore | None = None, + ) -> None: + self.code = code + self._settings = settings + self._clock = clock + self._sleep = sleep if sleep is not None else asyncio.sleep + self._content = content + #: The token → `SessionRecord` lookup (`set_level`'s unlock check and + #: `lobby_payload`'s `unlocked` read the session's `unlocked_level` + #: through it); `None` (a test-built room) counts as unlocked 1. + self._sessions = sessions + #: The supervisor's `SessionStore` (task 04; `None` for a test-built + #: room): `set_level` asks its `is_unlocked`, `next_level_id(token)` + #: reads its `next_level_id`, and `round_over` records the result + #: into every seated player's session through it. + self._store = store + #: Late joins (PLAN §6 "SPECTATING"): tokens that arrived while the + #: room was already past `LOBBY`. No seat, `role="spectator"`. + self.spectators: list[Spectator] = [] + #: A token's last emote timestamps (the 2-per-1.0 s throttle). + self._emote_times: dict[str, list[float]] = {} + #: A token → colour memory for the life of the room (task 03: a + #: reconnect keeps the same colour if it is still free). + self._color_memory: dict[str, str] = {} + #: The level id the round will play (the campaign's first by default; + #: host-pickable from task 03 on). `None` = no level selected. + self.level_id = level_id + #: The resolved `Level`, or `None` when there is no bundle or no id. + self.level: Level | None = ( + content.by_id.get(level_id) + if content is not None and level_id is not None + else None + ) + self.state = RoomState.LOBBY + self.seats: list[Seat] = [] + now = clock.now() + self.created_at = now + self.last_activity = now + self.state_since = now + #: When the last seat left; `None` while the room is occupied. + self.empty_since: float | None = None + self.closed_at: float | None = None + # -- the round (task 02) ------------------------------------------- + self.countdown_left: float = COUNTDOWN_S + self.sim_state: KitchenState | None = None + #: `result_payload` of the last finished round (the body of the + #: phase-06 `round` message), or `None` before the verdict. + self.last_result: Mapping[str, object] | None = None + #: Which round this is in this room (0 = none yet); each entry into + #: `COUNTDOWN` starts a new one. + self.round_no: int = 0 + #: The round's seed (the sim's `rng_state` start): the base `seed` + #: (or a 63-bit `secrets` draw) for round 1, an LCG step for every + #: later round — always fresh, always pinned from one base. + self.round_seed: int | None = None + #: Why the last round ended in a crash (`None` when it did not). + self.abort_reason: str | None = None + #: Sim events since the last `take_events()` — the phase-06 `ev` + #: buffer; drained exactly once per snapshot. + self.events_since_snapshot: list[Event] = [] + #: How many snapshot boundaries have passed since the room started. + self.snapshot_seq: int = 0 + self._queued_inputs: list[PlayerInput] = [] + self._settle_left: float | None = None + self._last_at: float | None = None + #: The test-only seam (the `UNDERCOOKED_TEST_HOOK`): a `Callable` + #: with `step`'s signature that, when set, stands in for `step` + #: in the `PLAYING` tick. Tests set it to inject a fault into one + #: room (the crash-isolation suite) — it is never read from the + #: network, and a production room has `None` here. + self._test_step_override: StepFn | None = None + self._seed_cursor = seed if seed is not None else secrets.randbelow(_SEED_MODULUS) + self._task: asyncio.Task[None] | None = None + + # ------------------------------------------------------------------ seats + + @property + def max_players(self) -> int: + return self._settings.max_players + + @property + def is_full(self) -> bool: + return len(self.seats) >= self.max_players + + @property + def is_empty(self) -> bool: + return not self.seats + + @property + def settings(self) -> Settings: + return self._settings + + @property + def content(self) -> ContentBundle | None: + return self._content + + @property + def sessions(self) -> SessionLookup | None: + """The room's token → session lookup (or `None` for a test-built + room; the supervisor always sets it).""" + return self._sessions + + @property + def store(self) -> SessionStore | None: + """The room's `SessionStore` (task 04; `None` for a test-built + room without one — such a room records nothing).""" + return self._store + + def seat_for_token(self, token: str) -> Seat | None: + """The occupant of the seat held by `token`, or `None`.""" + for seat in self.seats: + if seat.token == token: + return seat + return None + + def next_seat_index(self) -> int: + """The lowest seat index nobody holds (0-based). + + Raises: + IndexError: the room is full. + """ + taken = {seat.seat_index for seat in self.seats} + for index in range(self.max_players): + if index not in taken: + return index + msg = f"room {self.code} has no free seat ({self.max_players} max)" + raise IndexError(msg) + + def add_seat( + self, token: str, display_name: str, host: bool, now: float | None = None + ) -> Seat: + """Seat a player in the lowest free slot and stamp the timestamps. + + `player_id` is the sim's identity for the seat (the token — see + `Seat`); `color` is assigned by the colour pass (first free palette + colour in seat order; a returning token keeps its remembered colour). + + Raises: + ValueError: the room is full. + """ + if self.is_full: + msg = f"room {self.code} is full ({self.max_players} max)" + raise ValueError(msg) + when = self._clock.now() if now is None else now + seat = Seat( + token=token, + display_name=display_name, + seat_index=self.next_seat_index(), + host=host, + connected=True, + last_seen=when, + player_id=token, + ) + self.seats.append(seat) + self.empty_since = None + self.last_activity = when + self._assign_colors() + return seat + + def remove_seat(self, token: str, now: float | None = None) -> bool: + """Remove a seat; when the room empties, start the GC grace clock. + + When the removed seat held the crown, `transfer_host` runs — the + host *left*, the documented trigger (docs/ROOMS.md "Host rules"). + + Returns: + True when a seat was removed, False when the token was not seated. + """ + when = self._clock.now() if now is None else now + seat = self.seat_for_token(token) + before = len(self.seats) + self.seats = [seat_ for seat_ in self.seats if seat_.token != token] + if len(self.seats) == before: + return False + if not self.seats: + self.empty_since = when + self.last_activity = when + if seat is not None and seat.host: + self.transfer_host() + return True + + def mark_seat_disconnected(self, token: str, now: float | None = None) -> bool: + """Mark a seat's connection dropped (the rejoin path keeps the seat). + + When the marked seat held the crown, `transfer_host` runs — a + dropped host connection is a host *leave* for the crown's purposes + (docs/ROOMS.md "Host rules"). + + Returns: + True when a seated token 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 + seat.mark_disconnected(when) + self.last_activity = when + if seat.host: + self.transfer_host() + return True + + def mark_seat_connected(self, token: str, now: float | None = None) -> bool: + """Mark a seat's connection restored (the rejoin path, US-02). + + Deliberately does **not** run `transfer_host`: a returning host does + not automatically regain the crown (docs/ROOMS.md "Host rules" — + explicit, avoids flip-flopping). + + Returns: + True when a seated token 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 + seat.mark_connected(when) + self.last_activity = when + return True + + def set_ready(self, token: str, ready: bool) -> bool: + """Set the seat's ready flag (the ready-up, US-03). + + Returns: + True when a seated token was marked, False when it was not seated. + """ + seat = self.seat_for_token(token) + if seat is None: + return False + seat.ready = ready + self.note_activity() + return True + + def note_activity(self, now: float | None = None) -> None: + """Stamp the last meaningful event — what the lobby-idle GC measures.""" + self.last_activity = self._clock.now() if now is None else now + + # ------------------------------------------------------------ the FSM + + def _fire(self, event: str) -> RoomState: + """Take one transition: table lookup, state write, nothing else. + + Raises: + IllegalTransition: the pair is not in `ROOM_TRANSITIONS`. + """ + to = ROOM_TRANSITIONS.get((self.state, event)) + if to is None: + raise IllegalTransition(self.state, event) + self.state = to + self.state_since = self._clock.now() + return to + + def _begin_round(self) -> None: + """Start a new round: `round_no` up, a fresh seed, the countdown reset. + + Shared by `host_start`, `retry` and `next_level` (each fires its own + event first — the table decides the move, this decides the data). + The first round plays the base `seed` itself; every later round steps + the cursor, so the seed is fresh (and, from one base, pinned). + """ + self.round_no += 1 + if self.round_no > 1: + self._seed_cursor = ( + self._seed_cursor * _SEED_MULTIPLIER + _SEED_INCREMENT + ) % _SEED_MODULUS + self.round_seed = self._seed_cursor + self.countdown_left = COUNTDOWN_S + self._queued_inputs = [] + self._settle_left = None + + def request_start(self) -> None: + """The host's *Start*: ≥ 1 ready non-host, or a solo host (PLAN §6), + and a selected level. Fires `host_start` (LOBBY -> COUNTDOWN). + + Raises: + StartRefused: `"no_level"` or `"no_ready_players"`. + IllegalTransition: the room is not in `LOBBY`. + """ + if self.level is None: + raise StartRefused("no_level") + ready_others = sum(1 for seat in self.seats if seat.ready and not seat.host) + solo_host = len(self.seats) == 1 and self.seats[0].host + if ready_others == 0 and not solo_host: + raise StartRefused("no_ready_players") + self._begin_round() + self._fire("host_start") + + def to_lobby(self) -> None: + """Return to the lobby after a round (PLAN §6 "next-level / retry"). + + The ready flags reset — a fresh lobby readies up fresh — and the + round data (`sim_state`, `last_result`) stays for inspection until + the next build replaces it. Waiting spectators are promoted into the + free seats (PLAN §6: "late joins wait in `SPECTATING` until + `LOBBY`); when the room is full, the surplus stays watching. + """ + self._fire("to_lobby") + for seat in self.seats: + seat.ready = False + remaining: list[Spectator] = [] + for spec in self.spectators: + if not self.is_full: + self.add_seat( + spec.token, spec.display_name, host=False, now=spec.joined_at + ) + else: + remaining.append(spec) + self.spectators = remaining + + def retry(self) -> None: + """Replay the same level (PLAN §6 "retry") with a fresh seed. + + Raises: + IllegalTransition: the room is not in `ROUND_END`. + """ + self._begin_round() + self._fire("retry") + + def select_level(self, level_id: str) -> Level: + """Resolve `level_id` in the room's campaign and make it `self.level`. + + A data change, not a transition — `LOBBY`'s host pick (task 03) and + `next_level` both go through it. + + Raises: + ValueError: the room has no content bundle, or the id is not in it. + """ + if self._content is None: + msg = f"room {self.code} has no content bundle to select a level from" + raise ValueError(msg) + level = self._content.by_id.get(level_id) + if level is None: + msg = f"level {level_id!r} is not in room {self.code}'s campaign" + raise ValueError(msg) + self.level = level + self.level_id = level_id + return level + + def next_level(self, level_id: str) -> None: + """Switch to `level_id` and start it (ROUND_END -> COUNTDOWN). + + Raises: + ValueError: the room has no bundle, or the id is not in it + (the state is left untouched). + IllegalTransition: the room is not in `ROUND_END`. + """ + self.select_level(level_id) + self._begin_round() + self._fire("next_level") + + def next_level_id(self, token: str) -> str | None: + """The next campaign level for `token`'s session (task 04 wires + the `next_level` value from the session store): the store's + `next_level_id` over the room's campaign — the lowest unlocked + level with no best run yet (after a win, `order + 1`'s level; + after a loss, the level just played). + + `None` when the room has no store or campaign (a test-built + room cannot advise), or the campaign is done for that session. + """ + if self._store is None or self._content is None: + return None + return self._store.next_level_id(token, self._content) + + # ------------------------------------------------- the lobby's verbs (task 03) + + @property + def can_start(self) -> bool: + """Whether a start pressed now would be accepted: `LOBBY`, a + selected level, a crown holder, and the ready-up precondition + (a solo host, or ≥ 1 ready non-host — PLAN §6). The client's + Start button renders off this. + """ + if self.state is not RoomState.LOBBY or self.level is None: + return False + if not any(seat.host for seat in self.seats): + return False + ready_others = sum(1 for seat in self.seats if seat.ready and not seat.host) + return ready_others > 0 or (len(self.seats) == 1 and self.seats[0].host) + + def start(self, token: str) -> None: + """The host's *Start*, by token (US-03): the guards, then the same + preconditions as `request_start`. + + Raises: + NotInLobby: the room is not in `LOBBY`. + NotHost: the token holds no seat, or a non-host one. + NothingToStart: a `StartRefused` — `"no_level"` or + `"no_ready_players"` (a solo host needs neither). + """ + if self.state is not RoomState.LOBBY: + msg = f"room {self.code} is not in the lobby ({self.state.value})" + raise NotInLobby(msg) + if not self._is_host(token): + msg = f"only the host can start room {self.code}" + raise NotHost(msg) + try: + self.request_start() + except StartRefused as exc: + raise NothingToStart(exc.reason) from exc + + def set_level(self, token: str, level_id: str) -> Level: + """The host's level pick (PLAN §6 "level pick (host; limited to + unlocked)"): `LOBBY` only, host only, the level in the campaign + and unlocked by the requesting session. + + Raises: + NotInLobby: the room is not in `LOBBY`. + NotHost: the token is not the crown holder. + UnknownLevel: the id is not in the campaign (a bundle-less + room has no campaign). + LevelLocked: the level's `order` is past the session's + `unlocked_level` — the room asks its `SessionStore`'s + `is_unlocked` when it owns one (falling back to the + `sessions` lookup: a token with no session record counts + as unlocked 1 — the campaign's floor). + """ + if self.state is not RoomState.LOBBY: + msg = f"room {self.code} is not in the lobby ({self.state.value})" + raise NotInLobby(msg) + if not self._is_host(token): + msg = f"only the host can pick the level in room {self.code}" + raise NotHost(msg) + level = self._content.by_id.get(level_id) if self._content is not None else None + if level is None: + msg = f"level {level_id!r} is not in room {self.code}'s campaign" + raise UnknownLevel(msg) + record = ( + self._store.get(token) + if self._store is not None + else self._session_for(token) + ) + unlocked = record.unlocked_level if record is not None else 1 + locked = ( + not self._store.is_unlocked(token, level_id) + if self._store is not None + else level.order > unlocked + ) + if locked: + msg = ( + f"level {level_id!r} is campaign order {level.order}; " + f"the session is unlocked to {unlocked}" + ) + raise LevelLocked(msg) + return self.select_level(level_id) + + def transfer_host(self) -> bool: + """Move the crown when the host leaves (or their connection + drops) — called by `remove_seat` / `mark_seat_disconnected` when + the affected seat held it. + + In `LOBBY` and `ROUND_END` the crown goes to the lowest occupied, + *connected* seat (by seat index); when no connected seat remains + the host slot is left empty. In `COUNTDOWN`/`PLAYING` the round + continues: the host slot is emptied and **no mid-round election + happens** (the crown stays empty until a later host-leave in + `LOBBY`/`ROUND_END`). A host that returns does not automatically + regain the crown — that is explicit, and it avoids flip-flopping + (docs/ROOMS.md "Host rules"). + + Returns: + True when a new host was elected, False when the slot is empty. + """ + for seat in self.seats: + seat.host = False + if self.state not in (RoomState.LOBBY, RoomState.ROUND_END): + return False + for seat in sorted(self.seats, key=lambda s: (s.seat_index, s.token)): + if seat.connected: + seat.host = True + return True + return False + + def _is_host(self, token: str) -> bool: + seat = self.seat_for_token(token) + return seat is not None and seat.host + + def _session_for(self, token: str) -> SessionRecord | None: + if self._sessions is None: + return None + return self._sessions(token) + + # ------------------------------------------------------ spectators (task 03) + + def spectator_for_token(self, token: str) -> Spectator | None: + """The spectator entry held by `token`, or `None`.""" + for spec in self.spectators: + if spec.token == token: + return spec + return None + + def add_spectator( + self, token: str, display_name: str, now: float | None = None + ) -> Spectator: + """Register (or reclaim) a spectator: a token that arrived while + the room was already past `LOBBY` (PLAN §6 "SPECTATING"). No + seat, `role="spectator"`; a colour from the same first-free + palette (seats take first). Reclaiming marks the entry connected + again and updates the name when a new one was typed. + """ + when = self._clock.now() if now is None else now + existing = self.spectator_for_token(token) + if existing is not None: + existing.mark_connected(when) + if display_name: + existing.display_name = display_name + self._assign_colors() + self.note_activity(when) + return existing + spec = Spectator( + token=token, + display_name=display_name, + color="", + joined_at=when, + last_seen=when, + ) + self.spectators.append(spec) + self._assign_colors() + self.note_activity(when) + return spec + + def remove_spectator(self, token: str) -> bool: + """Remove a spectator entry (the `leave` for a watcher). + + Returns: + True when an entry was removed, False when the token was not + watching. + """ + before = len(self.spectators) + self.spectators = [spec for spec in self.spectators if spec.token != token] + return len(self.spectators) != before + + def mark_spectator_disconnected(self, token: str, now: float | None = None) -> bool: + """Mark a spectator's connection dropped (the entry is kept; a + genuine `leave` removes it). + + Returns: + True when a watching token was marked, False when it was not. + """ + when = self._clock.now() if now is None else now + spec = self.spectator_for_token(token) + if spec is None: + return False + spec.mark_disconnected(when) + self.note_activity(when) + return True + + def mark_spectator_connected(self, token: str, now: float | None = None) -> bool: + """Mark a spectator's connection restored (the entry is kept). + + Returns: + True when a watching token was marked, False when it was not. + """ + when = self._clock.now() if now is None else now + spec = self.spectator_for_token(token) + if spec is None: + return False + spec.mark_connected(when) + self.note_activity(when) + return True + + # ------------------------------------------------------------------ colours + + def _assign_colors(self) -> None: + """Hand out the 4-colour palette (task 03): first free colour, in + seat order — seats by seat index, then spectators in join order. + A token's colour is remembered for the life of the room, so a + reconnect (a seat reclaimed, a spectator re-added) keeps the same + colour when it is still free; a fresh token takes the first free + colour. (When all four colours are taken, a late entry keeps + `""` — the palette is exactly four.) + """ + ordered = ( + sorted(self.seats, key=lambda s: (s.seat_index, s.token)) + + list(self.spectators) + ) + used: set[str] = set() + for entry in ordered: + remembered = self._color_memory.get(entry.token) + if remembered is not None and remembered not in used: + entry.color = remembered + used.add(remembered) + for entry in ordered: + if entry.color: + if entry.color in SEAT_PALETTE: + used.add(entry.color) + continue + for color in SEAT_PALETTE: + if color not in used: + entry.color = color + self._color_memory[entry.token] = color + used.add(color) + break + + # ------------------------------------------------------------------ emotes + + def emote(self, token: str, emote_id: str, now: float | None = None) -> bool: + """Validate, throttle and broadcast one emote (PLAN §7: the + `emote` whitelist; no chat in v1). A seated player or a + spectator may emote; a token that holds neither is dropped. + + The room must be `LOBBY` or `PLAYING` — elsewhere the emote is + dropped (the phase-06 per-state whitelist is the wire-side copy + of this rule). An id outside `EMOTES` is an `UnknownEmote`, never + a silent no-op. The throttle is 2 per 1.0 s per connection (one + connection per token): a third emote inside the window is + dropped, not queued. + + Raises: + UnknownEmote: the id is not in `EMOTES`. + + Returns: + True when the emote was accepted and buffered as an `ev` + event (kind `"emote"`; the id and the token ride in the + payload); False when it was dropped. + """ + when = self._clock.now() if now is None else now + if self.state not in (RoomState.LOBBY, RoomState.PLAYING): + return False + if emote_id not in EMOTES: + msg = f"{emote_id!r} is not an emote (whitelist: {sorted(EMOTES)})" + raise UnknownEmote(msg) + if self.seat_for_token(token) is None and self.spectator_for_token(token) is None: + return False + recent = [t for t in self._emote_times.get(token, ()) if when - t < EMOTE_WINDOW_S] + if len(recent) >= EMOTE_MAX_PER_WINDOW: + return False + recent.append(when) + self._emote_times[token] = recent + self.events_since_snapshot.append( + Event( + kind=EMOTE_EVENT_KIND, + payload={"emote": emote_id, "player_id": token}, + ) + ) + self.note_activity(when) + return True + + # ------------------------------------------------------------- the payload + + def lobby_payload(self, viewer_token: str | None = None) -> dict[str, object]: + """The body of the phase-06 `lobby` message (PLAN §7), as the + `viewer_token` session sees it (an anonymous viewer — or no + lookup — sees `"unlocked": null`). + + Shape: `code`, `state`, `level`, `host_seat` (the crown holder's + seat index, or `null`), `can_start`, + `seats[{index, name, color, ready, host, connected}]` (in seat + order), `spectators[{name, color}]` (join order), `unlocked` + (the viewer's `unlocked_level`, or `null`), `round_no`. Tokens + never leave the room in this payload. + """ + session = ( + self._session_for(viewer_token) if viewer_token is not None else None + ) + host = next((seat for seat in self.seats if seat.host), None) + return { + "code": self.code, + "state": self.state.value, + "level": self.level_id, + "host_seat": host.seat_index if host is not None else None, + "can_start": self.can_start, + "seats": [ + { + "index": seat.seat_index, + "name": seat.display_name, + "color": seat.color, + "ready": seat.ready, + "host": seat.host, + "connected": seat.connected, + } + for seat in sorted(self.seats, key=lambda s: (s.seat_index, s.token)) + ], + "spectators": [ + {"name": spec.display_name, "color": spec.color} + for spec in self.spectators + ], + "unlocked": session.unlocked_level if session is not None else None, + "round_no": self.round_no, + } + + # -------------------------------------------------------------- input + + def queue_input(self, player_id: str, frame: PlayerInput) -> bool: + """Queue one frame of intent for the next tick. + + Only legal in `PLAYING` (elsewhere the frame is dropped — no error: + a client that sent late is not at fault); an id no seat holds is a + buffered `err` event (`unknown_player`), never an exception. The room + stamps `received_at_tick` with the sim's current tick, so input + ordering never depends on asyncio scheduling or the client's counter. + The queue is per-tick: `tick_once` passes it to `step` and clears it. + + Returns: + True when the frame was queued; False when it was dropped + (wrong state) or rejected (`unknown_player`). + """ + if self.state is not RoomState.PLAYING: + return False + if self.seat_for_token(player_id) is None: + reason = ( + "not_seated" + if self.spectator_for_token(player_id) is not None + else "unknown_player" + ) + self.events_since_snapshot.append( + Event( + kind=EventKind.ERROR, + payload={ + "reason": reason, + "player_id": player_id, + "seq": frame.seq, + }, + ) + ) + return False + tick = self.sim_state.tick if self.sim_state is not None else 0 + self._queued_inputs.append(replace(frame, received_at_tick=tick)) + return True + + def take_events(self) -> tuple[Event, ...]: + """Drain `events_since_snapshot` (the phase-06 `ev`/snapshot boundary). + + The buffer is drained exactly once per call: what is returned is + everything since the last call, and the next call starts empty. + `snapshot_seq` counts the boundary. + """ + events = tuple(self.events_since_snapshot) + self.events_since_snapshot.clear() + self.snapshot_seq += 1 + return events + + # ------------------------------------------------------- tick ownership + + @property + def tick_period(self) -> float: + return 1.0 / self._settings.tick_hz + + @property + def task(self) -> asyncio.Task[None] | None: + """The room's one owned tick task, or `None` before `start_ticking()`.""" + return self._task + + def start_ticking(self) -> None: + """Create the tick task (idempotent while it is running). + + (The lobby's *Start* by token is `start(token)` — task 03; the + no-token host start is task 02's `request_start`.) + """ + if self._task is not None and not self._task.done(): + return + self._task = asyncio.create_task(self.tick_loop(), name=f"room-{self.code}") + + async def close(self) -> None: + """Teardown — idempotent. Takes the `close` transition (legal from + every live state), cancels the tick task and (unless this *is* the + tick task's own call path) awaits it, then frees the seats. Task + cancellation is the only teardown (PLAN §6); there is no other way + to stop a room. + """ + if self.state is not RoomState.CLOSED: + self._fire("close") + self.closed_at = self._clock.now() + if self._task is not None and not self._task.done(): + self._task.cancel() + if self._task is not None and asyncio.current_task() is not self._task: + with contextlib.suppress(asyncio.CancelledError): + await self._task + self.seats.clear() + self.spectators.clear() + self._color_memory.clear() + self._emote_times.clear() + + async def tick_loop(self) -> None: + """The room's one task: one `tick_once` per period until closed. + + Deadline-scheduled off the injected clock — the next deadline is + computed (`deadline += period`), not slept as `1/tick_hz`, so the + cadence cannot accumulate drift. When the clock jumps (a lid, a test + advancing the `FakeClock`), a deadline that is more than a period in + the past resynchronises: the room catches up with at most one clamped + tick (the sim's `MAX_DT` clamp makes a burst worthless) and then + waits the normal period. + """ + period = self.tick_period + deadline = self._clock.now() + period + while self.state is not RoomState.CLOSED: + await self._sleep(max(0.0, deadline - self._clock.now())) + self.tick_once(self._clock.now()) + deadline += period + if deadline < self._clock.now() - period: + deadline = self._clock.now() + period + + def tick_once(self, now: float | None = None) -> None: + """Advance the room by one tick — the synchronous core. + + `now` (or the clock) sets the wall time; the step `dt` is the time + since the previous tick (0 on the first, floored at 0). The whole + body is crash-isolated: any exception logs (code + round, never a + player token), aborts the sim, buffers an `err` event, fires `close` + and never propagates out of the loop (one room must not kill the + server — `CancelledError` is not an `Exception`, so a teardown cancel + passes through untouched). + """ + when = self._clock.now() if now is None else float(now) + dt = 0.0 if self._last_at is None else max(0.0, when - self._last_at) + self._last_at = when + if self.state is RoomState.CLOSED: + return + try: + self._tick(dt, when) + except Exception as exc: # crash isolation: *any* tick failure aborts the room + self._crash_isolate(exc) + + def _tick(self, dt: float, when: float) -> None: + """One tick, by state (the body `tick_once` crash-isolates).""" + state = self.state + if state is RoomState.COUNTDOWN: + self.countdown_left = max(0.0, self.countdown_left - dt) + if self.countdown_left <= _EPS_S: + self._fire("countdown_done") + self._build_sim_state() + return + if state is RoomState.PLAYING: + if self.sim_state is None or self._content is None: + msg = f"room {self.code} is PLAYING with no built state (internal invariant)" + raise RuntimeError(msg) + queued = tuple(self._queued_inputs) + self._queued_inputs = [] + stepper = ( + self._test_step_override + if self._test_step_override is not None + else step + ) + self.sim_state, events = stepper( + self.sim_state, dt, queued, self._content.recipe_book + ) + self.events_since_snapshot.extend(events) + if self.sim_state.result is not None: + # The settle (sim/round_state.SETTLE_S): the first tick the + # verdict lands opens the window; it closes SETTLE_S of wall + # time later — the sim is the frozen stage-7 machine in + # between — and only then does the round end. + self._settle_left = ( + SETTLE_S if self._settle_left is None else self._settle_left - dt + ) + if self._settle_left <= _EPS_S: + self.last_result = result_payload(self.sim_state) + self._record_round_result() + self._fire("round_over") + return + # LOBBY or ROUND_END: the idle checks, the same rules and numbers the + # supervisor's GC applies externally (the room's own copy, so a room + # closes itself even if the GC is slow by a cycle). + if ( + self.is_empty + and self.empty_since is not None + and when - self.empty_since > SEAT_GRACE_S + ): + self._fire("all_seats_empty") + return + if when - self.last_activity > self._settings.lobby_idle_timeout_s: + self._fire("idle_expired") + + def _build_sim_state(self) -> None: + """Build tick 0 of the round: level + seated players + fresh seed. + + The room does the seeding `sim/state_build.py` leaves to it: the + spawn shelves' stock from `level.station_items` (the sim carries no + legend) and the prop stock counters from `level.station_limits` + (`sim/stations.py`'s `DEFAULT_STOCK` is the stand-in a round built + without them sees; a room-built round is capped by the level). + """ + if self.level is None: + msg = f"room {self.code} has no level to build a round from" + raise RuntimeError(msg) + if self._content is None: + msg = f"room {self.code} has no content bundle to build a round from" + raise RuntimeError(msg) + assert self.round_seed is not None # _begin_round ran before the countdown + seat_specs = [ + SeatSpec( + player_id=seat.player_id, + seat=seat.seat_index, + display_name=seat.display_name, + color=seat.color, + ) + for seat in sorted(self.seats, key=lambda seat: (seat.seat_index, seat.token)) + ] + state = build_state(self.level, seat_specs, self.round_seed, self._content.recipe_book) + stations = list(state.stations) + for index, station in enumerate(stations): + if station.kind is StationKind.SPAWN_SHELF: + kind = self.level.station_items[station.id] + stations[index] = replace( + station, contents=(Item(kind, ItemForm.RAW),) * 8 + ) + limits = self.level.station_limits + self.sim_state = replace( + state, + stations=tuple(stations), + counters={ + "plates_clean": limits.plates, + "plates_dirty": 0, + "pots_clean": limits.pots, + "pots_dirty": 0, + }, + ) + + def _record_round_result(self) -> None: + """The `round_over` half (task 04): record the finished round + into every seated player's session. A room without a store + (a test-built one) records nothing; spectators are not players + and are not recorded. A `LOST`/`ABORTED` verdict is still a + finished round: `rounds_played` / `dishes_served` count it, + and only a `WON` one touches the progression (the store's + rule).""" + if self._store is None or self.last_result is None or self.level is None: + return + for seat in self.seats: + self._store.record_result(seat.token, self.level, self.last_result) + + def _crash_isolate(self, exc: Exception) -> None: + """The crash-isolation seam: log, abort, tell the players, close. + + The log line carries the code and the round number only — never a + player token (PLAN §11). The sim is marked `ABORTED` through + `sim.round_state.abort` (a round that already won or lost keeps its + verdict — the sim's own rule), an `err` event is buffered for the + phase-06 `ev`/`err` broadcast, and `close` fires (legal from every + live state) — the exception itself never propagates out of + `tick_once`. + """ + logger.exception( + "room %s round %d: the tick crashed (%s); aborting the room", + self.code, + self.round_no, + type(exc).__name__, + ) + self.abort_reason = f"round {self.round_no} tick crashed: {type(exc).__name__}" + if self.sim_state is not None: + self.sim_state = abort(self.sim_state, self.abort_reason) + self.events_since_snapshot.append( + Event( + kind=EventKind.ERROR, + payload={"reason": "tick_crash", "round": self.round_no}, + ) + ) + self._fire("close") diff --git a/src/undercooked/server/rooms.py b/src/undercooked/server/rooms.py new file mode 100644 index 0000000..10a3a24 --- /dev/null +++ b/src/undercooked/server/rooms.py @@ -0,0 +1,491 @@ +"""The `RoomSupervisor`: the registry of every live room (PLAN §6). + +One per process, on `app.state.supervisor` (installed by `create_app`, +which already refuses to build without a validated content bundle — that +gate is the supervisor's "feature flag": rooms only exist when content +loaded). It owns: + +* **code minting** — `new_code` over the registry (collision-checked, + never reused while the process lives), +* **capacity** — `RoomCapacityError` at `settings.max_rooms`, `RoomFull` + at `settings.max_players` seats, +* **the token index** — one token, one room: a token that joins a second + room is evicted from the first (PLAN §7 "one socket per token"); the + rejoin path keeps *disconnected* seats in place, +* **the GC** — one pass per 5 s (`gc_loop`, cancellable; the sleep is + injectable so tests advance a `FakeClock`): a room whose last seat left + more than `SEAT_GRACE_S` ago is closed, a `LOBBY` room idle past + `settings.lobby_idle_timeout_s` is closed, already-closed rooms are + dropped, and stale token-index entries are purged, +* **teardown** — `start()` runs the GC loop and every room's tick task; + `shutdown()` cancels them all and awaits them. +* **the session store** — a bounded LRU `SessionStore` (task 04): every + token that created or joined a room is remembered there, and the + progression (`unlocked_level`, the best scores, the counters) is the + store's to own — `session_for` reads through it (task 03's + `set_level` unlock check and `lobby_payload`'s `unlocked` read the + session's `unlocked_level` through this), the rooms get it as their + `store`, and `POST /api/rooms` / `GET /api/session` run on it. + In-memory only: a process restart clears all progression (A6). + +Every timestamp comes from the injected `Clock`; nothing in this module +touches a wall clock (the grep guard is +`tests/unit/test_server_clock_discipline.py`). +""" + +from __future__ import annotations + +import asyncio +import logging +import random +from collections.abc import Awaitable, Callable +from contextlib import suppress + +from undercooked.config import Settings +from undercooked.server.clock import Clock +from undercooked.server.codes import new_code, normalize_code +from undercooked.server.room import SEAT_GRACE_S, Room, RoomState +from undercooked.server.seats import NAME_MAX_LEN, sanitize_name +from undercooked.server.sessions import SessionRecord, SessionStore +from undercooked.sim.content import ContentBundle + +__all__ = [ + "GC_INTERVAL_S", + "NAME_MAX_LEN", + "SEAT_GRACE_S", + "PlayerNotInRoom", + "RoomCapacityError", + "RoomClosed", + "RoomFull", + "RoomNotFound", + "RoomSupervisor", + "sanitize_name", +] + +logger = logging.getLogger(__name__) + +#: The GC loop's cadence (PLAN §6: "GC task every 5 s"). +GC_INTERVAL_S = 5.0 + + +class RoomNotFound(RuntimeError): # noqa: N818 - name fixed by the phase task file + """No live room under that code — *including* a malformed code. + + Deliberately indistinguishable: a typed `!!!` must fail exactly like an + unknown `ZZZZZ`, or the API becomes a room-existence oracle (US-02 + criterion 4). + """ + + +class RoomClosed(RuntimeError): # noqa: N818 - name fixed by the phase task file + """The room exists but is `CLOSED` (waiting for the GC to drop it).""" + + +class RoomFull(RuntimeError): # noqa: N818 - name fixed by the phase task file + """The room has `max_players` seats and the token holds none of them.""" + + +class RoomCapacityError(RuntimeError): + """The process holds `max_rooms` rooms; new rooms are refused (503).""" + + +class PlayerNotInRoom(RuntimeError): # noqa: N818 - name fixed by the phase task file + """The token holds no seat in that room (leave/disconnect of a + stranger).""" + + +class RoomSupervisor: + """The process-wide registry: rooms by code, players by token. + + Args: + settings: the process's `Settings` (seats cap, room cap, the lobby + idle timeout). + clock: injected `Clock` — never a wall clock. + content: the validated `ContentBundle`; a new room's default level + is the campaign's first (or `None` for an empty campaign). + code_rng: a `random.Random` for deterministic code minting in tests; + `None` draws with `secrets.choice`. + sleep: the GC loop's sleep; `None` = `asyncio.sleep`. + """ + + def __init__( + self, + settings: Settings, + clock: Clock, + content: ContentBundle, + *, + code_rng: random.Random | None = None, + sleep: Callable[[float], Awaitable[None]] | None = None, + ) -> None: + self._settings = settings + self._clock = clock + self._content = content + self._code_rng = code_rng + self._sleep = sleep if sleep is not None else asyncio.sleep + self._rooms: dict[str, Room] = {} + self._token_index: dict[str, str] = {} + self._sessions = SessionStore( + clock=self._clock, + campaign={level.id: level.order for level in self._content.levels}, + ) + self._gc_task: asyncio.Task[None] | None = None + + # ------------------------------------------------------------ properties + + @property + def settings(self) -> Settings: + return self._settings + + @property + def clock(self) -> Clock: + return self._clock + + @property + def gc_task(self) -> asyncio.Task[None] | None: + return self._gc_task + + @property + def content(self) -> ContentBundle: + return self._content + + @property + def sessions(self) -> SessionStore: + """The bounded LRU session store (task 04): the progression + (unlocks, best scores, counters), the `GET /api/session` body + and the diagnostics (`stats`).""" + return self._sessions + + def session_for(self, token: str) -> SessionRecord | None: + """The session record a token is known under (create/join), or + `None` — the rooms' `set_level`/`lobby_payload` read the session's + `unlocked_level` through this (task 03), through the store + (task 04: its bounded LRU, never a bare dict).""" + return self._sessions.get(token) + + # ------------------------------------------------------------- registry + + def create_room(self, session: SessionRecord, display_name: str | None = None) -> Room: + """Mint a code, seat the creator at seat 0 as host, register both. + + The session's token is the creator's identity; its display name is + the fallback when `display_name` is `None`. + + Raises: + RoomCapacityError: the process already holds `max_rooms` rooms. + """ + if len(self._rooms) >= self._settings.max_rooms: + msg = ( + f"room capacity reached ({self._settings.max_rooms}); " + "this lobby is full, try again" + ) + raise RoomCapacityError(msg) + code = new_code(self._rooms.keys(), self._code_rng) + now = self._clock.now() + level_id = self._content.levels[0].id if self._content.levels else None + room = Room( + code=code, + settings=self._settings, + clock=self._clock, + level_id=level_id, + sleep=self._sleep, + content=self._content, + sessions=self.session_for, + store=self._sessions, + ) + room.add_seat( + session.token, + sanitize_name( + display_name if display_name is not None else session.display_name, 0 + ), + host=True, + now=now, + ) + self._evict_token(session.token, code) + if self._sessions.get(session.token) is None: + self._sessions.put(session) + self._rooms[code] = room + self._token_index[session.token] = code + return room + + def join( + self, raw_code: str, token: str, display_name: str | None = None + ) -> Room: + """Seat `token` in the room under `raw_code` (normalized first) — + or make them a *spectator* when the room is already past `LOBBY` + (`COUNTDOWN`/`PLAYING`/`ROUND_END`; PLAN §6 "late joins wait in + `SPECTATING` until `LOBBY` — `Room.to_lobby` promotes them into + the free seats). + + A token that already holds a seat *in this room* reclaims it (the + rejoin path: a disconnected seat is marked connected again, the + name updated only when a new one is typed); a token that already + watches this room reclaims the spectator entry. A token holding + a seat (or watching) in a *different* room is evicted from there + first — one token, one room. + + Raises: + RoomNotFound: the code is malformed *or* unknown — never the + two apart (US-02 criterion 4). + RoomClosed: the room is `CLOSED` and still in the registry. + RoomFull: the room is full and the token holds no seat in it + (a full room never stops accepting *spectators*). + """ + code = self._lookup(raw_code) + room = self._rooms[code] + if room.state is RoomState.CLOSED: + msg = f"room {code} is closed" + raise RoomClosed(msg) + now = self._clock.now() + existing = room.seat_for_token(token) + if existing is not None: + existing.mark_connected(now) + if display_name is not None: + existing.display_name = sanitize_name(display_name, existing.seat_index) + room.note_activity(now) + self._remember_name(token, display_name) + self._token_index[token] = code + return room + existing_spec = room.spectator_for_token(token) + if existing_spec is not None: + existing_spec.mark_connected(now) + if display_name is not None: + existing_spec.display_name = sanitize_name( + display_name, len(room.seats) + len(room.spectators) - 1 + ) + room.note_activity(now) + self._remember_name(token, display_name) + self._token_index[token] = code + return room + self._evict_token(token, code) + if room.state is RoomState.LOBBY: + if room.is_full: + msg = f"room {code} is full ({room.max_players}/{room.max_players})" + raise RoomFull(msg) + room.add_seat( + token, + sanitize_name(display_name, room.next_seat_index()), + host=False, + now=now, + ) + else: + room.add_spectator( + token, + sanitize_name(display_name, len(room.seats) + len(room.spectators)), + now=now, + ) + self._remember_name(token, display_name) + self._token_index[token] = code + return room + + def _remember_name(self, token: str, display_name: str | None) -> None: + """The join's session side: an *unknown* token auto-creates a + record on the spot (the transport does not hand one in before + phase 06). A known record is left alone — its progression is the + store's to own; its name is filled in only when it is empty and + one was typed. + """ + record = self._sessions.get_or_create(token) + if display_name is not None and record.display_name is None: + record.display_name = sanitize_name(display_name, 0) + + def leave(self, raw_code: str, token: str) -> None: + """Remove `token` from the room — the seat, or the spectator + entry (a watcher's `leave`); an emptied room starts its grace + clock (the GC closes it after `SEAT_GRACE_S`). + + Raises: + RoomNotFound: malformed or unknown code. + PlayerNotInRoom: the token holds no seat and watches nowhere + there. + """ + code = self._lookup(raw_code) + room = self._rooms[code] + now = self._clock.now() + if not room.remove_seat(token, now=now) and not room.remove_spectator(token): + msg = f"token holds no seat in room {code}" + raise PlayerNotInRoom(msg) + self._drop_token(token, code) + logger.info( + "a player left room %s (%d seats, %d watching)", + code, + len(room.seats), + len(room.spectators), + ) + + def mark_disconnected(self, raw_code: str, token: str) -> None: + """Mark a player's connection dropped (phase 06 calls this when a + socket closes; a seat is kept for the rejoin path, a spectator + entry is kept for the re-watch path). + + Raises: + RoomNotFound: malformed or unknown code. + PlayerNotInRoom: the token holds no seat and watches nowhere. + """ + code = self._lookup(raw_code) + room = self._rooms[code] + now = self._clock.now() + if room.mark_seat_disconnected(token, now=now): + return + if room.mark_spectator_disconnected(token, now=now): + return + msg = f"token holds no seat in room {code}" + raise PlayerNotInRoom(msg) + + def get(self, raw_code: str) -> Room | None: + """The room under `raw_code` (normalized), or `None`. + + A malformed code and an unknown code both yield `None` — the + lookup never distinguishes the two (US-02 criterion 4). + """ + code = normalize_code(raw_code) + if code is None: + return None + return self._rooms.get(code) + + def by_token(self, token: str) -> Room | None: + """The room `token` is seated in (via the index), or `None`.""" + code = self._token_index.get(token) + if code is None: + return None + return self._rooms.get(code) + + def all_rooms(self) -> tuple[Room, ...]: + """Every room in the registry (closed-but-undropped included), + sorted by code for deterministic iteration.""" + return tuple(sorted(self._rooms.values(), key=lambda room: room.code)) + + def stats(self) -> dict[str, object]: + """A fixed-shape census: room/seat/token counts and the state mix.""" + states: dict[str, int] = {} + for room in self._rooms.values(): + states[room.state.value] = states.get(room.state.value, 0) + 1 + return { + "rooms": len(self._rooms), + "max_rooms": self._settings.max_rooms, + "seats": sum(len(room.seats) for room in self._rooms.values()), + "states": states, + "tokens": len(self._token_index), + "spectators": sum(len(room.spectators) for room in self._rooms.values()), + } + + # ------------------------------------------------------------- close + GC + + async def close_room(self, raw_code: str) -> None: + """Close one room (idempotent). The room stays in the registry in + `CLOSED` state until the next GC pass drops it — that window is + what makes `RoomClosed` observable to `join` and to the API. + + Raises: + RoomNotFound: malformed or unknown code. + """ + code = self._lookup(raw_code) + await self._rooms[code].close() + + async def gc_pass(self) -> int: + """One garbage-collection pass; returns how many rooms it closed. + + Rules, in order: + 1. drop rooms already `CLOSED` (their task is already cancelled); + 2. close rooms whose last seat left more than `SEAT_GRACE_S` ago + (any state — an orphaned room is an orphaned room); + 3. close `LOBBY` rooms idle past `settings.lobby_idle_timeout_s`; + 4. purge token-index entries whose room is gone or seat is empty. + """ + now = self._clock.now() + closed = 0 + for code in list(self._rooms): + room = self._rooms[code] + if room.state is RoomState.CLOSED: + self._drop_room(code) + continue + orphaned = ( + room.is_empty + and room.empty_since is not None + and (now - room.empty_since) > SEAT_GRACE_S + ) + idle_lobby = ( + room.state is RoomState.LOBBY + and (now - room.last_activity) > self._settings.lobby_idle_timeout_s + ) + if orphaned or idle_lobby: + await room.close() + self._drop_room(code) + closed += 1 + logger.info("gc closed room %s (%d seats)", code, len(room.seats)) + for token in list(self._token_index): + code = self._token_index[token] + room = self._rooms.get(code) + if room is None or ( + room.seat_for_token(token) is None + and room.spectator_for_token(token) is None + ): + del self._token_index[token] + return closed + + async def gc_loop(self) -> None: + """Run `gc_pass` every `GC_INTERVAL_S` until cancelled. The sleep is + the injected one, so a test drives the cadence with a `FakeClock` + and never waits in real time.""" + while True: + await self._sleep(GC_INTERVAL_S) + await self.gc_pass() + + # ------------------------------------------------------------- lifecycle + + async def start(self) -> None: + """Start the GC loop and every room's tick task (idempotent while + they run).""" + if self._gc_task is not None and not self._gc_task.done(): + return + self._gc_task = asyncio.create_task(self.gc_loop(), name="room-gc") + for room in self._rooms.values(): + room.start_ticking() + + async def shutdown(self) -> None: + """Cancel the GC loop and every room's tick task, and await them + all. The process is going down: rooms are dropped, not kept.""" + if self._gc_task is not None and not self._gc_task.done(): + self._gc_task.cancel() + if self._gc_task is not None: + with suppress(asyncio.CancelledError): + await self._gc_task + self._gc_task = None + for code in list(self._rooms): + await self._rooms[code].close() + self._drop_room(code) + + # ------------------------------------------------------------- internals + + def _lookup(self, raw_code: str) -> str: + """Normalize and find; one exception for both failure modes.""" + code = normalize_code(raw_code) + if code is None or code not in self._rooms: + msg = "room not found" + raise RoomNotFound(msg) + return code + + def _evict_token(self, token: str, from_code: str) -> None: + """One token, one room: drop the seat (and the spectator entry) + in any *other* room first.""" + previous = self._token_index.get(token) + if previous is None or previous == from_code: + return + old = self._rooms.get(previous) + if old is not None: + old.remove_seat(token, now=self._clock.now()) + old.remove_spectator(token) + + def _drop_token(self, token: str, code: str) -> None: + """Remove `token` from the index when it points at `code`.""" + if self._token_index.get(token) == code: + del self._token_index[token] + + def _drop_room(self, code: str) -> None: + """Remove a room from the registry and every token that points at it.""" + self._rooms.pop(code, None) + for token in [ + token + for token, room_code in self._token_index.items() + if room_code == code + ]: + del self._token_index[token] diff --git a/src/undercooked/server/routes_rooms.py b/src/undercooked/server/routes_rooms.py new file mode 100644 index 0000000..418f78f --- /dev/null +++ b/src/undercooked/server/routes_rooms.py @@ -0,0 +1,252 @@ +"""The room HTTP surface: `POST /api/rooms`, `GET /api/rooms/{code}`, +`POST /api/rooms/{code}/join`, `GET /api/session`. + +This is how the lobby lifecycle is exercised end-to-end *before* the +WebSocket transport exists (phase 06): create a room, inspect it, join it, +rejoin it, watch the capacity refusals — over plain HTTP, driven by +`tests/integration/test_rooms_api.py` (and the session's progression over +`tests/integration/test_sessions.py`). + +Three contracts live here: + +* **tokens are minted server-side — or taken from the caller — and + returned once.** `POST /api/rooms` takes the session from the + `X-Undercooked-Token` header or the body's `token` (the header wins): + an unknown token is auto-created at the campaign floor and a known one + keeps its progression; both absent, the server mints + `secrets.token_urlsafe(24)` (via `sessions.mint_token`) and hands the + token to the creator in `you`. The token never appears in any other + response. Joining passes the token in the body; the response's `you` + echoes *the caller's own* token back and nothing else. +* **seats never leak tokens.** `seats` is `{index, name, host, connected}` + — a name plus flags, never the token. `GET /api/rooms/{code}` has no + caller at all, so its `you` is `null`. `GET /api/session` is the same + way: its body (`SessionView`) is the store's `to_public` projection — + `display_name`, `unlocked`, `best`, `rounds_played`, `dishes_served`, + `endless_best` — and it carries no token. +* **error bodies are one fixed shape — `{"code": ""}`**: + `room_not_found` (404, for a malformed *or* unknown code: the two must + stay indistinguishable, US-02 criterion 4), `room_closed` (410), + `room_full` (503, for both a full room and a full process), `no_token` + (400, `GET /api/session` without the `X-Undercooked-Token` header). +""" + +from __future__ import annotations + +from fastapi import APIRouter, Body, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel, ConfigDict, Field + +from undercooked.server.room import Room, RoomState +from undercooked.server.rooms import ( + RoomCapacityError, + RoomClosed, + RoomFull, + RoomNotFound, + RoomSupervisor, +) +from undercooked.server.seats import sanitize_name +from undercooked.server.sessions import new_session + +__all__ = [ + "BestRunView", + "RoomCreateRequest", + "RoomJoinRequest", + "RoomView", + "SeatView", + "SessionView", + "YouView", + "rooms_router", +] + + +class RoomCreateRequest(BaseModel): + """`POST /api/rooms` body: an optional display name (sanitized + server-side) and an optional session `token` (the + `X-Undercooked-Token` header wins over it; both absent, the server + mints one, returned once in `you`).""" + + model_config = ConfigDict(extra="forbid") + + name: str | None = None + token: str | None = Field(default=None, max_length=128) + + +class RoomJoinRequest(BaseModel): + """`POST /api/rooms/{code}/join` body: the caller's token, optional name.""" + + model_config = ConfigDict(extra="forbid") + + token: str = Field(min_length=1, max_length=128) + name: str | None = None + + +class SeatView(BaseModel): + """One seat as *other* players see it: no token, ever.""" + + model_config = ConfigDict(extra="forbid") + + index: int + name: str + host: bool + connected: bool + + +class YouView(BaseModel): + """The caller's own seat: token + index + host flag (the token is the + caller's, returned once on create, echoed on join).""" + + model_config = ConfigDict(extra="forbid") + + token: str + seat: int + host: bool + + +class RoomView(BaseModel): + """The fixed response shape for every room endpoint.""" + + model_config = ConfigDict(extra="forbid") + + room_code: str + state: RoomState + seats: list[SeatView] + you: YouView | None = None + + +class BestRunView(BaseModel): + """One level's best run, as the session's `to_public` carries it.""" + + model_config = ConfigDict(extra="forbid") + + score: int + stars: int + completed_at: float + + +class SessionView(BaseModel): + """`GET /api/session`'s fixed body — the store's `to_public` + projection (nothing beyond it: no token, no internal stamps).""" + + model_config = ConfigDict(extra="forbid") + + display_name: str | None + unlocked: int + best: dict[str, BestRunView] + rounds_played: int + dishes_served: int + endless_best: int | None + + +def _supervisor(request: Request) -> RoomSupervisor: + supervisor: RoomSupervisor = request.app.state.supervisor + return supervisor + + +def _view(room: Room, you: YouView | None = None) -> RoomView: + return RoomView( + room_code=room.code, + state=room.state, + seats=[ + SeatView( + index=seat.seat_index, + name=seat.display_name, + host=seat.host, + connected=seat.connected, + ) + for seat in sorted(room.seats, key=lambda seat: seat.seat_index) + ], + you=you, + ) + + +def _error(status_code: int, code: str) -> JSONResponse: + return JSONResponse(status_code=status_code, content={"code": code}) + + +rooms_router = APIRouter(tags=["rooms"]) + + +@rooms_router.post("/api/rooms", status_code=201, response_model=RoomView) +async def create_room( + request: Request, + body: RoomCreateRequest | None = Body(default=None), # noqa: B008 - FastAPI's standard dependency-injection idiom +) -> RoomView | JSONResponse: + """Take (or mint) a session token and a room code; the creator is + seat 0, host. + + The body is optional — a bare `curl -X POST` (no JSON) is the + phase's completion criterion and reads as no name, no token. + The session comes from the `X-Undercooked-Token` header or the + body's `token` (the header wins): an unknown token is auto-created + at the campaign floor, a known one keeps its progression; both + absent, the server mints one (returned once, in `you`). + `503 {"code": "room_full"}` when the process is at `max_rooms` + (US-01 criterion 7 — the client turns it into "lobby full, try + again"). + """ + supervisor = _supervisor(request) + body = body if body is not None else RoomCreateRequest() + token = ( + request.headers.get("X-Undercooked-Token", "").strip() + or (body.token or "").strip() + or None + ) + if token is not None: + session = supervisor.sessions.get_or_create(token) + if body.name is not None and session.display_name is None: + session.display_name = sanitize_name(body.name, 0) + else: + session = new_session(body.name, supervisor.clock) + try: + room = supervisor.create_room(session, body.name) + except RoomCapacityError: + return _error(503, "room_full") + return _view(room, YouView(token=session.token, seat=0, host=True)) + + +@rooms_router.get("/api/rooms/{code}", response_model=RoomView) +async def get_room(request: Request, code: str) -> RoomView | JSONResponse: + """Inspect one room. No caller identity exists in a GET, so `you` is + `null` and no token ever appears; a malformed code 404s exactly like + an unknown one (US-02 criterion 4).""" + room = _supervisor(request).get(code) + if room is None: + return _error(404, "room_not_found") + return _view(room) + + +@rooms_router.post("/api/rooms/{code}/join", response_model=RoomView) +async def join_room( + request: Request, code: str, body: RoomJoinRequest +) -> RoomView | JSONResponse: + """Seat the caller's token in the room: a fresh seat, a reclaimed one + (rejoin), or one of the three named refusals.""" + supervisor = _supervisor(request) + try: + room = supervisor.join(code, body.token, body.name) + except RoomNotFound: + return _error(404, "room_not_found") + except RoomClosed: + return _error(410, "room_closed") + except RoomFull: + return _error(503, "room_full") + seat = room.seat_for_token(body.token) + assert seat is not None # just joined: the seat exists + return _view(room, YouView(token=body.token, seat=seat.seat_index, host=seat.host)) + + +@rooms_router.get("/api/session", response_model=SessionView) +async def get_session(request: Request) -> SessionView | JSONResponse: + """The caller's session (task 04): the store's `to_public` projection + of the token in `X-Undercooked-Token`. + + A fresh (never-seen) token auto-creates and reads `unlocked: 1` with + an empty `best`; a missing or blank header is a 400 + `{"code": "no_token"}` — a session without an identity is + meaningless. + """ + token = request.headers.get("X-Undercooked-Token", "").strip() + if not token: + return _error(400, "no_token") + return SessionView(**_supervisor(request).sessions.to_public(token)) diff --git a/src/undercooked/server/seats.py b/src/undercooked/server/seats.py new file mode 100644 index 0000000..46bc5ac --- /dev/null +++ b/src/undercooked/server/seats.py @@ -0,0 +1,251 @@ +"""The lobby's data and pure helpers (phase 05, task 03). + +`Room` (`server/room.py`) is the guarded FSM; this module holds what the +lobby half of it walks: + +* **`Seat`** — one occupied seat: the token, a display name sanitized at + the boundary, the seat index, the ready-up flag, the host flag, the + connection flag. `toggle_ready` / `mark_connected` / `mark_disconnected` + are the seat's own verbs; the room and the supervisor are its callers. +* **`Spectator`** — a token that arrived while the room is already + `COUNTDOWN`/`PLAYING`/`ROUND_END`: no seat, `role="spectator"`, keeps + watching (phase 06 ships them the snapshots); inputs are ignored + (`err not_seated`). +* **`sanitize_name`** — the display-name boundary (US-02 criterion 6): + strip Unicode control and format characters (zero-width marks, RTL + overrides, soft hyphen) and `<>"'`, collapse runs of whitespace, trim, + cap at 16; an empty result falls back to `Cook #{seat_index + 1}`. + Names are non-unique by design; the client renders them as text (never + HTML). +* **`SEAT_PALETTE`** — the four lobby colours, handed out first-free in + seat order; a token's colour is remembered for the life of the room, so + a reconnect keeps it. +* **`EMOTES`** — the emote whitelist (PLAN §7: "no chat in v1, emote + whitelist"); `emote` messages validate against this frozenset and are + throttled to 2 per 1.0 s per connection. +* **the named refusals** — `StartRefused` (task 02's preconditions), + `NothingToStart` (task 03's name for the same refusal — a + `StartRefused`, the reason rides along), `NotHost`, `NotInLobby`, + `UnknownLevel`, `LevelLocked`, `UnknownEmote`: the lobby verbs' + contract, one named exception per named cause. +""" + +from __future__ import annotations + +import re +import unicodedata +from dataclasses import dataclass +from typing import Final + +__all__ = [ + "EMOTES", + "EMOTE_EVENT_KIND", + "EMOTE_MAX_PER_WINDOW", + "EMOTE_WINDOW_S", + "NAME_MAX_LEN", + "SEAT_PALETTE", + "LevelLocked", + "NotHost", + "NotInLobby", + "NothingToStart", + "Seat", + "Spectator", + "StartRefused", + "UnknownEmote", + "UnknownLevel", + "sanitize_name", +] + +#: Display names are capped here (PLAN §7: "trim, 16 chars"). +NAME_MAX_LEN = 16 + +#: The four lobby colours, in seat order (task 03: "first free colour +#: from the 4-colour palette in seat order"). +SEAT_PALETTE: Final[tuple[str, ...]] = ( + "#e5484d", # red + "#3b9eff", # blue + "#3ddc84", # green + "#ffb224", # amber +) + +#: The emote whitelist (PLAN §7 "emote whitelist"; no chat in v1). +EMOTES: Final[frozenset[str]] = frozenset( + {"wave", "cheer", "thumbs_up", "thumbs_down", "angry", "star"} +) + +#: Emotes are throttled to 2 per 1.0 s window per connection (task 03). +EMOTE_WINDOW_S: Final[float] = 1.0 +EMOTE_MAX_PER_WINDOW: Final[int] = 2 + +#: The `ev` kind an accepted emote re-broadcasts as (PLAN §7: `emote` in, +#: `ev` out — the id and the token ride in the payload). +EMOTE_EVENT_KIND: Final[str] = "emote" + + +class StartRefused(RuntimeError): # noqa: N818 - name fixed by the phase task file + """`request_start` was refused: the precondition is not met. + + `reason` is the named cause (`"no_level"` / `"no_ready_players"`) — + the client-facing `err` message for phase 06. + """ + + def __init__(self, reason: str) -> None: + super().__init__(reason) + self.reason = reason + + +class NothingToStart(StartRefused): + """`Room.start(token)` refused the start precondition (task 03's name + for task 02's refusal; a `StartRefused`, so the reason — and the + task-02 contract — ride along unchanged).""" + + +class NotHost(RuntimeError): # noqa: N818 - name fixed by the phase task file + """The token is not the seat that holds the crown (`start` / `set_level`).""" + + +class NotInLobby(RuntimeError): # noqa: N818 - name fixed by the phase task file + """The verb only runs in `LOBBY` (`start` / `set_level`).""" + + +class UnknownLevel(RuntimeError): # noqa: N818 - name fixed by the phase task file + """The level id is not in the room's campaign (`set_level`).""" + + +class LevelLocked(RuntimeError): # noqa: N818 - name fixed by the phase task file + """The level's `order` is past the requesting session's unlock + (`set_level`; a session without a record counts as unlocked 1).""" + + +class UnknownEmote(RuntimeError): # noqa: N818 - name fixed by the phase task file + """The emote id is not in `EMOTES` (`emote`).""" + + +# ---------------------------------------------------------------- name boundary + +#: Markup a display name may never carry: the angle/quote pair that would +#: let a name inject into any template or attribute (the client renders +#: names via `textContent` anyway; this is the server-side backstop). +_MARKUP = frozenset('<>"\'') + +#: Unicode categories a display name may never contain: `Cc` (control: +#: ASCII C0/C1, DEL) and `Cf` (format: zero-width marks, RTL/LTR +#: overrides, soft hyphen). Stripping `Cf` is what makes a zero-width-only +#: or RTL-marked name fall back to `Cook #N` instead of an invisible +#: string. +_CONTROL_CATEGORIES = frozenset({"Cc", "Cf"}) + +#: Runs of whitespace collapse to one space (kept, then collapsed — +#: only `Z*`-category whitespace survives the control strip). +_WHITESPACE = re.compile(r"\s+") + + +def _keep(char: str) -> bool: + """One character's verdict at the boundary.""" + return ( + unicodedata.category(char) not in _CONTROL_CATEGORIES and char not in _MARKUP + ) + + +def sanitize_name(raw: str | None, seat_index: int) -> str: + """Canonicalise a player's display name (US-02 criterion 6). + + Strips Unicode control and format characters (zero-width marks, RTL + overrides, soft hyphen) and `<>"'`, collapses runs of whitespace, + trims, caps at 16 characters; an empty result falls back to + `Cook #{seat_index + 1}`. Names are non-unique by design — two + players may share one. The result is rendered by the client as text + (never HTML). + """ + if raw is None: + return f"Cook #{seat_index + 1}" + cleaned = "".join(char for char in raw if _keep(char)) + collapsed = _WHITESPACE.sub(" ", cleaned).strip() + if not collapsed: + return f"Cook #{seat_index + 1}" + return collapsed[:NAME_MAX_LEN].rstrip() + + +# --------------------------------------------------------------------- seats + +@dataclass +class Seat: + """One occupied seat. An empty seat is the *absence* of an entry. + + A seat stays in the list while its player is merely *disconnected* + (laptop closed, flaky Wi-Fi) — that is the rejoin path phase 12 + refines — and is removed only when the player genuinely leaves. + + `player_id` is the identity the sim knows the cook by (it equals the + token — `PlayerInput.player_id` must match `Player.id`, and the room + builds the state from the seats); `color` is the `SEAT_PALETTE` + entry the lobby's colour pass assigned (first free in seat order); + `ready` is the ready-up flag (task 03's `toggle_ready` / the room's + `set_ready` own its lifecycle; `request_start` reads it: ≥ 1 ready + non-host, or a solo host). + """ + + token: str + display_name: str + seat_index: int + host: bool = False + connected: bool = True + last_seen: float = 0.0 + ready: bool = False + player_id: str = "" + color: str = "" + + @property + def is_host(self) -> bool: + """Whether this seat holds the crown (PLAN §6's host authority).""" + return self.host + + def toggle_ready(self) -> bool: + """Flip the ready-up flag (US-03) and return the new value.""" + self.ready = not self.ready + return self.ready + + def mark_connected(self, now: float | None = None) -> None: + """The (re)connected: the seat is live again (the rejoin path + keeps it in place).""" + self.connected = True + if now is not None: + self.last_seen = now + + def mark_disconnected(self, now: float | None = None) -> None: + """The connection dropped; the seat is kept for the rejoin path + (phase 12 refines the grace).""" + self.connected = False + if now is not None: + self.last_seen = now + + +@dataclass +class Spectator: + """A player watching a round they did not start in (PLAN §6: "late + joins wait in `SPECTATING`"). No seat, no sim player: they receive + state and snapshots (phase 06) and their inputs are ignored + (`err not_seated`). `role` is the payload's discriminator; it is + always `"spectator"`. + """ + + token: str + display_name: str + color: str + joined_at: float + last_seen: float = 0.0 + connected: bool = True + role: str = "spectator" + + def mark_connected(self, now: float | None = None) -> None: + """The (re)connected: the entry is live again (the entry is + kept in place).""" + self.connected = True + if now is not None: + self.last_seen = now + + def mark_disconnected(self, now: float | None = None) -> None: + """The connection dropped; the entry is kept (leave removes it).""" + self.connected = False + if now is not None: + self.last_seen = now diff --git a/src/undercooked/server/sessions.py b/src/undercooked/server/sessions.py new file mode 100644 index 0000000..9de8ba8 --- /dev/null +++ b/src/undercooked/server/sessions.py @@ -0,0 +1,363 @@ +"""In-memory session store (A6, A16) — the campaign progression (phase 05 task 04). + +**In-memory only; a process restart clears all progression.** There is no +persistence and no optional one (A6): the unlocks, the best scores and +the counters are process memory and die with it (the README repeats this). +A browser tab's identity is a 24-byte token (`secrets.token_urlsafe(24)` — +the same entropy the browser side generates with `crypto.getRandomValues`, +PLAN A16), and the store remembers, per token, only: + +* `display_name` — sanitized at the boundary, non-unique, `None` when + nobody typed one; +* `unlocked_level` — the highest campaign *order* the session may play + (starts at `FLOOR_UNLOCKED_LEVEL`, the campaign's floor); it rises to + `level.order + 1` on a `WON` result and **never decreases**; +* `best[level_id]` — a `BestRun` (`score`, `stars`, `completed_at`); the + `completed_at` is the injected clock's seconds — a monotonic-relative + number, no timezone, and there is no IP, no user-agent and no raw + header anywhere in this module (`tests/unit/test_session_privacy.py` + locks the surface); +* `rounds_played` / `dishes_served` — counters that accumulate across + rooms; +* `created_at` / `last_seen` — the store's LRU stamps; +* `endless_best` — phase 13's endless-mode best score (`None` until then). + +`SessionStore` is the bounded holder (LRU, `DEFAULT_MAX_ENTRIES` = 10 000 +by default, PLAN §6): `get_or_create` (an unknown token auto-creates at +`unlocked_level=1`), `put` (a caller-minted record; an existing token's +progression is never replaced), `record_result` (one finished round), +`is_unlocked`, `next_level_id`, `to_public` (the `GET /api/session` +body — nothing beyond the public shape) and `stats` (diagnostics). LRU +eviction runs on a *new* insert: the oldest `last_seen` goes — never the +token that just touched the store. Every timestamp comes from the +injected `Clock` (default: the `SystemClock`, which holds +`time.monotonic` as a *reference*; `server/clock.py` stays the only +`server/` file that imports `time`, and the grep guard stays green). +""" + +from __future__ import annotations + +import secrets +from collections.abc import Mapping +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any + +from undercooked.server.clock import Clock, SystemClock + +if TYPE_CHECKING: + from undercooked.sim.content import ContentBundle + from undercooked.sim.level import Level + +__all__ = [ + "DEFAULT_MAX_ENTRIES", + "FLOOR_UNLOCKED_LEVEL", + "WON_RESULT", + "BestRun", + "SessionRecord", + "SessionStore", + "mint_token", + "new_session", +] + +#: Tokens are 24 random bytes, base64url — the exact entropy A16 fixes for +#: the browser side (`crypto.getRandomValues` + base64url). +_TOKEN_BYTES = 24 + +#: The campaign floor: a fresh session may play campaign `order` 1. +FLOOR_UNLOCKED_LEVEL = 1 + +#: The store's default cap (PLAN §6: "LRU-capped at 10 000 entries"). +DEFAULT_MAX_ENTRIES = 10_000 + +#: The `result_payload` value of the `result` key for a won round. +WON_RESULT = "won" + + +def mint_token() -> str: + """Mint one session token: 24 random bytes, urlsafe base64 (32 chars).""" + return secrets.token_urlsafe(_TOKEN_BYTES) + + +def _as_int(value: object, default: int) -> int: + """A payload number as an `int`: `result_payload`'s values are plain + JSON types, but a hand-built caller may send a float (or the wrong + type — which reads as the default, never a crash).""" + if isinstance(value, int) and not isinstance(value, bool): + return value + if isinstance(value, float) and value.is_integer(): + return int(value) + return default + + +@dataclass(slots=True) +class BestRun: + """One level's best finished run. + + `completed_at` is the injected clock's seconds — a monotonic-relative + number, never a wall timestamp with a timezone (and there is no IP, + user-agent or raw header in this store, ever). + """ + + score: int + stars: int + completed_at: float + + +@dataclass(slots=True) +class SessionRecord: + """One anonymous session, as the supervisor and API see it. + + `unlocked_level` is the highest campaign `order` the session may + play (starts at `FLOOR_UNLOCKED_LEVEL`); `best[level_id]` is the + best *won* run per level; `rounds_played` / `dishes_served` + accumulate across rooms; `created_at` / `last_seen` are the store's + LRU stamps; `endless_best` is phase 13's (the endless mode's best + score; `None` until it lands). Nothing PII-like; everything dies + with the process (A6). + """ + + token: str + display_name: str | None + created_at: float + unlocked_level: int = FLOOR_UNLOCKED_LEVEL + best: dict[str, BestRun] = field(default_factory=dict) + rounds_played: int = 0 + dishes_served: int = 0 + last_seen: float = 0.0 + endless_best: int | None = None + + +def new_session(display_name: str | None, clock: Clock) -> SessionRecord: + """Mint a fresh session: new token, the given (or absent) name, stamped + with the injected clock.""" + now = clock.now() + return SessionRecord( + token=mint_token(), + display_name=display_name, + created_at=now, + last_seen=now, + ) + + +class SessionStore: + """The process-wide, LRU-capped session store (PLAN §6). + + The supervisor owns exactly one (the app's); a `Room` holds a + reference to it (a test-built room may hold none). The `campaign` + mapping (`level_id -> order`, from the content bundle at + construction) is what `is_unlocked` resolves against; `next_level_id` + takes the `ContentBundle` itself, in campaign order. + + Args: + max_entries: the LRU cap (PLAN §6: 10 000 by default); must be + at least 1. + clock: the injected `Clock`; `None` = the `SystemClock` (never a + bare wall-clock call in this module). + campaign: `level_id -> order` for `is_unlocked`; `None` = a + bundle-less room's campaign (nothing is resolvable, so + nothing but the floor's logic applies). + """ + + def __init__( + self, + max_entries: int = DEFAULT_MAX_ENTRIES, + *, + clock: Clock | None = None, + campaign: Mapping[str, int] | None = None, + ) -> None: + if max_entries < 1: + msg = f"max_entries must be >= 1 (got {max_entries})" + raise ValueError(msg) + self._max_entries = max_entries + self._clock = clock if clock is not None else SystemClock() + self._campaign: Mapping[str, int] = dict(campaign) if campaign is not None else {} + self._records: dict[str, SessionRecord] = {} + self._evictions = 0 + + @property + def max_entries(self) -> int: + return self._max_entries + + @property + def clock(self) -> Clock: + return self._clock + + def __len__(self) -> int: + return len(self._records) + + # ------------------------------------------------------------- lookup + + def get(self, token: str) -> SessionRecord | None: + """The record `token` is known under, or `None`. + + Never auto-creates: a lookup is not an identity. + """ + return self._records.get(token) + + def get_or_create(self, token: str) -> SessionRecord: + """The record for `token`, created on first sight at + `unlocked_level=1` (an unknown token auto-creates — the + task-04 criterion). + + Touches `last_seen` either way (the LRU's stamp); evicts on the + *new* insert only, and never evicts `token` itself. + """ + now = self._clock.now() + record = self._records.get(token) + if record is None: + self._evict(keep=token) + record = SessionRecord( + token=token, display_name=None, created_at=now, last_seen=now + ) + self._records[token] = record + record.last_seen = now + return record + + def put(self, record: SessionRecord) -> None: + """Store a caller-minted record (the supervisor's create path). + + A token the store already knows is **left alone** — a caller + cannot replace a session's progression (a re-creator keeps their + unlocks). Touches `last_seen`, evicts on the new insert only, + and never evicts the record's own token. + """ + if record.token in self._records: + return + self._evict(keep=record.token) + self._records[record.token] = record + record.last_seen = self._clock.now() + + # ------------------------------------------------------------- results + + def record_result( + self, token: str, level: Level, payload: Mapping[str, object] + ) -> None: + """Record one finished round for `token` (the room's `round_over` + calls this for every seated player). + + Every result counts the round (`rounds_played += 1`) and the + served dishes (`dishes_served += counters.served` — a loss serves + dishes too). Only a `WON` result touches the progression: + `unlocked_level` rises to `level.order + 1` (never decreases), + and `best[level.id]` updates **when the score beats the stored + one** — a tie keeps the older run (and its `completed_at`). The + `stars` recorded are the winning run's. + """ + record = self.get_or_create(token) + record.rounds_played += 1 + counters = payload.get("counters") + record.dishes_served += ( + _as_int(counters.get("served", 0), 0) if isinstance(counters, Mapping) else 0 + ) + if payload.get("result") != WON_RESULT: + return + record.unlocked_level = max(record.unlocked_level, level.order + 1) + score = _as_int(payload.get("score", 0), 0) + stored = record.best.get(level.id) + if stored is None or score > stored.score: + record.best[level.id] = BestRun( + score=score, + stars=_as_int(payload.get("stars", 0), 0), + completed_at=self._clock.now(), + ) + + # -------------------------------------------------------- unlock / next + + def is_unlocked(self, token: str, level_id: str) -> bool: + """Whether `token`'s session may play `level_id`: + `order <= unlocked_level`. + + An id that is not in the campaign the store was built with is + `False` (locked by default — the room checks `UnknownLevel` + before asking, so this is the store's own fallback, not the + room's). A token without a record counts as unlocked 1 (the + campaign's floor). + """ + order = self._campaign.get(level_id) + if order is None: + return False + record = self.get(token) + unlocked = record.unlocked_level if record is not None else FLOOR_UNLOCKED_LEVEL + return order <= unlocked + + def next_level_id(self, token: str, content: ContentBundle) -> str | None: + """The campaign's next level for `token`'s session, or `None`. + + The lowest campaign `order` that is unlocked and has no best run + yet: after a win, `order + 1`'s level; after a loss, the level + just played (retry); `None` when every unlocked level has been + won (the campaign is done for that session). This is the value + the room's `next_level` resolves through (`Room.next_level_id`). + """ + record = self.get(token) + unlocked = record.unlocked_level if record is not None else FLOOR_UNLOCKED_LEVEL + completed = ( + set(record.best) if record is not None else frozenset() + ) # level ids with a best run + for level in content.levels: # campaign order + if level.order <= unlocked and level.id not in completed: + return level.id + return None + + # ------------------------------------------------------------- surface + + def to_public(self, token: str) -> dict[str, Any]: + """What the client may see (the `GET /api/session` body). + + Exactly `display_name`, `unlocked`, `best` + (`{level_id: {score, stars, completed_at}}`), `rounds_played`, + `dishes_served`, `endless_best` — nothing else: no `token` (the + client holds it itself), no `created_at` / `last_seen` (the LRU's + internal stamps), no IP / user-agent / header (there is none to + leak). A fresh (never-seen) token auto-creates and reads + `unlocked: 1` with an empty `best`. The values are plain JSON + types by construction (`Any`: the `SessionView` model above it + re-validates them on the wire). + """ + record = self.get_or_create(token) + return { + "display_name": record.display_name, + "unlocked": record.unlocked_level, + "best": { + level_id: { + "score": run.score, + "stars": run.stars, + "completed_at": run.completed_at, + } + for level_id, run in sorted(record.best.items()) + }, + "rounds_played": record.rounds_played, + "dishes_served": record.dishes_served, + "endless_best": record.endless_best, + } + + def stats(self) -> dict[str, object]: + """The diagnostics census (fixed shape: `sessions`, `max_entries`, + `evictions`).""" + return { + "sessions": len(self._records), + "max_entries": self._max_entries, + "evictions": self._evictions, + } + + # ---------------------------------------------------------------- LRU + + def _evict(self, keep: str) -> None: + """Keep the store at its cap on a new insert. + + The oldest `last_seen` goes — never `keep` (the token that just + touched the store is never evicted). Ties break on the token, + so the choice is deterministic. When the store holds only `keep` + (cap 1, a re-touch), there is nothing to evict. + """ + if len(self._records) < self._max_entries: + return + candidates = sorted( + (record.last_seen, token) + for token, record in self._records.items() + if token != keep + ) + if not candidates: + return + del self._records[candidates[0][1]] + self._evictions += 1 diff --git a/tests/e2e/test_us01_landing_surface.py b/tests/e2e/test_us01_landing_surface.py index 2b2ccdc..3a9f58b 100644 --- a/tests/e2e/test_us01_landing_surface.py +++ b/tests/e2e/test_us01_landing_surface.py @@ -24,7 +24,7 @@ pytestmark = pytest.mark.e2e # Strings a player sees, kept in sync with app/static/index.html and # app/static/js/app.js by hand: changing one must be a deliberate act. READY = "Pick a kitchen to start." -NOT_IMPLEMENTED = "Not implemented until phase 05." +NOT_IMPLEMENTED = "Not implemented until phase 07." SHORT_CODE = "A room code is 5 characters." TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,}$") diff --git a/tests/integration/test_lifecycle_end_to_end.py b/tests/integration/test_lifecycle_end_to_end.py new file mode 100644 index 0000000..7906411 --- /dev/null +++ b/tests/integration/test_lifecycle_end_to_end.py @@ -0,0 +1,570 @@ +"""Integration: the whole lobby/room lifecycle over HTTP (phase 05, task 05; +US-01/US-02/US-03) — every path the task file names, driven by `TestClient` +plus manual `tick_once` (a `FakeClock`; no test sleeps in real time). + +* **the happy path** — `POST /api/rooms` → three `POST .../join` → ready-up + → the host's `start` → the 3 s countdown in 0.05 s wall frames → a + scripted full-pipeline round to `WON` → the `ROUND_END` payload is + `result_payload` (the exact key set, the per-seat `players`) → + `next_level` → a second (losing) round → `to_lobby`: back in `LOBBY` + with the same seats, tokens and colours, the ready flags reset, +* **the solo path** — create, `start` with no other players, a scripted + win, and the session's progression (the task-04 wiring through the + supervisor's store: `unlocked_level` up to 2, a `best` run, the + counters, the `next_level_id` advice), +* **the full-room path** — the 5th join is a 503 `room_full` and the + room's code is retained (still 200, still four seats), +* **the bad-code path** — `GET /api/rooms/ZZZZZ` (unknown) and + `GET /api/rooms/nope!` (malformed) answer with the same status and the + same body (US-02 criterion 4: the API is not a room-existence oracle), + with no stack trace in the response, +* **the abandon path** — every player leaves a `LOBBY`; exactly at + `SEAT_GRACE_S` the room is still up, one `gc_pass()` after the grace + closes and drops it (the code no longer resolves — a 404), +* **the idle-lobby expiry** — a `FakeClock` advanced past + `lobby_idle_timeout_s` and one `gc_pass()` close an occupied idle + lobby (exactly at the timeout: not yet), +* **the capacity** — with `max_rooms=2`, the 3rd `POST /api/rooms` is a + 503 `room_full` and the existing rooms are untouched. + +The app's supervisor is swapped before any request (a `FakeClock` + the +test's own content bundle — the pattern `test_rooms_api.py` pins), and +the `TestClient` is used **without** the context manager: the lifespan +never starts the rooms' tick tasks, so every tick is the test's own +`tick_once` call and `gc_pass` runs in a fresh `asyncio.run` with nothing +to cancel. (There is no HTTP `start`/`ready` yet — that is the phase-06 +transport; `docs/ROOMS.md` "Lifecycle walkthrough" documents the gap.) +""" + +from __future__ import annotations + +import asyncio +import sys +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from undercooked.config import Settings +from undercooked.server.app import create_app +from undercooked.server.clock import FakeClock +from undercooked.server.room import SEAT_GRACE_S, Room, RoomState +from undercooked.server.rooms import RoomSupervisor +from undercooked.sim.content import ContentBundle +from undercooked.sim.level import Level, LevelDef +from undercooked.sim.model import ( + EventKind, + ItemForm, + ItemKind, + KitchenState, + RoundResult, + StationKind, +) +from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step + +pytestmark = pytest.mark.integration + +#: The test-isolated content dir (a recipe book, no levels) — explicit, so +#: the app never reads the shipped `levels/` implicitly. The bundle the +#: supervisor gets carries the test's own two levels. +FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels" + +#: The sim's scripted-cook harness lives with the sim's tests (test-only). +_SIM_TESTS = Path(__file__).resolve().parents[1] / "sim" +if str(_SIM_TESTS) not in sys.path: + sys.path.insert(0, str(_SIM_TESTS)) +from helpers import ( # noqa: E402 # type: ignore[reportMissingImports] + CookController, + shelf_id_for, +) + +#: One 20 Hz tick, in wall seconds (the sim's `STEP_DT`). +PERIOD = 0.05 + +#: The exact key set of `result_payload` (PLAN §7's `round` body) — the +#: `ROUND_END` payload the happy path checks (the phase-04 contract). +RESULT_PAYLOAD_KEYS = { + "result", + "score", + "target", + "stars", + "time_left", + "counters", + "longest_streak", + "players", + "level_id", + "seed", + "ticks", +} + + +# ------------------------------------------------------------------ content + + +def make_book() -> RecipeBook: + """A one-recipe book: the full-pipeline `soup` (pick → chop → cook → + plate → serve), so the scripted cook runs the whole station chain — + the same shape the phase-04 full rounds prove.""" + 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="pick", station=StationKind.SPAWN_SHELF), + Step(kind="chop", station=StationKind.CHOP_BOARD, seconds=0.9), + Step(kind="cook", station=StationKind.STOVE, seconds=4.0), + Step(kind="plate", station=StationKind.PREP, seconds=0.4), + Step(kind="serve", station=StationKind.SERVE), + ], + ) + }, + ingredients=[ItemKind.TOMATO], + ) + + +def make_level( + level_id: str, *, order: int, time_limit: float, target: int +) -> Level: + """An 8x5 kitchen with the full station chain: one spawn shelf + (`tomato`), a chop board, a stove, a prep and a serve window — every + fixture has a walkable stand cell (the scripted cook's BFS). The + four spawn points seat the four cooks in four distinct cells, the + host on the working row and the three idle cooks on the back row: + an idle cook that shares the one-tile corridor parks where the + Goto's detour has no exit cell (the body-cell avoid set covers every + neighbour), and a single shared spawn cell overlaps the bodies at + start (the soft-push drift is what parks an idle cook there). The + back-row bench keeps the corridor free for the scripted cook.""" + data: dict[str, object] = { + "id": level_id, + "name": f"Lifecycle kitchen {level_id}", + "order": order, + "time_limit": time_limit, + "target_score": target, + "grid": [ + "########", + "#S.C.VP#", + "#......#", + "#T.....#", + "########", + ], + "tiles": { + ".": {"kind": "floor"}, + "#": {"kind": "wall"}, + "S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"}, + "C": {"kind": "floor", "station": "chop_board"}, + "T": {"kind": "floor", "station": "stove"}, + "V": {"kind": "floor", "station": "serve"}, + "P": {"kind": "floor", "station": "prep"}, + }, + "spawns": [[2, 2], [3, 3], [4, 3], [5, 3]], + "orders": { + "pool": ["soup"], + "concurrent": 1, + "spawn_interval": [4, 6], + "deadline": 30, + }, + } + return Level.build(LevelDef.model_validate(data)) + + +def make_bundle() -> ContentBundle: + """The test's two-level campaign: A (order 1, 60 s, a target a single + scripted serve clears) and B (order 2, 5 s, a target nothing clears) — + the happy path's win and its quick losing follow-up.""" + return ContentBundle( + recipe_book=make_book(), + levels=( + make_level("07_win", order=1, time_limit=60.0, target=10), + make_level("07_lose", order=2, time_limit=5.0, target=1000), + ), + ) + + +# --------------------------------------------------------------------- app + + +class LifecycleApp: + """An app whose supervisor runs on a `FakeClock` (the + `FakeClockSupervisorApp` pattern `test_rooms_api.py` pins), with the + test's own content bundle (the fixture dir is a zero-level campaign; + the rooms need a level to run a round). The `TestClient` is used + without the context manager, so the rooms' tick tasks are never + started — every tick is the test's own `tick_once`. + + The `settings` are the app's (the supervisor reuses them, so the + task's `max_rooms=2` / `lobby_idle_timeout_s` overrides apply). + """ + + def __init__(self, **settings_overrides: Any) -> None: + self.settings = Settings(levels_dir=FIXTURE_LEVELS_DIR, **settings_overrides) + self.app = create_app(self.settings) + self.clock = FakeClock() + self.app.state.supervisor = RoomSupervisor( + self.settings, self.clock, make_bundle() + ) + + @property + def supervisor(self) -> RoomSupervisor: + return self.app.state.supervisor + + +# ---------------------------------------------------------------- helpers + + +def create_room(client: TestClient, name: str | None = None) -> dict[str, Any]: + body: dict[str, Any] = {} + if name is not None: + body["name"] = name + response = client.post("/api/rooms", json=body) + assert response.status_code == 201, response.text + return response.json() + + +def join_room( + client: TestClient, code: str, token: str, name: str | None = None +) -> tuple[int, dict[str, Any]]: + body: dict[str, Any] = {"token": token} + if name is not None: + body["name"] = name + response = client.post(f"/api/rooms/{code}/join", json=body) + return response.status_code, response.json() + + +def drive_countdown_to_playing(room: Room, clock: FakeClock) -> None: + """The 3.0 s countdown in 0.05 s wall frames (the manual `tick_once` + cadence); leaves the room in `PLAYING` with the round built.""" + for _ in range(90): # 60 frames are the 3.0 s; the rest is slack + clock.advance(PERIOD) + room.tick_once() + if room.state is RoomState.PLAYING: + break + assert room.state is RoomState.PLAYING + assert room.sim_state is not None + + +def drive_round_to_end( + room: Room, clock: FakeClock, max_ticks: int, active: str | None = None +) -> None: + """0.05 s wall frames until the room is out of `PLAYING` (verdict + + the 1.0 s settle → `ROUND_END`). When `active` is a seat's token, + the phase-04 scripted cook drives that one cook (the controller is + built before the loop — the round is already built); every other + seat stays idle (no input). The cook stops feeding once the verdict + lands (the frozen settle ignores inputs anyway).""" + controller: CookController | None = None + if active is not None: + state = room.sim_state + assert state is not None # the countdown has built the round + controller = CookController(0, shelf_id_for(state, ItemKind.TOMATO), "any") + for _ in range(max_ticks): + if room.state is not RoomState.PLAYING: + break + clock.advance(PERIOD) + state = room.sim_state + assert state is not None + if controller is not None and state.result is None: + frame = controller.next_frame(state, state.tick) + assert frame.player_id == active # seat 0 is the host's sim player + room.queue_input(frame.player_id, frame) + room.tick_once() + assert room.state is RoomState.ROUND_END + + +def check_won_payload( + room: Room, state: KitchenState, level_id: str, n_players: int +) -> Mapping[str, object]: + """The `ROUND_END` payload is the `result_payload` of the verdict: + the exact key set, the win, the star grade, the per-seat `players`.""" + payload = room.last_result + assert payload is not None + assert set(payload) == RESULT_PAYLOAD_KEYS + assert payload["result"] == "won" + assert payload["score"] == state.score + assert payload["target"] == state.target_score + assert payload["stars"] in (1, 2, 3) # a win grades at least one star + assert payload["level_id"] == level_id + assert payload["seed"] == room.round_seed + players = payload["players"] + assert isinstance(players, list) + assert len(players) == n_players # one per seat + return payload + + +def check_round_end_events(room: Room) -> None: + """The verdict's events (the `ROUND_END` and the served dishes) landed + in the buffer and drain exactly once.""" + events = room.take_events() + assert any(e.kind is EventKind.ROUND_END for e in events) + assert any(e.kind is EventKind.SERVED for e in events) + assert room.take_events() == () + + +def check_second_losing_round( + room: Room, clock: FakeClock, level_id: str +) -> None: + """`next_level`: the campaign's second level, round 2 — an idle round + loses on the 5 s buzzer (plus the settle).""" + room.next_level(level_id) + assert room.state is RoomState.COUNTDOWN + assert room.level_id == level_id + assert room.round_no == 2 + drive_countdown_to_playing(room, clock) + assert room.sim_state is not None + assert room.sim_state.level_id == level_id + drive_round_to_end(room, clock, max_ticks=300) + assert room.sim_state is not None + assert room.sim_state.result is RoundResult.LOST + assert room.last_result is not None and room.last_result["result"] == "lost" + + +def check_lobby_return( + client: TestClient, + room: Room, + code: str, + host_token: str, + colours: dict[str, str], +) -> None: + """Back to the `LOBBY`: the same seats and colours, the ready flags + reset, the crown untouched — and over HTTP the room answers with all + four seats.""" + room.to_lobby() + assert room.state is RoomState.LOBBY + seated = sorted(room.seats, key=lambda seat: seat.seat_index) + assert [seat.token for seat in seated] == [ + host_token, + "tok-bob", + "tok-cy", + "tok-di", + ] + assert {seat.token: seat.color for seat in seated} == colours + assert all(not seat.ready for seat in seated) # ready up again + assert seated[0].host is True # the crown was never in play + get = client.get(f"/api/rooms/{code}") + assert get.status_code == 200 + body = get.json() + assert body["state"] == "lobby" and len(body["seats"]) == 4 + + +# --------------------------------------------------------------- the paths + + +def test_happy_path_from_create_to_won_to_next_level_to_lobby() -> None: + """The task's happy path, end to end (HTTP for the lobby shell, manual + `tick_once` for the round — no sleeps).""" + fixture = LifecycleApp() + client = TestClient(fixture.app) + sup = fixture.supervisor + level_a, level_b = sup.content.levels + + # create + three joins + body = create_room(client, "Ada") + code = body["room_code"] + host_token = body["you"]["token"] + assert body["you"]["seat"] == 0 and body["you"]["host"] is True + room = sup.get(code) + assert room is not None + assert room.level_id == level_a.id # the campaign's first by default + for name, token in (("Bob", "tok-bob"), ("Cy", "tok-cy"), ("Di", "tok-di")): + status, joined = join_room(client, code, token, name) + assert status == 200, joined + assert joined["you"]["token"] == token + assert len(room.seats) == 4 + + # ready-up (two of the three; one ready non-host is the precondition) + assert room.set_ready("tok-bob", True) + assert room.set_ready("tok-cy", True) + assert room.can_start + + # the host's start (the phase-06 `start` message is its transport copy) + room.start(host_token) + assert room.state is RoomState.COUNTDOWN + assert room.round_no == 1 + + # the countdown completes in 0.05 s wall frames + drive_countdown_to_playing(room, fixture.clock) + assert room.sim_state is not None + assert room.sim_state.level_id == level_a.id + + # the colours are handed out first-free in seat order — captured before + # the round so the return to the lobby can check them + colours = {seat.token: seat.color for seat in room.seats} + assert len(set(colours.values())) == 4 # the whole palette is used + + # the round: a scripted full-pipeline round to WON + drive_round_to_end(room, fixture.clock, max_ticks=1500, active=host_token) + state = room.sim_state + assert state is not None + assert state.result is RoundResult.WON + assert state.score >= state.target_score + + # the ROUND_END payload is the result_payload of the verdict + check_won_payload(room, state, level_a.id, n_players=4) + check_round_end_events(room) + + # `next_level` and the idle second round + check_second_losing_round(room, fixture.clock, level_b.id) + + # back to the LOBBY: the same seats and colours, the ready flags reset + check_lobby_return(client, room, code, host_token, colours) + + +def test_solo_path_wins_and_the_unlock_advances() -> None: + """Solo (no other players): the host's `start` is legal as-is, the + scripted round wins, and the task-04 wiring records the result into + the creator's session through the supervisor's store.""" + fixture = LifecycleApp() + client = TestClient(fixture.app) + sup = fixture.supervisor + level_a = sup.content.levels[0] + level_b = sup.content.levels[1] + + body = create_room(client, "Solo") + code = body["room_code"] + token = body["you"]["token"] + room = sup.get(code) + assert room is not None and len(room.seats) == 1 + + room.start(token) # the solo host: no ready flag needed + assert room.state is RoomState.COUNTDOWN + drive_countdown_to_playing(room, fixture.clock) + drive_round_to_end(room, fixture.clock, max_ticks=1500, active=token) + + state = room.sim_state + assert state is not None and state.result is RoundResult.WON + payload = room.last_result + assert payload is not None and payload["result"] == "won" + players = payload["players"] + assert isinstance(players, list) and len(players) == 1 + + # the progression: the store's rule, through the room's `round_over` + record = sup.sessions.get(token) + assert record is not None + assert record.unlocked_level == 2 # the campaign's floor raised by the win + assert record.best[level_a.id] is not None + assert record.best[level_a.id].score == payload["score"] + assert record.best[level_a.id].stars in (1, 2, 3) + assert record.rounds_played == 1 + assert record.dishes_served >= 1 + # and the `next_level` advice the campaign hands back + assert sup.sessions.next_level_id(token, sup.content) == level_b.id + + +def test_full_room_refuses_the_fifth_join_and_retains_the_code() -> None: + """With four seats taken, the 5th join is a 503 `room_full` — and the + room's code is retained (still resolvable, still four seats).""" + fixture = LifecycleApp() + client = TestClient(fixture.app) + + code = create_room(client, "Ada")["room_code"] + for token in ("tok-bob", "tok-cy", "tok-di"): + status, _ = join_room(client, code, token) + assert status == 200 + + status, body = join_room(client, code, "tok-eve", "Eve") + assert status == 503 + assert body == {"code": "room_full"} + + get = client.get(f"/api/rooms/{code}") + assert get.status_code == 200 + body = get.json() + assert len(body["seats"]) == 4 # the room is untouched + assert body["state"] == "lobby" + + +def test_bad_codes_are_indistinguishable_and_stack_trace_free() -> None: + """US-02 criterion 4: an unknown code (`ZZZZZ`) and a malformed one + (`nope!`) must produce the *same* response — the API is not a + room-existence oracle — and the refusal is rate-limit friendly (no + stack trace in the body).""" + fixture = LifecycleApp() + client = TestClient(fixture.app) + + unknown = client.get("/api/rooms/ZZZZZ") + malformed = client.get("/api/rooms/nope!") + assert unknown.status_code == 404 + assert malformed.status_code == 404 + assert unknown.json() == malformed.json() == {"code": "room_not_found"} + for response in (unknown, malformed): + assert "Traceback" not in response.text + assert "Exception" not in response.text + + +def test_all_players_leave_and_the_gc_closes_after_the_grace() -> None: + """The abandon path: every player leaves a `LOBBY`; exactly at + `SEAT_GRACE_S` the room is still up, and one `gc_pass()` past the + grace closes and drops it — the code no longer resolves (a 404).""" + fixture = LifecycleApp() + client = TestClient(fixture.app) + sup = fixture.supervisor + + body = create_room(client, "Ada") + code = body["room_code"] + host_token = body["you"]["token"] + status, _ = join_room(client, code, "tok-bob", "Bob") + assert status == 200 + + sup.leave(code, "tok-bob") + sup.leave(code, host_token) + room = sup.get(code) + assert room is not None and room.is_empty + assert room.empty_since == fixture.clock.now() + + fixture.clock.advance(SEAT_GRACE_S) # exactly 30 s is not *past* the grace + assert asyncio.run(sup.gc_pass()) == 0 + assert sup.get(code) is not None # still in the registry + + fixture.clock.advance(1.0) + assert asyncio.run(sup.gc_pass()) == 1 + assert sup.get(code) is None # dropped: the code no longer resolves + assert client.get(f"/api/rooms/{code}").status_code == 404 + + +def test_idle_lobby_expires_past_the_lobby_timeout() -> None: + """The idle-lobby rule (an occupied room, so not the grace): a + `FakeClock` advanced past `lobby_idle_timeout_s` and one `gc_pass()` + close it; exactly at the timeout it is still up.""" + fixture = LifecycleApp(lobby_idle_timeout_s=10.0) + client = TestClient(fixture.app) + sup = fixture.supervisor + + code = create_room(client, "Idle")["room_code"] + assert sup.get(code) is not None + + fixture.clock.advance(10.0) # exactly the timeout is not past it + assert asyncio.run(sup.gc_pass()) == 0 + assert sup.get(code) is not None + + fixture.clock.advance(0.5) + assert asyncio.run(sup.gc_pass()) == 1 + assert sup.get(code) is None + assert client.get(f"/api/rooms/{code}").status_code == 404 + + +def test_process_capacity_refuses_the_third_room_and_leaves_existing_ones() -> None: + """With `max_rooms=2`, the 3rd `POST /api/rooms` is a 503 `room_full` + (US-01 criterion 7) and the two existing rooms are untouched.""" + fixture = LifecycleApp(max_rooms=2) + client = TestClient(fixture.app) + + code_1 = create_room(client, "One")["room_code"] + code_2 = create_room(client, "Two")["room_code"] + + response = client.post("/api/rooms", json={}) + assert response.status_code == 503 + assert response.json() == {"code": "room_full"} + + for code in (code_1, code_2): # the existing rooms are untouched + get = client.get(f"/api/rooms/{code}") + assert get.status_code == 200 + body = get.json() + assert len(body["seats"]) == 1 + assert body["state"] == "lobby" + assert client.get("/healthz").json()["rooms"] == 2 diff --git a/tests/integration/test_lobby.py b/tests/integration/test_lobby.py new file mode 100644 index 0000000..51b3e31 --- /dev/null +++ b/tests/integration/test_lobby.py @@ -0,0 +1,815 @@ +"""Integration: the lobby (phase 05, task 03; US-02, US-03) — seats, +ready-up, the host's authority, level picking and spectators. + +The contract, driven by hand on a `FakeClock` (no test sleeps): + +* **ready-up** — `Seat.toggle_ready` / the room's `set_ready`, visible in + `lobby_payload` (the `ready` flags and `can_start`: a solo host, or ≥ 1 + ready non-host, and a selected level, and a crown holder), +* **start** — `Room.start(token)`: `LOBBY` only, host only + (`NotInLobby` / `NotHost`), then task 02's preconditions as + `NothingToStart` (a `StartRefused` — the reason rides along), +* **level pick** — `Room.set_level(token, level_id)`: `LOBBY` only, host + only, the id in the campaign (`UnknownLevel`) and its `order` within + the requesting session's `unlocked_level` (`LevelLocked`; a token + without a session record counts as unlocked 1), +* **the host rules** — the creator's seat 0 is the host; when the crown + holder leaves or their connection drops in `LOBBY`/`ROUND_END` the + crown moves to the lowest occupied, *connected* seat; in + `COUNTDOWN`/`PLAYING` the host slot is emptied and **no mid-round + election happens** (asserted explicitly); a returning host does not + automatically regain the crown, +* **spectators** — a `join` in `COUNTDOWN`/`PLAYING`/`ROUND_END` adds a + spectator (no seat, `role="spectator"`, a colour from the same + first-free palette); a spectator's input is an `err not_seated`; a + `leave` removes the entry; `to_lobby` promotes them into the free + seats (PLAN §6: "late joins wait in `SPECTATING` until `LOBBY`"), +* **colours** — the 4-colour palette, first free in seat order; a + token's colour is remembered for the life of the room (a reconnect + keeps it), +* **emotes** — the `EMOTES` whitelist (`UnknownEmote`), the 2-per-1.0 s + per-token throttle, re-broadcast as an `ev` event, +* **names** — sanitized at the boundary (a markup name comes back ≤ 16 + chars, markup-free). +""" + +from __future__ import annotations + +import copy +from typing import Any + +import pytest + +from undercooked.config import Settings +from undercooked.server.clock import FakeClock +from undercooked.server.room import Room, RoomState, StartRefused +from undercooked.server.rooms import PlayerNotInRoom, RoomSupervisor +from undercooked.server.seats import ( + EMOTE_EVENT_KIND, + EMOTES, + SEAT_PALETTE, + LevelLocked, + NothingToStart, + NotHost, + NotInLobby, + Seat, + Spectator, + UnknownEmote, + UnknownLevel, +) +from undercooked.server.sessions import SessionRecord, new_session +from undercooked.sim.content import ContentBundle +from undercooked.sim.level import Level, LevelDef +from undercooked.sim.model import EventKind, ItemForm, ItemKind, StationKind +from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step +from undercooked.sim.step import PlayerInput + +pytestmark = pytest.mark.integration + +# ------------------------------------------------------------------ fixtures + + +def make_book() -> RecipeBook: + """A one-recipe book: the small levels' order pool names `soup`.""" + 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, order: int, *, time_limit: float = 3.0, target: int = 1000 +) -> Level: + """A 7x4 kitchen (one spawn shelf, one serve window) at campaign + `order`; `target` above any idle score, so a round ends `LOST` on + the buzzer — the shortest path to `ROUND_END`.""" + 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"Lobby kitchen {level_id}", + "order": order, + "time_limit": time_limit, + "target_score": target, + "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(*levels: Level) -> ContentBundle: + return ContentBundle(recipe_book=make_book(), levels=levels) + + +def two_level_bundle() -> ContentBundle: + return make_bundle(make_level("05a_first", 1), make_level("05b_second", 2)) + + +def make_room( + bundle: ContentBundle | None = None, + sessions: dict[str, SessionRecord] | None = None, + **settings: Any, +) -> tuple[Room, FakeClock]: + """A `LOBBY` room on a `FakeClock`; `sessions` (or its absence) is + the room's token → session lookup (`None` = no lookup: a token counts + as unlocked 1).""" + 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, + sessions=(sessions or {}).get, + ) + return room, clock + + +def make_supervisor( + bundle: ContentBundle | None = None, **settings: Any +) -> tuple[RoomSupervisor, FakeClock]: + clock = FakeClock() + supervisor = RoomSupervisor(Settings(**settings), clock, bundle or two_level_bundle()) + return supervisor, clock + + +def prime(room: Room, now: float = 0.0) -> None: + room.tick_once(now=now) + + +def drive_countdown(room: Room, now: float = 0.0) -> float: + """The 3 s countdown in 1 s frames; returns the final `now`, with the + room in `PLAYING` (the round built).""" + prime(room, now) + for offset in (1.0, 2.0, 3.0): + room.tick_once(now=now + offset) + assert room.state is RoomState.PLAYING + return now + 3.0 + + +def play_round_to_end(room: Room, now: float = 3.0) -> float: + """1 s frames through the 3.0 s round and its 1.0 s settle window, + until `ROUND_END`; returns the final `now`.""" + while room.state is RoomState.PLAYING: + now += 1.0 + room.tick_once(now=now) + assert room.state is RoomState.ROUND_END + return now + + +def seat(room: Room, token: str) -> Seat: + """Narrow `seat_for_token` for the tests (the token is seated).""" + got = room.seat_for_token(token) + assert got is not None + return got + + +def watching(room: Room, token: str) -> Spectator: + """Narrow `spectator_for_token` for the tests (the token is watching).""" + got = room.spectator_for_token(token) + assert got is not None + return got + + +def seat_host_and_ready(room: Room) -> None: + room.add_seat("tok-a", "Ada", True) + room.add_seat("tok-b", "Bob", False) + assert room.set_ready("tok-b", True) + + +# ---------------------------------------------------------------- ready / start + + +def test_ready_toggles_are_visible_in_the_lobby_payload() -> None: + room, _ = make_room(two_level_bundle()) + room.add_seat("tok-a", "Ada", True) + room.add_seat("tok-b", "Bob", False) + cy = room.add_seat("tok-c", "Cy", False) + + payload: dict[str, Any] = room.lobby_payload() + assert [seat["ready"] for seat in payload["seats"]] == [False, False, False] + assert payload["can_start"] is False # a host, a level, but nobody ready + + assert cy.toggle_ready() is True + assert cy.toggle_ready() is False # the toggle flips back + assert seat(room, "tok-b").toggle_ready() is True + + lobby_view: dict[str, Any] = room.lobby_payload() + ready = {seat["index"]: seat["ready"] for seat in lobby_view["seats"]} + assert ready == {0: False, 1: True, 2: False} + assert room.lobby_payload()["can_start"] is True # one ready non-host is enough + + assert room.set_ready("tok-b", False) + assert room.lobby_payload()["can_start"] is False + + +def test_solo_start_is_allowed_and_the_payload_says_can_start() -> None: + room, _ = make_room(two_level_bundle()) + room.add_seat("tok-a", "Ada", True) + assert room.lobby_payload()["can_start"] is True # a solo host: no ready needed + room.start("tok-a") + assert room.state is RoomState.COUNTDOWN + assert room.round_no == 1 + + +def test_start_requires_a_ready_non_host_and_names_the_refusal() -> None: + room, _ = make_room(two_level_bundle()) + seat_host_and_ready(room) + seat(room, "tok-b").ready = False # the ready-up is not there yet + + with pytest.raises(NothingToStart, match="no_ready_players") as exc_info: + room.start("tok-a") + assert exc_info.value.reason == "no_ready_players" + assert isinstance(exc_info.value, StartRefused) # the task-02 contract rides along + assert room.state is RoomState.LOBBY # the refusal leaves no state change + + room.set_ready("tok-b", True) + room.start("tok-a") + assert room.state is RoomState.COUNTDOWN + + +def test_start_without_a_level_refuses_no_level() -> None: + room, _ = make_room(None) # no content bundle: no level selected + room.add_seat("tok-a", "Ada", True) + with pytest.raises(NothingToStart, match="no_level"): + room.start("tok-a") + assert room.state is RoomState.LOBBY + + +def test_start_and_set_level_by_a_non_host_are_rejected() -> None: + bundle = two_level_bundle() + sessions = {"tok-b": SessionRecord("tok-b", "Bob", 0.0, 2)} + room, _ = make_room(bundle, sessions) + room.add_seat("tok-a", "Ada", True) + room.add_seat("tok-b", "Bob", False) + room.set_ready("tok-b", True) # so the host's start can run past the guards + + with pytest.raises(NotHost): + room.start("tok-b") + with pytest.raises(NotHost): + room.start("tok-stranger") + with pytest.raises(NotHost): + room.set_level("tok-b", "05a_first") + + # Outside the lobby, neither verb runs — even for the host. + room.start("tok-a") + assert room.state is RoomState.COUNTDOWN + with pytest.raises(NotInLobby): + room.start("tok-a") + with pytest.raises(NotInLobby): + room.set_level("tok-a", "05a_first") + + +# ------------------------------------------------------------- level picking + + +def test_set_level_requires_a_known_unlocked_level() -> None: + sessions = { + "tok-a": SessionRecord("tok-a", "Ada", 0.0, 1), + "tok-b": SessionRecord("tok-b", "Bob", 0.0, 2), + } + room, _ = make_room(two_level_bundle(), sessions) + room.add_seat("tok-a", "Ada", True) + + with pytest.raises(UnknownLevel): + room.set_level("tok-a", "05_zzz") + with pytest.raises(LevelLocked, match="order 2"): + room.set_level("tok-a", "05b_second") # the session is unlocked 1 + assert room.level_id == "05a_first" # the refusal leaves the pick unchanged + + room.add_seat("tok-b", "Bob", False) + seat(room, "tok-a").host = False # the crown moves to Bob (unlocked 2) + seat(room, "tok-b").host = True + level = room.set_level("tok-b", "05b_second") + assert level.id == "05b_second" + assert room.level_id == "05b_second" + + +def test_set_level_without_a_session_lookup_counts_as_unlocked_1() -> None: + room, _ = make_room(two_level_bundle()) # no sessions map at all + room.add_seat("tok-a", "Ada", True) + with pytest.raises(LevelLocked): + room.set_level("tok-a", "05b_second") + with pytest.raises(UnknownLevel): + room.set_level("tok-a", "05_zzz") + picked = room.set_level("tok-a", "05a_first") # the floor is always pickable + assert picked.id == "05a_first" + + +def test_set_level_refuses_an_id_outside_a_bundleless_campaign() -> None: + room, _ = make_room(None) # no content bundle: no campaign to pick from + room.add_seat("tok-a", "Ada", True) + with pytest.raises(UnknownLevel): + room.set_level("tok-a", "05a_first") + + +# ---------------------------------------------------------------- the host rules + + +def test_host_handoff_in_the_lobby_goes_to_the_lowest_connected_seat() -> None: + room, _ = make_room(two_level_bundle()) + room.add_seat("tok-a", "Ada", True) + room.add_seat("tok-b", "Bob", False) + room.add_seat("tok-c", "Cy", False) + + # A dropped host connection is a host leave: the crown moves to the + # lowest occupied, *connected* seat. + assert room.mark_seat_disconnected("tok-a") + assert not seat(room, "tok-a").host + assert seat(room, "tok-b").host is True # seat 1, the lowest connected + assert room.lobby_payload()["host_seat"] == 1 + + # The host's leave does the same: on to the next connected seat. + assert room.remove_seat("tok-b") + assert seat(room, "tok-c").host is True # seat 2 + + # When no connected seat remains, the crown is left empty. + room.mark_seat_disconnected("tok-c") + room.mark_seat_disconnected("tok-a") + assert not any(seat.host for seat in room.seats) + assert room.lobby_payload()["host_seat"] is None + assert room.lobby_payload()["can_start"] is False # nobody can press Start + + +def test_host_handoff_in_round_end_elects_the_lowest_connected_seat() -> None: + room, _ = make_room(two_level_bundle()) + seat_host_and_ready(room) + room.start("tok-a") + now = drive_countdown(room) + now = play_round_to_end(room, now) + assert room.state is RoomState.ROUND_END + + assert room.mark_seat_disconnected("tok-a") + assert seat(room, "tok-b").host is True # elected in ROUND_END + assert room.lobby_payload()["host_seat"] == 1 + + +def test_no_mid_round_host_election_happens() -> None: + """The documented rule, asserted explicitly (not by omission): in + `COUNTDOWN`/`PLAYING` the host slot is emptied when the host leaves + or drops — and the round continues, with no election, until + `ROUND_END` (and no election happens on the state change either).""" + room, _ = make_room(two_level_bundle()) + seat_host_and_ready(room) + room.start("tok-a") + now = drive_countdown(room) # PLAYING + + assert room.mark_seat_disconnected("tok-a") + assert all(not seat.host for seat in room.seats) # the host slot is empty + assert room.lobby_payload()["host_seat"] is None + assert room.state is RoomState.PLAYING # the round continues + + now = play_round_to_end(room, now) + assert all(not seat.host for seat in room.seats) # still empty at ROUND_END + assert room.lobby_payload()["host_seat"] is None + assert room.lobby_payload()["can_start"] is False + + # The host's *leave* mid-round: the same rule. + room2, _ = make_room(two_level_bundle()) + seat_host_and_ready(room2) + room2.start("tok-a") + drive_countdown(room2) + assert room2.remove_seat("tok-a") + assert all(not seat.host for seat in room2.seats) + assert room2.state is RoomState.PLAYING + + +def test_a_returning_host_does_not_regain_the_crown() -> None: + room, _ = make_room(two_level_bundle()) + room.add_seat("tok-a", "Ada", True) + room.add_seat("tok-b", "Bob", False) + room.mark_seat_disconnected("tok-a") # the crown moves to Bob + assert seat(room, "tok-b").host is True + + # Ada's connection returns: the seat is live again, the crown stays. + assert room.mark_seat_connected("tok-a") + assert seat(room, "tok-a").connected is True + assert not seat(room, "tok-a").host + assert seat(room, "tok-b").host is True + + # Ada leaves the lobby and re-joins: the fresh seat is not the host. + room.remove_seat("tok-a") + again = room.add_seat("tok-a", "Ada", False) + assert again.host is False + assert seat(room, "tok-b").host is True + + +# --------------------------------------------------------------- spectators + + +def test_join_during_play_becomes_a_spectator() -> None: + supervisor, clock = make_supervisor(two_level_bundle()) + ada = new_session("Ada", clock) + room = supervisor.create_room(ada, "Ada") + bob = new_session("Bob", clock) + supervisor.join(room.code, bob.token, "Bob") + assert room.set_ready(bob.token, True) + room.start(ada.token) + drive_countdown(room) # PLAYING + + cy = new_session("Cy", clock) + supervisor.join(room.code, cy.token, "Cy") + assert room.seat_for_token(cy.token) is None # no seat + spec = room.spectator_for_token(cy.token) + assert spec is not None + assert spec.role == "spectator" and spec.connected + assert spec.display_name == "Cy" + assert spec.color == SEAT_PALETTE[2] # first free, after the two seats + + payload = room.lobby_payload(ada.token) + assert payload["spectators"] == [{"name": "Cy", "color": SEAT_PALETTE[2]}] + assert supervisor.stats()["spectators"] == 1 + # The token index still finds the watcher (by_token works for them). + assert supervisor.by_token(cy.token) is room + + +def test_a_full_room_still_accepts_spectators() -> None: + supervisor, clock = make_supervisor(two_level_bundle()) + eve = new_session("Eve", clock) + room = supervisor.create_room(eve, "Eve") + tokens = [eve.token] + for name in ("Fay", "Gus", "Hal"): + token = new_session(name, clock).token + supervisor.join(room.code, token, name) + tokens.append(token) + assert len(room.seats) == 4 # max_players + room.set_ready(tokens[1], True) + room.start(eve.token) + drive_countdown(room) + + imran = new_session("Imran", clock) + supervisor.join(room.code, imran.token, "Imran") # the 5th player: a watcher + assert room.seat_for_token(imran.token) is None + assert room.spectator_for_token(imran.token) is not None + # Four seats take the whole palette: the late watcher has no colour. + assert watching(room, imran.token).color == "" + + +def test_join_during_countdown_becomes_a_spectator() -> None: + supervisor, clock = make_supervisor(two_level_bundle()) + ida = new_session("Ida", clock) + room = supervisor.create_room(ida, "Ida") + room.start(ida.token) # a solo host: the countdown runs + prime(room) # the 3 s is in flight + + jon = new_session("Jon", clock) + supervisor.join(room.code, jon.token, "Jon") + assert room.seat_for_token(jon.token) is None + assert room.spectator_for_token(jon.token) is not None + + +def test_spectator_input_is_rejected_with_not_seated() -> None: + """A spectator's `input` is ignored — as a named `err`, not a + silent no-op (and distinct from `unknown_player`).""" + supervisor, clock = make_supervisor(two_level_bundle()) + ada = new_session("Ada", clock) + room = supervisor.create_room(ada, "Ada") + room.start(ada.token) + drive_countdown(room) + + cy = new_session("Cy", clock) + supervisor.join(room.code, cy.token, "Cy") + assert room.queue_input(cy.token, PlayerInput(player_id=cy.token)) is False + + events = room.take_events() + assert len(events) == 1 + assert events[0].kind is EventKind.ERROR + assert events[0].payload == { + "reason": "not_seated", + "player_id": cy.token, + "seq": 0, + } + + +def test_spectator_leave_removes_the_entry_and_disconnect_keeps_it() -> None: + supervisor, clock = make_supervisor(two_level_bundle()) + ada = new_session("Ada", clock) + room = supervisor.create_room(ada, "Ada") + room.start(ada.token) + drive_countdown(room) + + cy = new_session("Cy", clock) + supervisor.join(room.code, cy.token, "Cy") + supervisor.mark_disconnected(room.code, cy.token) # the entry is kept + spec = room.spectator_for_token(cy.token) + assert spec is not None and spec.connected is False + + supervisor.leave(room.code, cy.token) + assert room.spectator_for_token(cy.token) is None + assert supervisor.stats()["spectators"] == 0 + with pytest.raises(PlayerNotInRoom): + supervisor.leave(room.code, cy.token) + + +def test_spectator_rejoin_keeps_the_entry_and_the_color() -> None: + supervisor, clock = make_supervisor(two_level_bundle()) + ada = new_session("Ada", clock) + room = supervisor.create_room(ada, "Ada") + room.start(ada.token) + drive_countdown(room) + + cy = new_session("Cy", clock) + supervisor.join(room.code, cy.token, "Cy") + color = watching(room, cy.token).color + assert color == SEAT_PALETTE[1] # first free, after Ada's seat + + supervisor.mark_disconnected(room.code, cy.token) + supervisor.join(room.code, cy.token, "Cy") # the re-watch + spec = watching(room, cy.token) + assert spec.connected is True + assert spec.color == color # the same entry, the same colour + + +def test_spectators_are_promoted_to_free_seats_on_to_lobby() -> None: + supervisor, clock = make_supervisor(two_level_bundle()) + ada = new_session("Ada", clock) + room = supervisor.create_room(ada, "Ada") + bob = new_session("Bob", clock) + supervisor.join(room.code, bob.token, "Bob") + assert room.set_ready(bob.token, True) + room.start(ada.token) + now = drive_countdown(room) + cy = new_session("Cy", clock) + di = new_session("Di", clock) + supervisor.join(room.code, cy.token, "Cy") + supervisor.join(room.code, di.token, "Di") + now = play_round_to_end(room, now) + + room.to_lobby() + assert room.state is RoomState.LOBBY + assert len(room.seats) == 4 # both watchers were promoted + assert room.spectators == [] + assert not any(seat.ready for seat in room.seats) # a fresh lobby readies up + for token in (cy.token, di.token): + seat = room.seat_for_token(token) + assert seat is not None and seat.host is False and seat.connected + + +def test_a_spectator_of_a_full_room_stays_watching_on_to_lobby() -> None: + supervisor, clock = make_supervisor(two_level_bundle()) + eve = new_session("Eve", clock) + room = supervisor.create_room(eve, "Eve") + tokens = [eve.token] + for name in ("Fay", "Gus", "Hal"): + token = new_session(name, clock).token + supervisor.join(room.code, token, name) + tokens.append(token) + room.set_ready(tokens[1], True) + room.start(eve.token) + now = drive_countdown(room) + imran = new_session("Imran", clock) + supervisor.join(room.code, imran.token, "Imran") + now = play_round_to_end(room, now) + + room.to_lobby() + assert len(room.seats) == 4 # the room is full: nobody new can sit + assert len(room.spectators) == 1 + assert room.spectator_for_token(imran.token) is not None + + +# ------------------------------------------------------------------ colours + + +def test_colors_are_first_free_in_seat_order() -> None: + room, _ = make_room(two_level_bundle()) + ada = room.add_seat("tok-a", "Ada", True) + bob = room.add_seat("tok-b", "Bob", False) + cy = room.add_seat("tok-c", "Cy", False) + dee = room.add_seat("tok-d", "Dee", False) + assert (ada.color, bob.color, cy.color, dee.color) == SEAT_PALETTE + + # Seat 2 leaves: the next joiner takes the first free colour (c2). + room.remove_seat("tok-c") + eve = room.add_seat("tok-e", "Eve", False) + assert eve.seat_index == 2 and eve.color == SEAT_PALETTE[2] + + # Seat 0 leaves: the next joiner takes the first free colour (c0). + room.remove_seat("tok-a") + fred = room.add_seat("tok-f", "Fred", False) + assert fred.seat_index == 0 and fred.color == SEAT_PALETTE[0] + + +def test_a_returning_token_keeps_its_color_over_the_first_free() -> None: + room, _ = make_room(two_level_bundle()) + room.add_seat("tok-a", "Ada", True) # palette[0] + room.add_seat("tok-b", "Bob", False) # palette[1] + room.add_seat("tok-c", "Cy", False) # palette[2] + room.remove_seat("tok-b") + room.add_seat("tok-d", "Dee", False) # seat 1, first free = palette[1] + room.remove_seat("tok-a") # palette[0] is now the first free + bob2 = room.add_seat("tok-b", "Bob", False) # seat 0: first free = palette[0] + assert bob2.seat_index == 0 + assert bob2.color == SEAT_PALETTE[1] # Bob's remembered colour wins + + +# ------------------------------------------------------------------ emotes + + +def test_emote_whitelist_and_throttle() -> None: + room, _ = make_room(two_level_bundle()) + room.add_seat("tok-a", "Ada", True) + + assert room.emote("tok-a", "wave") is True # t=0 + assert room.emote("tok-a", "cheer", now=0.1) is True + assert room.emote("tok-a", "star", now=0.2) is False # the 3rd in 1 s: dropped + assert room.emote("tok-a", "star", now=1.1) is True # the window has passed + + events = room.take_events() + assert [(e.kind, dict(e.payload)) for e in events] == [ + (EMOTE_EVENT_KIND, {"emote": "wave", "player_id": "tok-a"}), + (EMOTE_EVENT_KIND, {"emote": "cheer", "player_id": "tok-a"}), + (EMOTE_EVENT_KIND, {"emote": "star", "player_id": "tok-a"}), + ] + # The per-token windows are independent. + room.add_seat("tok-b", "Bob", False) + assert room.emote("tok-b", "wave", now=0.2) is True # unthrottled for Bob + + with pytest.raises(UnknownEmote): + room.emote("tok-a", "dance") # outside the whitelist: a named refusal + assert room.emote("tok-stranger", "wave") is False # an unknown token: dropped + assert set(EMOTES) == {"wave", "cheer", "thumbs_up", "thumbs_down", "angry", "star"} + + +def test_emotes_are_dropped_outside_the_emote_states_and_for_spectators_too() -> None: + room, _ = make_room(two_level_bundle()) + room.add_seat("tok-a", "Ada", True) + room.add_spectator("tok-b", "Bob") + assert room.emote("tok-b", "wave") is True # a seated-or-watching token may + + room.start("tok-a") # COUNTDOWN + assert room.emote("tok-a", "wave") is False # dropped, not an error + events = room.take_events() + # Only the accepted LOBBY emote is buffered; the dropped one is not. + assert len(events) == 1 + assert events[0].kind is EMOTE_EVENT_KIND + assert events[0].payload == {"emote": "wave", "player_id": "tok-b"} + + +# ------------------------------------------------------------- the payload + + +def test_the_lobby_payload_shape_and_viewer_unlock() -> None: + sessions = {"tok-a": SessionRecord("tok-a", "Ada", 0.0, 2)} + room, _ = make_room(two_level_bundle(), sessions) + room.add_seat("tok-a", "Ada", True) + room.add_seat("tok-b", "Bob", False) + room.set_ready("tok-b", True) + room.add_spectator("tok-c", "Cy") + + payload: dict[str, Any] = room.lobby_payload("tok-a") + assert set(payload) == { + "code", + "state", + "level", + "host_seat", + "can_start", + "seats", + "spectators", + "unlocked", + "round_no", + } + assert payload["code"] == room.code + assert payload["state"] == "lobby" + assert payload["level"] == "05a_first" + assert payload["host_seat"] == 0 + assert payload["can_start"] is True + assert payload["round_no"] == 0 + assert payload["unlocked"] == 2 # the viewer's session + assert set(payload["seats"][0]) == { + "index", + "name", + "color", + "ready", + "host", + "connected", + } + assert [seat["host"] for seat in payload["seats"]] == [True, False] + assert [seat["name"] for seat in payload["seats"]] == ["Ada", "Bob"] + assert payload["seats"][0]["color"] == SEAT_PALETTE[0] + assert payload["spectators"] == [{"name": "Cy", "color": SEAT_PALETTE[2]}] + + # An anonymous viewer sees no unlock, and a token never leaks out. + assert room.lobby_payload()["unlocked"] is None + assert room.lobby_payload("tok-stranger")["unlocked"] is None + blob = str(room.lobby_payload("tok-a")) + assert "tok-a" not in blob and "tok-c" not in blob + + +# -------------------------------------------------- names at the boundary + + +def test_a_markup_display_name_comes_back_sanitized() -> None: + """US-02 criterion 6, at the boundary: the markup name is stored + sanitized (≤ 16 chars, markup-free) for a create and a join.""" + supervisor, clock = make_supervisor(two_level_bundle()) + room = supervisor.create_room(new_session(None, clock), "") + name = room.seats[0].display_name + assert name == "scriptalert(1)/s" + assert len(name) <= 16 + assert not {"<", ">", '"'} & set(name) + + cy = new_session(None, clock) + supervisor.join(room.code, cy.token, "") + assert seat(room, cy.token).display_name == "scriptalert(1)/s" + + # A whitespace-only name falls back to the seat number. + dee = new_session(None, clock) + supervisor.join(room.code, dee.token, " ") + assert seat(room, dee.token).display_name == "Cook #3" + + +# -------------------------------------------------- seats' own methods + + +def test_the_seat_and_spectator_verbs() -> None: + """The `Seat`/`Spectator` verbs and the room's bookkeeping edges: + `is_host`, `next_seat_index` on a full room, the not-seated / not- + watching refusals, the `add_spectator` reclaim, the session lookup's + `None` branch.""" + room, _ = make_room(two_level_bundle()) + host = room.add_seat("tok-a", "Ada", True) + room.add_seat("tok-b", "Bob", False) + assert host.is_host is True + assert seat(room, "tok-b").is_host is False + + assert room.mark_seat_connected("tok-stranger") is False # not seated + assert room.mark_spectator_connected("tok-stranger") is False # not watching + + # The reclaim path: a second `add_spectator` for the same token keeps + # the entry, re-connects it, and updates the name when a new one is + # typed (an empty name keeps the old one). + first = room.add_spectator("tok-b", "Bob") + assert first.color == SEAT_PALETTE[2] # first free, after the two seats + again = room.add_spectator("tok-b", "Bobby") + assert again is first and again.connected is True + assert again.display_name == "Bobby" + room.add_spectator("tok-b", "") + assert again.display_name == "Bobby" # an empty name keeps the old one + + # A full room: `next_seat_index` names the refusal, `add_seat` refuses. + room.add_seat("tok-c", "Cy", False) + room.add_seat("tok-d", "Dee", False) + assert room.is_full + with pytest.raises(IndexError, match="no free seat"): + room.next_seat_index() + with pytest.raises(ValueError, match="is full"): + room.add_seat("tok-e", "Eve", False) + + # A test-built room (no `sessions` argument): the lookup is `None`, and + # `_session_for` says so (a token counts as unlocked 1). + bare2 = Room(code="T2", settings=Settings(), clock=FakeClock()) + assert bare2.sessions is None + assert bare2._session_for("tok-anyone") is None + + +# -------------------------------------------------- sessions at the boundary + + +def test_a_join_with_an_unknown_token_mints_a_session() -> None: + supervisor, clock = make_supervisor(two_level_bundle()) + supervisor.create_room(new_session("Ada", clock), "Ada") + room = supervisor.all_rooms()[0] + + assert supervisor.session_for("tok-unknown") is None + supervisor.join(room.code, "tok-unknown", "Zed") + record = supervisor.session_for("tok-unknown") + assert record is not None + assert record.display_name == "Zed" + assert record.unlocked_level == 1 # a fresh session: the campaign's floor + + +def test_a_known_session_record_is_left_alone_on_rejoin() -> None: + supervisor, clock = make_supervisor(two_level_bundle()) + supervisor.create_room(new_session("Ada", clock), "Ada") + room = supervisor.all_rooms()[0] + unlocked = SessionRecord("tok-u2", "U2", 0.0, 3) + supervisor.create_room(unlocked, None) # U2 owns a room of their own + + supervisor.join(room.code, "tok-u2", None) # evicted from their room + assert supervisor.session_for("tok-u2") is unlocked # the record is intact + assert unlocked.unlocked_level == 3 diff --git a/tests/integration/test_no_wall_clock.py b/tests/integration/test_no_wall_clock.py new file mode 100644 index 0000000..b0ea8cc --- /dev/null +++ b/tests/integration/test_no_wall_clock.py @@ -0,0 +1,118 @@ +"""Integration (grep-style): the lifecycle modules' time discipline +(phase 05, task 05). + +Complements `tests/unit/test_server_clock_discipline.py` (which already +greps *all* of `server/` for `time` imports and wall-clock calls): this +pins the task file's rules on the lifecycle modules themselves — +`room.py`, `rooms.py`, `sessions.py` — so the lifecycle stays +deterministically testable: + +* **no direct wall-clock call** — `time.time()` / `time.monotonic()` + (all timing goes through the injected `Clock`; `server/clock.py` is + the only module that may import `time` — the unit guard above), +* **no `import time` in the lifecycle modules** — the `Clock` is the + seam, +* **no `asyncio.sleep(` with a non-zero literal** — the cadence comes + from the deadline computed on the injected `Clock` (`Room.tick_loop`, + the supervisor's `gc_loop`), and the loop's sleep is injectable + (`Room(sleep=...)`, `RoomSupervisor(sleep=...)`), so a test drives + the whole lifecycle with a `FakeClock` and never sleeps. A + `asyncio.sleep(0.05)` literal would reintroduce a real-time + dependency on the exact path this test guards. (A *named* argument — + the injected sleep's seconds — and `asyncio.sleep(0)` are legal.) +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.integration + +REPO_ROOT = Path(__file__).resolve().parents[2] +SERVER_DIR = REPO_ROOT / "src" / "undercooked" / "server" + +#: The lifecycle modules the task file names (`room*.py` → `room.py`, +#: plus `rooms.py` and `sessions.py`). +LIFECYCLE_MODULES = ("room.py", "rooms.py", "sessions.py") + +#: The wall-clock calls the contract forbids in server code. +WALL_CLOCK_CALLS = ("time.monotonic()", "time.time()") + +#: `time` may be imported by exactly one module (the unit guard checks +#: that it is `clock.py`). +TIME_IMPORT = re.compile(r"^\s*(?:import time\b|from time import\b)", re.MULTILINE) + +#: An `asyncio.sleep(` whose argument is a literal (number or identifier). +SLEEP_CALL = re.compile(r"asyncio\.sleep\(\s*([A-Za-z_][A-Za-z0-9_]*|[\d.]+)\s*\)") + +#: A (possibly exponential, signed) floating-point or integer literal. +NUMERIC_LITERAL = re.compile(r"-?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?") + + +def lifecycle_modules() -> list[Path]: + paths = [] + for name in LIFECYCLE_MODULES: + path = SERVER_DIR / name + assert path.is_file(), f"the lifecycle module vanished: {name}" + paths.append(path) + return paths + + +def test_no_wall_clock_call_lives_in_the_lifecycle_modules() -> None: + offenders = [ + f"{path.name}: {literal}" + for path in lifecycle_modules() + for literal in WALL_CLOCK_CALLS + if literal in path.read_text(encoding="utf-8") + ] + assert offenders == [], "wall-clock calls leaked into the lifecycle:\n " + "\n ".join( + offenders + ) + + +def test_no_time_import_lives_in_the_lifecycle_modules() -> None: + offenders = [ + path.name + for path in lifecycle_modules() + if TIME_IMPORT.search(path.read_text(encoding="utf-8")) + ] + assert offenders == [], "the lifecycle may only read time through the Clock: " + ", ".join( + offenders + ) + + +def test_no_asyncio_sleep_with_a_nonzero_literal() -> None: + offenders = [] + for path in lifecycle_modules(): + for match in SLEEP_CALL.finditer(path.read_text(encoding="utf-8")): + literal = match.group(1) + # A named argument (the injected sleep's seconds) is legal; + # only a numeric literal is a wall-time dependency. + if not NUMERIC_LITERAL.fullmatch(literal): + continue + if float(literal) > 0: + offenders.append(f"{path.name}: asyncio.sleep({literal})") + assert offenders == [], ( + "a non-zero asyncio.sleep literal would tie the lifecycle to real " + "time (drive it with a FakeClock instead):\n " + "\n ".join(offenders) + ) + + +def test_the_guard_is_not_vacuous() -> None: + """A guard that never fires is no guard: prove each check detects a + planted violation, and that the modules it greps exist.""" + assert WALL_CLOCK_CALLS[0] in "x = time.monotonic()\n" + assert WALL_CLOCK_CALLS[1] in "y = time.time()\n" + assert TIME_IMPORT.search("import time\n") is not None + assert TIME_IMPORT.search("from time import monotonic\n") is not None + # The sleep guard: a non-zero literal fires, zero and named arguments do not. + assert SLEEP_CALL.search("asyncio.sleep(0.05)\n") is not None + assert SLEEP_CALL.search("asyncio.sleep(seconds)\n") is not None + match = SLEEP_CALL.search("asyncio.sleep(0.05)\n") + assert match is not None + assert NUMERIC_LITERAL.fullmatch(match.group(1)) + assert float(match.group(1)) > 0 + assert lifecycle_modules() diff --git a/tests/integration/test_room_fsm.py b/tests/integration/test_room_fsm.py new file mode 100644 index 0000000..fe4c2e5 --- /dev/null +++ b/tests/integration/test_room_fsm.py @@ -0,0 +1,695 @@ +"""Integration: the `Room` state machine (phase 05, task 02) — the guarded +FSM in `server/room.py`. + +The whole contract, driven by hand on a `FakeClock` (no test sleeps): + +* **the transition table is data** — every `(state, event)` pair of the + 5 states x 9 events is tested through `Room._fire`: the 14 legal pairs + land on the table's target, the 31 illegal ones raise `IllegalTransition` + and leave the state untouched (no ad-hoc state writes exist), +* **the preconditions are named** — `request_start` refuses `"no_level"` + and `"no_ready_players"` (≥ 1 ready non-host, or a solo host, PLAN §6), +* **the countdown is 3 s** — it counts 3 frames of 1 s to zero (and the + 20 Hz cadence: 60 frames of 0.05 s) and the round's state is built at + `countdown_done` (level, a fresh seed, the shelf stock and the prop + counters the room owns — `sim/stations.py`'s `DEFAULT_STOCK` is only the + stand-in a room-less build sees), +* **`round_over` stores the result** — after the sim's 1.0 s settle window + (`sim/round_state.SETTLE_S`), the room is `ROUND_END` and `last_result` + is `result_payload` of the state at the verdict, +* **`retry` reuses the level with a new seed; `next_level` switches to an + explicitly supplied level id** (task 04 wires that value from the session + store), +* **`close` is idempotent and frees the seats** (the universal teardown, + legal from every live state), +* **the idle rules** — `all_seats_empty` closes after the 30 s grace, + `idle_expired` closes past `lobby_idle_timeout_s` (the room's own copy of + the GC's rules), +* **queued inputs are per-tick and PLAYING-only** — dropped outside + `PLAYING`, an unknown player is a buffered `err` event, and `take_events` + drains the buffer exactly once. +""" + +from __future__ import annotations + +import copy +from typing import Any + +import pytest + +from undercooked.config import Settings +from undercooked.server.clock import FakeClock +from undercooked.server.room import ( + COUNTDOWN_S, + ROOM_EVENTS, + ROOM_TRANSITIONS, + SEAT_GRACE_S, + IllegalTransition, + Room, + RoomState, + StartRefused, +) +from undercooked.sim.content import ContentBundle +from undercooked.sim.level import Level, LevelDef +from undercooked.sim.model import ( + EventKind, + ItemForm, + ItemKind, + RoundResult, + StationKind, +) +from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step +from undercooked.sim.step import PlayerInput + +pytestmark = pytest.mark.integration + + +# ------------------------------------------------------------------ fixtures + + +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_id: str, *, time_limit: float = 3.0, target: int = 1000) -> Level: + """A 7x4 kitchen: one spawn shelf, one serve window, one spawn point. + + `target` is above anything an idle cook scores, so a `PLAYING` round + on this level always ends `LOST` on the buzzer — the shortest path to + `ROUND_END`. + """ + 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"FSM kitchen {level_id}", + "order": 1, + "time_limit": time_limit, + "target_score": target, + "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(*levels: Level) -> ContentBundle: + return ContentBundle(recipe_book=make_book(), levels=levels) + + +def make_room( + bundle: ContentBundle | None = None, + **settings: Any, +) -> tuple[Room, FakeClock]: + """A `LOBBY` room on a `FakeClock`; `bundle=None` is the task-01 style + (no content: the room cannot start a round it has no level for).""" + 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 seat_solo_host(room: Room, player_id: str = "tok-a", name: str = "Ada") -> None: + room.add_seat(player_id, name, True) + + +def seat_ready_non_host(room: Room, player_id: str = "tok-b", name: str = "Bob") -> None: + seat = room.add_seat(player_id, name, False) + assert room.set_ready(player_id, True) + assert seat is not None + + +def prime(room: Room, now: float = 0.0) -> None: + """The first `tick_once` (dt 0) — the room's wall clock starts here.""" + room.tick_once(now=now) + + +def drive_countdown(room: Room, now: float = 0.0) -> float: + """The 3 s countdown in 1 s frames (the task's own words); returns the + final `now` (3.0), with the room in `PLAYING`.""" + prime(room, now) + for offset in (1.0, 2.0, 3.0): + room.tick_once(now=now + offset) + assert room.state is RoomState.PLAYING + return now + 3.0 + + +def play_round_to_end(room: Room, now: float = 3.0) -> float: + """One second-frames through the 3.0 s round (each frame's `dt` clamps + to the sim's `MAX_DT` of 0.1 s — 2 substeps) and its 1.0 s settle + window (2 more frames), until `ROUND_END`; returns the final `now`.""" + while room.state is RoomState.PLAYING: + now += 1.0 + room.tick_once(now=now) + assert room.state is RoomState.ROUND_END + return now + + +# ------------------------------------------------- the transition matrix + + +@pytest.mark.parametrize("state", list(RoomState)) +@pytest.mark.parametrize("event", ROOM_EVENTS) +def test_the_full_states_x_events_matrix(state: RoomState, event: str) -> None: + """Every pair of the 5 states x 9 events: the 14 legal ones (the table) + land on the target; the 31 illegal ones raise `IllegalTransition` and + leave the state exactly where it was.""" + room, _ = make_room() + room.state = state # test setup only: `_fire` is the guarded path + expected = ROOM_TRANSITIONS.get((state, event)) + if expected is None: + with pytest.raises(IllegalTransition): + room._fire(event) + assert room.state is state # unchanged + else: + assert room._fire(event) is expected + + +def test_the_table_is_closed_over_the_state_and_event_namespaces() -> None: + """The table only names known states and events, and every event is + legal somewhere (no orphaned event, no transition out of the FSM).""" + for (state, event), to in ROOM_TRANSITIONS.items(): + assert isinstance(state, RoomState), (state, event) + assert event in ROOM_EVENTS, (state, event) + assert isinstance(to, RoomState), (state, event) + assert {event for _, event in ROOM_TRANSITIONS} == set(ROOM_EVENTS) + # `CLOSED` is an exit, never a source: no transition leaves it. + assert not any(from_state is RoomState.CLOSED for (from_state, _) in ROOM_TRANSITIONS) + + +def test_every_event_is_legal_from_the_states_the_diagram_names() -> None: + """PLAN §6's diagram, spelled out: the legal pairs are exactly the + table's 14 (so a future edit of either is a test failure).""" + legal = { + (RoomState.LOBBY, "host_start"), + (RoomState.COUNTDOWN, "countdown_done"), + (RoomState.PLAYING, "round_over"), + (RoomState.ROUND_END, "to_lobby"), + (RoomState.ROUND_END, "retry"), + (RoomState.ROUND_END, "next_level"), + (RoomState.LOBBY, "close"), + (RoomState.COUNTDOWN, "close"), + (RoomState.PLAYING, "close"), + (RoomState.ROUND_END, "close"), + (RoomState.LOBBY, "all_seats_empty"), + (RoomState.ROUND_END, "all_seats_empty"), + (RoomState.LOBBY, "idle_expired"), + (RoomState.ROUND_END, "idle_expired"), + } + assert set(ROOM_TRANSITIONS) == legal + assert len(legal) == 14 # 45 pairs - 31 illegal = 14 legal + + +# ------------------------------------------------------------ preconditions + + +def test_request_start_requires_a_solo_host_or_a_ready_non_host() -> None: + bundle = make_bundle(make_level("05_start")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.request_start() # the solo host: no ready flag needed + assert room.state is RoomState.COUNTDOWN + assert room.round_no == 1 + assert room.round_seed is not None + + +def test_request_start_refuses_without_a_ready_non_host() -> None: + bundle = make_bundle(make_level("05_start")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.add_seat("tok-b", "Bob", False) # not ready + with pytest.raises(StartRefused, match="no_ready_players"): + room.request_start() + assert room.state is RoomState.LOBBY # the refusal leaves no state change + + +def test_request_start_accepts_one_ready_non_host_among_the_unready() -> None: + bundle = make_bundle(make_level("05_start")) + room, _ = make_room(bundle) + seat_solo_host(room) + seat_ready_non_host(room, "tok-b", "Bob") + room.add_seat("tok-c", "Cy", False) # unready, but one ready is enough + room.request_start() + assert room.state is RoomState.COUNTDOWN + + +def test_request_start_refuses_without_a_level() -> None: + room, _ = make_room(None) # no content bundle: no level to play + seat_solo_host(room) + with pytest.raises(StartRefused, match="no_level"): + room.request_start() + assert room.state is RoomState.LOBBY + + +def test_request_start_is_refused_outside_the_lobby() -> None: + bundle = make_bundle(make_level("05_start")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.request_start() + with pytest.raises(IllegalTransition): + room.request_start() # already COUNTDOWN + + +# ---------------------------------------------------------------- countdown + + +def test_the_countdown_counts_three_frames_of_one_second() -> None: + bundle = make_bundle(make_level("05_count")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.request_start() + assert room.state is RoomState.COUNTDOWN + assert room.countdown_left == COUNTDOWN_S == 3.0 + prime(room) + room.tick_once(now=1.0) + assert room.state is RoomState.COUNTDOWN and room.countdown_left == 2.0 + room.tick_once(now=2.0) + assert room.state is RoomState.COUNTDOWN and room.countdown_left == 1.0 + room.tick_once(now=3.0) # the third second lands it on zero + assert room.state is RoomState.PLAYING # `countdown_done` fired + + +def test_the_countdown_runs_the_20_hz_cadence_too() -> None: + """60 frames of 0.05 s are the same 3.0 s (the real loop's cadence).""" + bundle = make_bundle(make_level("05_count")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.request_start() + prime(room) + for frame in range(1, 61): + room.tick_once(now=frame * 0.05) + if room.state is RoomState.PLAYING: + assert frame >= 59 # float noise may cost one frame at most + break + assert room.state is RoomState.PLAYING + + +def test_countdown_done_builds_the_round_from_level_seats_and_seed() -> None: + bundle = make_bundle(make_level("05_build")) + room, _ = make_room(bundle) + seat_solo_host(room, "tok-a", "Ada") + seat_ready_non_host(room, "tok-b", "Bob") + room.request_start() + drive_countdown(room) + + assert room.sim_state is not None + state = room.sim_state + assert state.level_id == "05_build" + assert state.seed == room.round_seed # the fresh seed, on the state + assert state.time_left == state.time_limit == 3.0 + assert [player.id for player in state.players] == ["tok-a", "tok-b"] # seat order + # The room's seeding job (sim/stations.py's DEFAULT_STOCK is only the + # stand-in a room-less build sees): the shelf stock and the prop caps. + shelf = next(s for s in state.stations if s.id.startswith("spawn_shelf")) + assert len(shelf.contents) == 8 # the room's shelf seed + assert state.counters == { + "plates_clean": 99, + "plates_dirty": 0, + "pots_clean": 99, + "pots_dirty": 0, + } + # A queued input from the countdown is gone (per-tick, cleared by the + # round's start). + assert room._queued_inputs == [] + + +# ------------------------------------------------------------- round end + + +def test_round_over_stores_last_result_and_the_payload() -> None: + bundle = make_bundle(make_level("05_end")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.request_start() + now = drive_countdown(room) + now = play_round_to_end(room, now) # the 3 s round + the 1 s settle + + assert room.state is RoomState.ROUND_END + assert room.last_result is not None + payload = room.last_result + # The exact key set of `result_payload` (PLAN §7's `round` body). + assert set(payload) == { + "result", + "score", + "target", + "stars", + "time_left", + "counters", + "longest_streak", + "players", + "level_id", + "seed", + "ticks", + } + assert payload["result"] == "lost" # the idle cook loses on the buzzer + assert payload["score"] == 0 + assert payload["stars"] == 0 + assert payload["level_id"] == "05_end" + assert payload["seed"] == room.round_seed + # The settle window ran: the verdict tick is not the state's last tick. + assert int(str(payload["ticks"])) > 0 + + +def test_the_settle_window_keeps_the_room_playing_before_round_over() -> None: + """The sim's `SETTLE_S` contract, on the room's side: the tick the + verdict lands does not end the round — the room keeps ticking the + frozen sim for 1.0 s of wall time first.""" + bundle = make_bundle(make_level("05_settle")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.request_start() + now = drive_countdown(room) + # 1 s frames (each `dt` clamps to the sim's 0.1 s) until the verdict lands. + for _ in range(32): + now += 1.0 + room.tick_once(now=now) + if room.sim_state is not None and room.sim_state.result is not None: + break + assert room.sim_state is not None + assert room.sim_state.result is RoundResult.LOST + # The verdict tick: the settle window is open (the sim ticks on, frozen), + # and the payload has not landed yet. + assert room.state is RoomState.PLAYING # still settling + assert room.last_result is None # the payload lands with `round_over` + # The window closes 1.0 s of wall time after it opened. + now += 1.0 + room.tick_once(now=now) + assert room.state is RoomState.ROUND_END + assert room.last_result is not None and room.last_result["result"] == "lost" + + +# ------------------------------------------------------------ retry / next + + +def test_retry_reuses_the_level_with_a_new_seed() -> None: + bundle = make_bundle(make_level("05_retry")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.request_start() + seed_one = room.round_seed + assert seed_one is not None + now = drive_countdown(room) + now = play_round_to_end(room, now) + + room.retry() + assert room.state is RoomState.COUNTDOWN + assert room.round_no == 2 + assert room.round_seed is not None + assert room.round_seed != seed_one # the seed is fresh + assert room.level_id == "05_retry" # the level is the same + + drive_countdown(room, now) + assert room.state is RoomState.PLAYING + assert room.sim_state is not None + assert room.sim_state.level_id == "05_retry" # reused + assert room.sim_state.seed == room.round_seed # and fresh + + +def test_next_level_switches_to_the_supplied_level_id() -> None: + bundle = make_bundle(make_level("05_a"), make_level("05_b")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.request_start() + now = drive_countdown(room) + now = play_round_to_end(room, now) + + room.next_level("05_b") # the explicitly supplied id (task 04 wires it) + assert room.state is RoomState.COUNTDOWN + assert room.level_id == "05_b" and room.level is not None + drive_countdown(room, now) + assert room.state is RoomState.PLAYING + assert room.sim_state is not None + assert room.sim_state.level_id == "05_b" # the new level, on the state + + +def test_next_level_refuses_an_id_outside_the_campaign_without_a_state_change() -> None: + bundle = make_bundle(make_level("05_a")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.request_start() + now = drive_countdown(room) + now = play_round_to_end(room, now) + + with pytest.raises(ValueError, match="not in room"): + room.next_level("05_missing") + assert room.state is RoomState.ROUND_END # untouched + assert room.level_id == "05_a" + with pytest.raises(ValueError, match="no content bundle"): + make_room(None)[0].select_level("05_a") # a bundle-less room + + +def test_to_lobby_returns_to_the_lobby_and_resets_the_ready_flags() -> None: + bundle = make_bundle(make_level("05_lobby")) + room, _ = make_room(bundle) + seat_solo_host(room) + seat_ready_non_host(room) # Bob is ready + room.request_start() + now = drive_countdown(room) + now = play_round_to_end(room, now) + + room.to_lobby() + assert room.state is RoomState.LOBBY + assert all(not seat.ready for seat in room.seats) # ready up again + assert room.last_result is not None # kept for inspection + # Ready up again, and the next round starts from the fresh lobby. + assert room.set_ready("tok-b", True) + room.request_start() + assert room.state is RoomState.COUNTDOWN and room.round_no == 2 + + +# ------------------------------------------------------------------ close + + +async def test_close_is_idempotent_and_frees_the_seats() -> None: + bundle = make_bundle(make_level("05_close")) + room, _ = make_room(bundle) + seat_solo_host(room) + seat_ready_non_host(room) + + await room.close() + assert room.state is RoomState.CLOSED + assert room.seats == [] + assert room.closed_at is not None + await room.close() # idempotent: no error, no second state write + assert room.state is RoomState.CLOSED + assert room.closed_at is not None + + +async def test_close_works_from_every_live_state() -> None: + bundle = make_bundle(make_level("05_close_any")) + for target in (RoomState.COUNTDOWN, RoomState.PLAYING, RoomState.ROUND_END): + room, _ = make_room(bundle) + seat_solo_host(room) + room.state = target # test setup: the table's `close` row is what's tested + await room.close() + assert room.state is RoomState.CLOSED + assert room.seats == [] + + +def test_a_closed_room_ticks_nothing() -> None: + bundle = make_bundle(make_level("05_closed_tick")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.state = RoomState.CLOSED # test setup + room.tick_once(now=99.0) + assert room.state is RoomState.CLOSED + + +# ------------------------------------------------------- the idle rules + + +def test_all_seats_empty_closes_after_the_grace() -> None: + bundle = make_bundle(make_level("05_grace")) + room, _ = make_room(bundle) + seat_solo_host(room, "tok-a") + room.add_seat("tok-b", "Bob", False) + room.remove_seat("tok-b") + room.remove_seat("tok-a") + assert room.empty_since == 0.0 + + prime(room) + room.tick_once(now=SEAT_GRACE_S) # exactly 30 s is not past the grace + assert room.state is RoomState.LOBBY + room.tick_once(now=SEAT_GRACE_S + 1.0) + assert room.state is RoomState.CLOSED + + +def test_idle_expired_closes_past_the_lobby_timeout() -> None: + bundle = make_bundle(make_level("05_idle")) + room, _ = make_room(bundle, lobby_idle_timeout_s=10.0) + seat_solo_host(room) # occupied: the idle-lobby rule, not the grace + + prime(room) + room.tick_once(now=10.0) # exactly the timeout is not past it + assert room.state is RoomState.LOBBY + room.tick_once(now=10.5) + assert room.state is RoomState.CLOSED + + +def test_the_idle_rules_run_in_round_end_too() -> None: + bundle = make_bundle(make_level("05_idle_end")) + room, _ = make_room(bundle, lobby_idle_timeout_s=10.0) + seat_solo_host(room) + room.request_start() + now = drive_countdown(room) + now = play_round_to_end(room, now) + assert room.state is RoomState.ROUND_END + + room.tick_once(now=now + 10.5) # > 10 s of lobby-idle in ROUND_END + assert room.state is RoomState.CLOSED + + +# ------------------------------------------------------ defensive edges + + +def test_the_settings_and_content_properties_expose_the_injected_values() -> None: + bundle = make_bundle(make_level("06_props")) + settings = Settings() + room = Room(code="TEST1", settings=settings, clock=FakeClock(), content=bundle) + assert room.settings is settings + assert room.content is bundle + empty = Room(code="TEST2", settings=Settings(), clock=FakeClock()) + assert empty.content is None # content is optional (the supervisor always sets it) + + +def test_a_full_room_refuses_new_seats() -> None: + bundle = make_bundle(make_level("06_full")) + room = Room( + code="TEST1", + settings=Settings(max_players=1), + clock=FakeClock(), + content=bundle, + level_id=bundle.levels[0].id, + ) + first = room.add_seat("tok-a", "Ada", True) + with pytest.raises(ValueError, match="is full"): + room.add_seat("tok-b", "Bob", False) + assert first is room.seats[0] + + +def test_set_ready_for_an_unknown_token_is_a_noop() -> None: + bundle = make_bundle(make_level("06_ready")) + room, _ = make_room(bundle) + assert room.set_ready("tok-ghost", True) is False + assert room.set_ready("tok-ghost", False) is False + + +def test_ticking_a_room_with_no_content_bundle_raises_an_internal_error() -> None: + clock = FakeClock() + room = Room(code="TEST1", settings=Settings(), clock=clock) # no content + room.add_seat("tok-a", "Ada", True) + # A level is attached (so `request_start` passes) but the bundle itself is + # absent: the round build cannot load recipes or seats' props. + room.level = make_level("06_nolevel") + room.request_start() + clock.advance(COUNTDOWN_S) + # White-box: `tick_once` crash-isolates (by design), so the build is + # exercised directly to pin the internal error. + with pytest.raises(RuntimeError, match="no content bundle"): + room._build_sim_state() + + +def test_ticking_a_corrupted_playing_room_raises_the_internal_invariant() -> None: + bundle = make_bundle(make_level("06_corrupt")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.request_start() + for frame in range(62): # through the countdown: the round is built + room.tick_once(now=frame * 0.05) + assert room.sim_state is not None + room.sim_state = None # corrupt: PLAYING with no built state + # White-box: `tick_once` crash-isolates (by design), so the tick body is + # exercised directly to pin the internal error. + with pytest.raises(RuntimeError, match="no built state"): + room._tick(0.0, 62 * 0.05) + + +# -------------------------------------------------- input legality / drain + + +def test_queued_inputs_are_dropped_outside_playing() -> None: + bundle = make_bundle(make_level("05_input")) + room, _ = make_room(bundle) + seat_solo_host(room) + frame = PlayerInput(player_id="tok-a", seq=1) + + assert room.queue_input("tok-a", frame) is False # LOBBY: dropped + assert room.events_since_snapshot == [] # not an error: the client is not at fault + room.state = RoomState.COUNTDOWN + assert room.queue_input("tok-a", frame) is False + + +def test_queue_input_rejects_an_unknown_player_with_an_err_event() -> None: + bundle = make_bundle(make_level("05_input_err")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.request_start() + drive_countdown(room) + + assert room.queue_input("tok-stranger", PlayerInput(player_id="tok-stranger")) is False + events = room.take_events() + assert len(events) == 1 + assert events[0].kind is EventKind.ERROR + assert events[0].payload == { + "reason": "unknown_player", + "player_id": "tok-stranger", + "seq": 0, + } + # A known seat is queued, stamped with the sim's tick, and the queue is + # per-tick: the next `tick_once` consumes and clears it. + frame = PlayerInput(player_id="tok-a", seq=7) + assert room.queue_input("tok-a", frame) is True + state = room.sim_state + assert state is not None + assert room._queued_inputs[-1].received_at_tick == state.tick + assert room._queued_inputs[-1].seq == 7 + room.tick_once() + assert room._queued_inputs == [] + + +def test_take_events_drains_the_buffer_exactly_once() -> None: + bundle = make_bundle(make_level("05_drain")) + room, _ = make_room(bundle) + seat_solo_host(room) + room.request_start() + drive_countdown(room) + room.queue_input("tok-stranger", PlayerInput(player_id="tok-stranger")) # an event + + first = room.take_events() + assert len(first) == 1 and first[0].kind is EventKind.ERROR + assert room.take_events() == () # the second drain is empty + assert room.snapshot_seq == 2 diff --git a/tests/integration/test_room_isolation.py b/tests/integration/test_room_isolation.py new file mode 100644 index 0000000..03104bb --- /dev/null +++ b/tests/integration/test_room_isolation.py @@ -0,0 +1,312 @@ +"""Integration: one room's crash does not take the server down (phase 05, +task 05; PLAN §6 "crash isolation") — the task's `UNDERCOOKED_TEST_HOOK` +seam in action. + +Two live rooms, both created through the supervisor and both driven by +manual `tick_once` (a `FakeClock`; no test sleeps). An exception injected +into room A's sim through the test-only `Room._test_step_override` seam +(set only here — a `Callable` with `step`'s signature the `PLAYING` tick +calls in the kernel's place; never read from the network): + +* room A becomes `CLOSED` — the sim marked `ABORTED` through + `sim.round_state.abort`, `abort_reason` names the exception type, and + the log line carries the room's code and round **never a player + token**; +* the `err` event is delivered — one `tick_crash` in the buffer, the + round number in the payload, drained exactly once; +* room B keeps ticking correctly — its sim advances one substep per + 0.05 s frame, still `PLAYING`, untouched by A's fault; +* the supervisor's `gc_pass()` reaps room A (dropped from the registry — + the code no longer resolves, its token purged) and leaves room B + registered; +* a room that is `CLOSED` ticks nothing; +* the server keeps serving `/healthz` (200, the live room count is one — + room B). +""" + +from __future__ import annotations + +import asyncio +import logging +from collections.abc import Sequence +from pathlib import Path +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from undercooked.config import Settings +from undercooked.server.app import create_app +from undercooked.server.clock import FakeClock +from undercooked.server.room import Room, RoomState +from undercooked.server.rooms import RoomSupervisor +from undercooked.server.sessions import new_session +from undercooked.sim.content import ContentBundle +from undercooked.sim.level import Level, LevelDef +from undercooked.sim.model import ( + Event, + EventKind, + ItemForm, + ItemKind, + KitchenState, + RoundResult, + StationKind, +) +from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step +from undercooked.sim.step import PlayerInput +from undercooked.sim.step import step as real_step + +pytestmark = pytest.mark.integration + +#: The test-isolated content dir (a recipe book, no levels) — the app +#: boots on it; the supervisor's bundle carries the two test levels. +FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels" + +#: One 20 Hz tick, in wall seconds (the sim's `STEP_DT`). +PERIOD = 0.05 + + +# ------------------------------------------------------------------ content + + +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="pick", station=StationKind.SPAWN_SHELF), + Step(kind="serve", station=StationKind.SERVE), + ], + ) + }, + ingredients=[ItemKind.TOMATO], + ) + + +def make_level(level_id: str, *, time_limit: float, target: int) -> Level: + """A 7x4 kitchen (one shelf, one serve window) — the rooms only need + to reach `PLAYING`; room A never runs its round (the injected fault + fires on the first playing tick) and room B never needs a verdict + (the 60 s limit outlives the test).""" + data: dict[str, object] = { + "id": level_id, + "name": f"Isolation kitchen {level_id}", + "order": 1, + "time_limit": time_limit, + "target_score": target, + "grid": [ + "#######", + "#S...V#", + "#.....#", + "#######", + ], + "tiles": { + ".": {"kind": "floor"}, + "#": {"kind": "wall"}, + "S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"}, + "V": {"kind": "floor", "station": "serve"}, + }, + "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: + """The two levels: `08_bad` (room A — the injected fault) and + `08_good` (room B — the one that must keep ticking).""" + return ContentBundle( + recipe_book=make_book(), + levels=( + make_level("08_bad", time_limit=60.0, target=1000), + make_level("08_good", time_limit=60.0, target=1000), + ), + ) + + +# --------------------------------------------------------------------- app + + +class IsolationApp: + """An app whose supervisor runs on a `FakeClock` (the + `FakeClockSupervisorApp` pattern `test_rooms_api.py` pins) with the + test's two-level bundle. The `TestClient` is used without the + context manager: the rooms' tick tasks are never started — every + tick is the test's own `tick_once`, and `gc_pass` runs in a fresh + `asyncio.run` with nothing to cancel.""" + + def __init__(self, **settings_overrides: Any) -> None: + self.settings = Settings(levels_dir=FIXTURE_LEVELS_DIR, **settings_overrides) + self.app = create_app(self.settings) + self.clock = FakeClock() + self.app.state.supervisor = RoomSupervisor( + self.settings, self.clock, make_bundle() + ) + + @property + def supervisor(self) -> RoomSupervisor: + return self.app.state.supervisor + + +# ------------------------------------------------------------------ the test + + +def bring_both_to_playing( + room_a: Room, room_b: Room, clock: FakeClock +) -> None: + """Both rooms through the 3 s countdown (0.05 s wall frames); both + end `PLAYING` with a built, un-decided round.""" + for _ in range(90): + clock.advance(PERIOD) + room_a.tick_once() + room_b.tick_once() + if room_a.state is RoomState.PLAYING and room_b.state is RoomState.PLAYING: + break + assert room_a.state is RoomState.PLAYING + assert room_b.state is RoomState.PLAYING + assert room_a.sim_state is not None and room_b.sim_state is not None + assert room_a.sim_state.result is None + assert room_b.sim_state.result is None + + +def inject_the_fault(room_a: Room) -> None: + """The UNDERCOOKED_TEST_HOOK: room A's sim raises on its first + playing tick; room B's level is left to the real kernel.""" + + def boom( + state: KitchenState, + dt: float, + inputs: Sequence[PlayerInput], + book: RecipeBook | None, + ) -> tuple[KitchenState, tuple[Event, ...]]: + if state.level_id == "08_bad": + msg = "sim exploded in room A" + raise RuntimeError(msg) + return real_step(state, dt, inputs, book) + + room_a._test_step_override = boom + + +def check_room_a_is_aborted( + room_a: Room, + caplog: pytest.LogCaptureFixture, + token_a: str, + token_b: str, +) -> None: + """Room A: aborted and closed; the err event delivered; the log line + carries the code and the round, never a player token.""" + assert room_a.state is RoomState.CLOSED + assert room_a.sim_state is not None + assert room_a.sim_state.result is RoundResult.ABORTED + assert room_a.abort_reason is not None + assert "RuntimeError" in room_a.abort_reason + events = room_a.take_events() + crash = [ + e + for e in events + if e.kind is EventKind.ERROR and e.payload.get("reason") == "tick_crash" + ] + assert len(crash) == 1 + assert crash[0].payload["round"] == room_a.round_no + assert room_a.take_events() == () + assert room_a.code in caplog.text + assert "round 1" in caplog.text + assert token_a not in caplog.text + assert token_b not in caplog.text + + +def check_room_b_keeps_ticking(room_b: Room, clock: FakeClock) -> None: + """Room B: untouched — still playing, still ticking correctly (one + substep per 0.05 s frame: no slip); and a closed room ticks nothing.""" + sim = room_b.sim_state + assert sim is not None + tick_before = sim.tick + for _ in range(20): # 1.0 s of B's round + clock.advance(PERIOD) + room_b.tick_once() + assert room_b.state is RoomState.PLAYING + assert room_b.sim_state is not None + assert room_b.sim_state.tick == tick_before + 20 + assert room_b.sim_state.result is None + + +async def check_gc_reaps_a_leaves_b( + sup: RoomSupervisor, + room_a: Room, + room_b: Room, + token_a: str, + token_b: str, +) -> None: + """The GC reaps A (already `CLOSED`: the pass drops it — the close + itself happened in the crash isolation, not here) and leaves B + registered.""" + assert await sup.gc_pass() == 0 + assert sup.get(room_a.code) is None # A is gone: the code no longer resolves + assert sup.get(room_b.code) is room_b + assert sup.by_token(token_a) is None # A's token is purged + assert sup.by_token(token_b) is room_b + assert sup.stats()["rooms"] == 1 + + +# ------------------------------------------------------------------ the test + + +def test_one_room_fails_without_affecting_the_other( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.ERROR, logger="undercooked.server.room") + fixture = IsolationApp() + sup = fixture.supervisor + clock = fixture.clock + + async def scenario() -> None: + # Two live rooms, both created through the supervisor (the + # creator is seat 0, host; the rooms get the store as their + # `store`). + ada = new_session("Ada", clock) + bob = new_session("Bob", clock) + room_a = sup.create_room(ada, "Ada") + room_b = sup.create_room(bob, "Bob") + room_b.select_level("08_good") # A plays the campaign's first + assert room_a.level_id == "08_bad" + + # Both are solo hosts: the start is legal as-is. + room_a.start(ada.token) + room_b.start(bob.token) + bring_both_to_playing(room_a, room_b, clock) + + inject_the_fault(room_a) + # The crash tick — isolated: it must not propagate. The wall + # time is not advanced: A takes this tick at the last wall time + # (dt 0), so B's cadence below stays exactly one 0.05 s frame + # per substep. + room_a.tick_once() + + check_room_a_is_aborted(room_a, caplog, ada.token, bob.token) + check_room_b_keeps_ticking(room_b, clock) + + # A closed room ticks nothing. + clock.advance(PERIOD) + room_a.tick_once() + assert room_a.state is RoomState.CLOSED + + await check_gc_reaps_a_leaves_b(sup, room_a, room_b, ada.token, bob.token) + + asyncio.run(scenario()) + + # The server keeps serving /healthz — room B is the live room. + client = TestClient(fixture.app) + body = client.get("/healthz").json() + assert body["status"] == "ok" + assert body["rooms"] == 1 diff --git a/tests/integration/test_room_tick.py b/tests/integration/test_room_tick.py new file mode 100644 index 0000000..ae53b40 --- /dev/null +++ b/tests/integration/test_room_tick.py @@ -0,0 +1,443 @@ +"""Integration: the `Room`'s tick ownership (phase 05, task 02) — the 20 Hz +loop, the synchronous `tick_once` core, and crash isolation. + +What this file pins: + +* `tick_once` drives the sim forward — a scripted `move` input moves the + cook (the room queues the frame, the kernel steps it, the buffer drains), +* the cadence has no drift — 100 manual calls at 0.05 s land on exactly + 100 substeps and exactly 5.0 s of wall time, and the async loop + (deadline-scheduled, PLAN §6) makes one tick per 0.05 s of wall time for + 100 ticks, +* a clock jump resynchronises instead of paying a catch-up burst (the sim's + `MAX_DT` clamp makes a burst worthless; the tick count proves there was + none), +* the event buffer drains exactly once (`take_events`), +* crash isolation — an exception in the tick (the `step` seam, or the round + build) is logged (code + round, never a player token), aborts the sim + (`sim.round_state.abort`), buffers an `err` event, fires `close`, and + never propagates out of `tick_once` or the loop; the loop runs to its + own exit (the task ends normally, not cancelled), +* a crash closes *only* that room: a second live room (registered in the + supervisor, tick task running) keeps ticking, and the supervisor stays + alive, +* a `Room` driven by `tick_once` reaches `WON` on the shipped level 1 with + a scripted cook (the phase-04 full-round policy, through the room's own + build, queue and settle). + +All time is a `FakeClock`; no test sleeps in real time. +""" + +from __future__ import annotations + +import asyncio +import logging +import sys +from pathlib import Path +from typing import Any + +import pytest +from test_room_fsm import ( + make_bundle, + make_level, + make_room, + seat_solo_host, +) + +from undercooked.config import Settings +from undercooked.server.clock import FakeClock +from undercooked.server.room import Room, RoomState +from undercooked.server.rooms import RoomSupervisor +from undercooked.server.sessions import new_session +from undercooked.sim.content import ContentBundle, load_level_file +from undercooked.sim.model import Event, EventKind, ItemKind, KitchenState, RoundResult, Vec2 +from undercooked.sim.recipe import RecipeBook +from undercooked.sim.step import PlayerInput +from undercooked.sim.step import step as real_step + +pytestmark = pytest.mark.integration + +#: The scripted full-round harness lives with the sim's tests (test-only). +_SIM_TESTS = Path(__file__).resolve().parents[1] / "sim" +if str(_SIM_TESTS) not in sys.path: + sys.path.insert(0, str(_SIM_TESTS)) +from helpers import ( # noqa: E402 # type: ignore[reportMissingImports] + LEVEL1_YAML, + CookController, + load_book, + shelf_id_for, +) + +#: One 20 Hz tick, in wall seconds (the sim's `STEP_DT`). +PERIOD = 0.05 + + +def drive_countdown_via_clock(room: Room, clock: FakeClock) -> None: + """The 3.0 s countdown in 0.05 s wall frames (clock-driven, the way the + manual `tick_once` tests run); leaves the room in `PLAYING`.""" + for _ in range(80): + clock.advance(PERIOD) + room.tick_once() + if room.state is RoomState.PLAYING: + break + assert room.state is RoomState.PLAYING + assert room.sim_state is not None + + +def make_playing_room(**settings: Any) -> tuple[Room, FakeClock]: + """A room in `PLAYING` with a built sim state (the countdown already ran).""" + bundle = make_bundle(make_level("06_playing")) + room, clock = make_room(bundle, **settings) + seat_solo_host(room) + room.request_start() + drive_countdown_via_clock(room, clock) + return room, clock + + +# ------------------------------------------------------------- tick_once + + +def test_tick_once_drives_the_sim_forward_under_a_scripted_input() -> None: + room, clock = make_playing_room() + state = room.sim_state + assert state is not None + start_x = state.players[0].pos.x + # An empty press at open floor: a named refusal (a sim event in the + # buffer, drained with the rest). The cook is on open floor at spawn, + # facing down into a wall row: no target in reach at all. + room.queue_input( + "tok-a", + PlayerInput(player_id="tok-a", action=True, face=state.players[0].facing), + ) + clock.advance(PERIOD) + room.tick_once() + events = room.take_events() + denied = [e for e in events if e.kind is EventKind.DENIED] + assert denied, "the empty press must land a named refusal in the buffer" + assert denied[0].payload["reason"] == "nothing_here" + # And 50 scripted move ticks move the cook (the queue feeds `step`). + for _ in range(50): + room.queue_input("tok-a", PlayerInput(player_id="tok-a", move=Vec2(1.0, 0.0))) + clock.advance(PERIOD) + room.tick_once() + + state = room.sim_state + assert state is not None + assert state.tick == 51 # one 0.05 s substep per wall 0.05 s: no slip + player = state.players[0] + assert player.pos.x > start_x # the scripted intent moved the cook + assert player.facing is not None and player.facing.value == "right" + + +# ---------------------------------------------------------------- cadence + + +def test_tick_cadence_has_no_drift_over_100_manual_calls() -> None: + # A 60 s level: the 100 ticks (5.0 s of sim) must not reach the verdict. + bundle = make_bundle(make_level("06_cadence", time_limit=60.0)) + room, clock = make_room(bundle) + seat_solo_host(room) + room.request_start() + drive_countdown_via_clock(room, clock) + assert room.sim_state is not None and room.sim_state.tick == 0 + wall_at_start = clock.now() + for _ in range(100): + clock.advance(PERIOD) + room.tick_once() + + state = room.sim_state + assert state is not None + assert state.tick == 100 # exactly 100 substeps: no tick lost or doubled + # Wall time and sim time agree to the float: 100 ticks x 0.05 s = 5.0 s. + assert abs((clock.now() - wall_at_start) - 5.0) < 1e-9 + + +async def test_the_tick_loop_schedules_on_deadlines_without_drift() -> None: + """The async loop (PLAN §6 "one asyncio tick task") at the 20 Hz + deadline cadence: 100 ticks over exactly 5.0 s of `FakeClock` time — + the first the room's prime (dt 0), 60 the 3.0 s countdown, 39 the + round — with the wall time and the sim tick count agreeing to the + float.""" + clock = FakeClock() + # A 60 s level: the 5.0 s of sim must not reach the verdict. + bundle = make_bundle(make_level("06_loop", time_limit=60.0)) + + async def yield_sleep(_seconds: float) -> None: + await asyncio.sleep(0) # the test is the time source (no pre-advance) + + room = Room( + code="TEST1", + settings=Settings(), + clock=clock, + content=bundle, + level_id=bundle.levels[0].id, + sleep=yield_sleep, + ) + seat_solo_host(room) + room.request_start() + room.start_ticking() + assert room.task is not None and not room.task.done() + + await asyncio.sleep(0) # the room runs its first wait (0.05 s) and parks + for _ in range(100): + clock.advance(PERIOD) + await asyncio.sleep(0) # the room ticks to the deadline: one tick per period + + state = room.sim_state + assert state is not None + assert room.state is RoomState.PLAYING + assert state.tick == 39 # 100 ticks: 1 prime (dt 0) + 60 countdown + 39 playing + assert abs(clock.now() - 5.0) < 1e-9 # 100 x 0.05 s, no drift + assert room.task is not None and not room.task.done() # still ticking + await room.close() + assert room.state is RoomState.CLOSED and room.task.cancelled() + + +async def test_the_tick_loop_resynchronises_after_a_clock_jump() -> None: + """A clock jump (a lid, a test advancing the `FakeClock`) must not buy a + catch-up burst: the room takes at most one (clamped) catch-up tick and + then waits the normal period. A burst would show as an inflated sim tick + count (each catch-up tick clamps to 0.1 s = 2 substeps).""" + clock = FakeClock() + bundle = make_bundle(make_level("06_jump")) + + async def yield_sleep(_seconds: float) -> None: + await asyncio.sleep(0) # the test is the time source (no pre-advance) + + room = Room( + code="TEST1", + settings=Settings(), + clock=clock, + content=bundle, + level_id=bundle.levels[0].id, + sleep=yield_sleep, + ) + seat_solo_host(room) + room.request_start() + room.start_ticking() + + for _ in range(10): # 0.5 s: 1 prime + 9 decrements, the countdown is at 2.55 s + clock.advance(PERIOD) + await asyncio.sleep(0) + clock.advance(10.0) # the jump + await asyncio.sleep(0) # the one catch-up tick: dt 10.0 (clamped), the countdown ends + for _ in range(10): # 0.5 s of playing + clock.advance(PERIOD) + await asyncio.sleep(0) + + state = room.sim_state + assert state is not None + assert room.state is RoomState.PLAYING + assert state.tick == 10 # the 0.5 s of playing: no burst (a burst would be >= 200) + assert abs(clock.now() - 11.0) < 1e-9 # 0.5 + 10.0 + 0.5 + await room.close() + + +# --------------------------------------------------------- crash isolation + + +def test_an_injected_exception_aborts_and_closes_without_raising( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.ERROR, logger="undercooked.server.room") + room, clock = make_playing_room() + assert room.sim_state is not None and room.sim_state.result is None + + def boom(*args: object, **kwargs: object) -> tuple[KitchenState, tuple[Event, ...]]: + msg = "sim exploded" + raise RuntimeError(msg) + + monkeypatch.setattr("undercooked.server.room.step", boom) + clock.advance(PERIOD) + room.tick_once() # must not propagate + + assert room.state is RoomState.CLOSED + assert room.sim_state is not None + assert room.sim_state.result is RoundResult.ABORTED # the sim's `abort` seam + assert room.abort_reason is not None + assert "RuntimeError" in room.abort_reason + events = room.take_events() + crash_events = [ + e for e in events if e.kind is EventKind.ERROR and e.payload.get("reason") == "tick_crash" + ] + assert len(crash_events) == 1 + assert crash_events[0].payload["round"] == room.round_no + # The log line carries the code and the round — never a player token. + assert "TEST1" in caplog.text and "round 1" in caplog.text + assert "tok-a" not in caplog.text + + +def test_a_crash_building_the_round_closes_the_room( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`countdown_done` builds the round; a build that raises (a room with no + level can be put in `COUNTDOWN` by the table directly) is crash-isolated + the same way — the room ends `CLOSED`, no state, no propagation.""" + room, clock = make_room(None) # no content bundle + seat_solo_host(room) + room._fire("host_start") # test setup: the table's row + for _ in range(80): + clock.advance(PERIOD) + room.tick_once() + assert room.state is RoomState.CLOSED # the build raised; the crash isolated it + assert room.sim_state is None + assert room.abort_reason is not None and "RuntimeError" in room.abort_reason + + +async def test_the_tick_loop_swallows_the_crash_and_exits_cleanly( + monkeypatch: pytest.MonkeyPatch, +) -> None: + clock = FakeClock() + bundle = make_bundle(make_level("06_loop_crash")) + + async def advancing_sleep(seconds: float) -> None: + clock.advance(seconds) + await asyncio.sleep(0) + + room = Room( + code="TEST1", + settings=Settings(), + clock=clock, + content=bundle, + level_id=bundle.levels[0].id, + sleep=advancing_sleep, + ) + seat_solo_host(room) + room.request_start() + + def boom(*args: object, **kwargs: object) -> tuple[KitchenState, tuple[Event, ...]]: + msg = "sim exploded" + raise RuntimeError(msg) + + monkeypatch.setattr("undercooked.server.room.step", boom) + room.start_ticking() + for _ in range(80): + if room.task is not None and room.task.done(): + break + await asyncio.sleep(0) + + assert room.state is RoomState.CLOSED + assert room.task is not None and room.task.done() + assert not room.task.cancelled() # the loop ran to its own exit + assert room.sim_state is not None + assert room.sim_state.result is RoundResult.ABORTED + assert await room.task is None # no exception escaped the loop + + +async def test_a_crash_closes_only_that_room(monkeypatch: pytest.MonkeyPatch) -> None: + """The phase's completion criterion: an injected sim exception aborts and + closes one room; a second live room (supervisor-registered, tick task + running) keeps ticking, and the supervisor stays alive.""" + clock = FakeClock() + bundle = make_bundle(make_level("06_bad"), make_level("06_good")) + + async def parked_sleep(_seconds: float) -> None: + await asyncio.Event().wait() # never set: the tasks run but never step + + supervisor = RoomSupervisor(Settings(), clock, bundle, sleep=parked_sleep) + room_bad = supervisor.create_room(new_session("Ada", clock), "Ada") + room_good = supervisor.create_room(new_session("Bob", clock), "Bob") + await supervisor.start() # starts the GC loop and every room's tick task + room_good.select_level("06_good") + room_bad.request_start() + room_good.request_start() + + for _ in range(80): # both to PLAYING + clock.advance(PERIOD) + room_bad.tick_once() + room_good.tick_once() + await asyncio.sleep(0) + assert room_bad.state is RoomState.PLAYING + assert room_good.state is RoomState.PLAYING + assert room_good.task is not None and not room_good.task.done() # a live room + assert room_good.sim_state is not None + good_tick_before = room_good.sim_state.tick + + def selective( + state: KitchenState, + dt: float, + inputs: tuple[PlayerInput, ...], + book: RecipeBook | None, + ) -> tuple[KitchenState, tuple[Event, ...]]: + if state.level_id == "06_bad": + msg = "sim exploded in the bad room" + raise RuntimeError(msg) + return real_step(state, dt, inputs, book) + + monkeypatch.setattr("undercooked.server.room.step", selective) + for _ in range(30): + clock.advance(PERIOD) + room_bad.tick_once() # the first one crashes; the rest are no-ops + room_good.tick_once() + await asyncio.sleep(0) + + # The bad room: aborted and closed, its exception isolated. + assert room_bad.state is RoomState.CLOSED + assert room_bad.sim_state is not None + assert room_bad.sim_state.result is RoundResult.ABORTED + assert room_bad.abort_reason is not None + # The good room: untouched — still playing, still ticking, task alive. + assert room_good.state is RoomState.PLAYING + assert room_good.sim_state is not None + assert room_good.sim_state.tick == good_tick_before + 30 + assert room_good.task is not None and not room_good.task.done() + # And the supervisor itself is alive. + assert supervisor.stats()["rooms"] == 2 + await supervisor.shutdown() + assert room_bad.state is RoomState.CLOSED + assert room_good.state is RoomState.CLOSED # the shutdown teardown, not a crash + + +# ------------------------------------------------------------------ level 1 + + +def test_a_room_driven_by_tick_once_reaches_won_on_level_1() -> None: + """The phase's completion criterion: a `Room` driven by `tick_once` — its + own build (level + seat + seed + the room's shelf/prop seeding), its + input queue, its settle — reaches `WON` on the shipped level 1 with the + phase-04 scripted cook (the same policy `test_full_rounds.py` proves + headless, through the room instead of the bare kernel).""" + level = load_level_file(LEVEL1_YAML) + bundle = ContentBundle(recipe_book=load_book(), levels=(level,)) + clock = FakeClock() + room = Room( + code="TEST1", + settings=Settings(), + clock=clock, + content=bundle, + level_id=level.id, + seed=0x5EED_0406, # the phase-04 harness's seed: round 1 plays it as-is + ) + room.add_seat("tok-0", "cook 1", True) + room.request_start() # the solo host + + drive_countdown_via_clock(room, clock) + assert room.sim_state is not None + assert room.sim_state.seed == room.round_seed + + controller = CookController(0, shelf_id_for(room.sim_state, ItemKind.TOMATO), "any") + for _ in range(4000): # 150 s of level + the settle, with slack + if room.state is not RoomState.PLAYING: + break + clock.advance(PERIOD) + if room.sim_state.result is None: + frame = controller.next_frame(room.sim_state, room.sim_state.tick) + assert room.queue_input(frame.player_id, frame) + room.tick_once() + + assert room.state is RoomState.ROUND_END + state = room.sim_state + assert state is not None + assert state.result is RoundResult.WON + assert state.score >= state.target_score + assert room.last_result is not None + assert room.last_result["result"] == "won" + assert room.last_result["score"] == state.score + assert room.last_result["stars"] in (1, 2, 3) # a win grades at least one star + # The verdict's events (incl. the one `ROUND_END`) landed in the buffer, + # and the buffer drains exactly once. + events = room.take_events() + assert any(e.kind is EventKind.ROUND_END for e in events) + assert any(e.kind is EventKind.SERVED for e in events) + assert room.take_events() == () # the second drain is empty + assert room.snapshot_seq == 2 diff --git a/tests/integration/test_rooms_api.py b/tests/integration/test_rooms_api.py new file mode 100644 index 0000000..294dec7 --- /dev/null +++ b/tests/integration/test_rooms_api.py @@ -0,0 +1,282 @@ +"""Integration: the room HTTP surface (`routes_rooms.py`) over a live +in-process app. + +Covers the three endpoints — `POST /api/rooms` (201 + the once-returned +server-minted token), `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join` — +plus the named refusals (`room_full` 503 for a full room *and* a full +process, `room_closed` 410, `room_not_found` 404 for a malformed *or* +unknown code: the two must stay indistinguishable, US-02 criterion 4), +no-token-leakage for other players, name sanitization at the boundary, and +`/healthz`'s live room count. Time-dependent tests swap the app's +supervisor for one on a `FakeClock` *before* the TestClient's lifespan runs. +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from undercooked.config import Settings +from undercooked.server.app import create_app +from undercooked.server.clock import FakeClock +from undercooked.server.codes import CROCKFORD +from undercooked.server.rooms import RoomSupervisor +from undercooked.server.sessions import new_session + +pytestmark = pytest.mark.integration + +#: The test-isolated content dir (a recipe book, no levels) — explicit, so +#: the app never reads the shipped `levels/` implicitly. +FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels" + + +def make_app(**settings_overrides: Any) -> FastAPI: + settings = Settings(levels_dir=FIXTURE_LEVELS_DIR, **settings_overrides) + return create_app(settings) + + +class FakeClockSupervisorApp: + """An app whose supervisor runs on a `FakeClock`. + + Built by swapping `app.state.supervisor` *before* the TestClient's + lifespan runs `supervisor.start()`; the lifespan then starts (and later + shuts down) the fake-clock supervisor, so GC is drivable without sleeps. + """ + + def __init__(self, **settings_overrides: Any) -> None: + self.app = make_app(**settings_overrides) + self.clock = FakeClock() + self.app.state.supervisor = RoomSupervisor( + self.app.state.settings, self.clock, self.app.state.content + ) + + +def create_room(client: TestClient, name: str | None = None) -> dict[str, Any]: + response = client.post("/api/rooms", json={"name": name} if name is not None else {}) + assert response.status_code == 201, response.text + return response.json() + + +def join_room( + client: TestClient, code: str, token: str, name: str | None = None +) -> tuple[int, dict[str, Any]]: + body: dict[str, Any] = {"token": token} + if name is not None: + body["name"] = name + response = client.post(f"/api/rooms/{code}/join", json=body) + return response.status_code, response.json() + + +# ---------------------------------------------------------------------- create + + +def test_create_room_returns_201_with_a_crockford_code_seats_and_you() -> None: + with TestClient(make_app()) as client: + body = create_room(client, "Ada") + + assert len(body["room_code"]) == 5 + assert set(body["room_code"]) <= set(CROCKFORD) + assert body["state"] == "lobby" + assert body["seats"] == [ + {"index": 0, "name": "Ada", "host": True, "connected": True} + ] + assert len(body["you"]["token"]) == 32 # token_urlsafe(24): 24 bytes, base64url + assert body["you"]["seat"] == 0 + assert body["you"]["host"] is True + + +def test_create_room_with_no_name_defaults_to_cook_1() -> None: + with TestClient(make_app()) as client: + body = create_room(client) + assert body["seats"][0]["name"] == "Cook #1" + + +def test_create_room_sanitizes_the_name_at_the_boundary() -> None: + with TestClient(make_app()) as client: + body = client.post( + "/api/rooms", json={"name": " A<>d\x01a B\n"} + ).json() + assert body["seats"][0]["name"] == "Ada B" + + with TestClient(make_app()) as client: + body = client.post( + "/api/rooms", json={"name": "A" * 40} + ).json() + assert body["seats"][0]["name"] == "A" * 16 # the 16-char cap + + +def test_create_room_without_any_body_is_the_bare_curl_criterion() -> None: + """The phase's completion criterion: `curl -X POST /api/rooms` + (no JSON body at all) must not 422 — it reads as no name, no + token (a server-minted token, the `Cook #1` default).""" + with TestClient(make_app()) as client: + response = client.post("/api/rooms") + assert response.status_code == 201, response.text + body = response.json() + assert body["seats"] == [ + {"index": 0, "name": "Cook #1", "host": True, "connected": True} + ] + assert len(body["you"]["token"]) == 32 + + +def test_create_room_at_process_capacity_is_503_room_full() -> None: + with TestClient(make_app(max_rooms=1)) as client: + assert create_room(client)["room_code"] + response = client.post("/api/rooms", json={}) + assert response.status_code == 503 + assert response.json() == {"code": "room_full"} + + +def test_create_room_rejects_malformed_bodies() -> None: + with TestClient(make_app()) as client: + assert client.post("/api/rooms", json={"name": "Ada", "extra": 1}).status_code == 422 + assert client.post("/api/rooms", json={"n": "Ada"}).status_code == 422 + + +# ------------------------------------------------------------------------ get + + +def test_get_room_shows_seats_without_any_tokens() -> None: + with TestClient(make_app()) as client: + code = create_room(client, "Ada")["room_code"] + body = client.get(f"/api/rooms/{code.lower()}").json() # normalized + + assert body["room_code"] == code + assert body["state"] == "lobby" + assert len(body["seats"]) == 1 + assert set(body["seats"][0]) == {"index", "name", "host", "connected"} + assert "token" not in body["seats"][0] + assert body["you"] is None # no caller identity exists in a GET + + +def test_get_unknown_and_malformed_codes_are_indistinguishable() -> None: + """US-02 criterion 4: `ZZZZZ` (unknown) and `!!!` (malformed) must + produce the *same* response, so the API is not a room-existence oracle.""" + with TestClient(make_app()) as client: + unknown = client.get("/api/rooms/ZZZZZ") + malformed = client.get("/api/rooms/!!!") + assert unknown.status_code == 404 + assert malformed.status_code == 404 + assert unknown.json() == malformed.json() == {"code": "room_not_found"} + + +# ----------------------------------------------------------------------- join + + +def test_join_seats_the_player_in_the_next_seat_and_hides_other_tokens() -> None: + with TestClient(make_app()) as client: + code = create_room(client, "Ada")["room_code"] + status, body = join_room(client, code, "tok-bob", "Bob") + + assert status == 200 + assert body["you"] == {"token": "tok-bob", "seat": 1, "host": False} + assert [seat["name"] for seat in body["seats"]] == ["Ada", "Bob"] + for seat in body["seats"]: + assert "token" not in seat + blob = str(body["seats"]) + assert "tok-bob" not in blob # never in the *other* players' views + + +def test_join_rejoin_is_idempotent_and_keeps_the_seat() -> None: + with TestClient(make_app()) as client: + code = create_room(client, "Ada")["room_code"] + status_first, body_first = join_room(client, code, "tok-bob", "Bob") + status_second, body_second = join_room(client, code, "tok-bob") + + assert status_first == 200 and status_second == 200 + assert body_second["you"]["seat"] == body_first["you"]["seat"] == 1 + assert len(body_second["seats"]) == 2 # no second seat was minted + + +def test_join_updates_the_name_only_when_one_is_typed() -> None: + with TestClient(make_app()) as client: + code = create_room(client, "Ada")["room_code"] + join_room(client, code, "tok-bob", "Bob") + status, body = join_room(client, code, "tok-bob", "Robert") + assert status == 200 + assert body["seats"][1]["name"] == "Robert" + status, body = join_room(client, code, "tok-bob") + assert status == 200 + assert body["seats"][1]["name"] == "Robert" # untouched on rejoin + + +def test_join_a_full_room_is_503_room_full() -> None: + with TestClient(make_app(max_players=1)) as client: + code = create_room(client, "Ada")["room_code"] + status, body = join_room(client, code, "tok-bob", "Bob") + assert status == 503 + assert body == {"code": "room_full"} + + +def test_join_a_closed_room_is_410_room_closed() -> None: + """Close the room *before* the app starts (no tick task exists yet, so + the close happens in this test's own loop — never across the TestClient's + portal loop), then watch the API report the closed room until the GC + pass would drop it.""" + fixture = FakeClockSupervisorApp() + session = new_session("Ada", fixture.clock) + room = fixture.app.state.supervisor.create_room(session, "Ada") + asyncio.run(fixture.app.state.supervisor.close_room(room.code)) + + with TestClient(fixture.app) as client: + inspect = client.get(f"/api/rooms/{room.code}") + assert inspect.status_code == 200 # still in the registry + assert inspect.json()["state"] == "closed" + status, body = join_room(client, room.code, "tok-bob", "Bob") + assert status == 410 + assert body == {"code": "room_closed"} + + with TestClient(fixture.app) as client: # after shutdown the room is gone + assert client.get(f"/api/rooms/{room.code}").status_code == 404 + + +def test_join_unknown_and_malformed_codes_are_indistinguishable() -> None: + with TestClient(make_app()) as client: + unknown = client.post("/api/rooms/ZZZZZ/join", json={"token": "tok"}) + malformed = client.post("/api/rooms/!!!/join", json={"token": "tok"}) + assert unknown.status_code == 404 + assert malformed.status_code == 404 + assert unknown.json() == malformed.json() == {"code": "room_not_found"} + + +def test_join_rejects_malformed_bodies() -> None: + with TestClient(make_app()) as client: + code = create_room(client)["room_code"] + assert client.post(f"/api/rooms/{code}/join", json={"n": "tok"}).status_code == 422 + assert ( + client.post(f"/api/rooms/{code}/join", json={"token": ""}).status_code == 422 + ) + assert ( + client.post( + f"/api/rooms/{code}/join", json={"token": "tok", "extra": 1} + ).status_code + == 422 + ) + + +def test_join_moves_a_token_between_rooms() -> None: + with TestClient(make_app()) as client: + code_a = create_room(client, "Ada")["room_code"] + code_b = create_room(client, "Bob")["room_code"] + status, _ = join_room(client, code_a, "tok-ada2", "Ada2") + assert status == 200 + status, body = join_room(client, code_b, "tok-ada2") + assert status == 200 + assert body["you"]["seat"] == 1 # seated in room B + room_a = client.get(f"/api/rooms/{code_a}").json() + assert len(room_a["seats"]) == 1 # the token was evicted from room A + + +def test_healthz_reports_the_live_room_count() -> None: + with TestClient(make_app()) as client: + assert client.get("/healthz").json()["rooms"] == 0 + create_room(client, "Ada") + create_room(client, "Bob") + body = client.get("/healthz").json() + assert body["status"] == "ok" + assert body["rooms"] == 2 diff --git a/tests/integration/test_sessions.py b/tests/integration/test_sessions.py new file mode 100644 index 0000000..dc9fb5e --- /dev/null +++ b/tests/integration/test_sessions.py @@ -0,0 +1,755 @@ +"""Integration: the in-memory session store and its wiring (phase 05, +task 04; US-07 campaign progression) — all on a `FakeClock` (no test +sleeps). + +Covers the store's contract (the task file's list): + +* **unlock only on win** — a `LOST` / `ABORTED` result never touches + `unlocked_level`; a `WON` result raises it to `level.order + 1`, +* **the unlock is monotonic** — win L1, lose L2: `unlocked_level` is 2 + and stays 2 (a loss never decreases it; a lower-order win never + decreases it either), +* **the best score replaces only when higher** — a tie keeps the older + run (and its `completed_at`); the stars are the winning run's, +* **counters accumulate across rooms** — `rounds_played` per finished + round, `dishes_served` per served dish, summed over `record_result` + calls, +* **`to_public` leaks nothing beyond the public shape** — the exact key + set; no `token`, no `created_at` / `last_seen`; +* **an unknown token auto-creates at `unlocked_level=1`** + (`get_or_create` and `to_public`), +* **the LRU evicts the stalest and never the current session** (and + 10 001 sessions stay bounded at the 10 000 cap), +* **the wiring** — the supervisor owns the store (the rooms get it as + their `store`; `POST /api/rooms` takes the session from the + `X-Undercooked-Token` header or the body's `token`, minting + otherwise, and returns the token once; a known token's progression is + never reset), `GET /api/session` (200 for a fresh token — + `unlocked: 1`, empty `best`; 400 `no_token` without the header), + `Room.set_level` asks the store's `is_unlocked`, + `Room.next_level_id` reads the store's `next_level_id`, and + `round_over` records the result into every seated player's session + (spectators are not players and are not recorded). +""" + +from __future__ import annotations + +import copy +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from undercooked.config import Settings +from undercooked.server.app import create_app +from undercooked.server.clock import FakeClock +from undercooked.server.room import Room, RoomState +from undercooked.server.rooms import RoomSupervisor +from undercooked.server.seats import LevelLocked, UnknownLevel +from undercooked.server.sessions import SessionRecord, SessionStore, new_session +from undercooked.sim.content import ContentBundle +from undercooked.sim.level import Level, LevelDef +from undercooked.sim.model import ItemForm, ItemKind, StationKind +from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step + +pytestmark = pytest.mark.integration + +#: The test-isolated content dir (a recipe book, no levels) — explicit, so +#: the app never reads the shipped `levels/` implicitly. +FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels" + +# ------------------------------------------------------------------ fixtures + + +def make_book() -> RecipeBook: + """A one-recipe book: the small levels' order pool names `soup`.""" + 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, order: int, *, time_limit: float = 3.0, target: int = 1000 +) -> Level: + """A 7x4 kitchen (one spawn shelf, one serve window) at campaign + `order`; `target` above any idle score, so a round ends `LOST` on + the buzzer — the shortest path to `ROUND_END`.""" + 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"Session kitchen {level_id}", + "order": order, + "time_limit": time_limit, + "target_score": target, + "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(*levels: Level) -> ContentBundle: + return ContentBundle(recipe_book=make_book(), levels=levels) + + +def two_level_bundle() -> ContentBundle: + return make_bundle(make_level("05a_first", 1), make_level("05b_second", 2)) + + +def three_level_bundle() -> ContentBundle: + return make_bundle( + make_level("05a_first", 1), + make_level("05b_second", 2), + make_level("05c_third", 3), + ) + + +def make_store(bundle: ContentBundle, **overrides: Any) -> tuple[SessionStore, FakeClock]: + clock = FakeClock() + store = SessionStore( + clock=clock, + campaign={level.id: level.order for level in bundle.levels}, + **overrides, + ) + return store, clock + + +def make_room_with_store( + bundle: ContentBundle, **settings: Any +) -> tuple[Room, FakeClock, SessionStore]: + """A `LOBBY` room on a `FakeClock`, wired to its own store (the + supervisor's wiring, in miniature).""" + clock = FakeClock() + store = SessionStore( + clock=clock, campaign={level.id: level.order for level in bundle.levels} + ) + room = Room( + code="TEST1", + settings=Settings(**settings), + clock=clock, + content=bundle, + level_id=bundle.levels[0].id if bundle.levels else None, + sessions=store.get, + store=store, + ) + return room, clock, store + + +def make_app(**settings_overrides: Any) -> FastAPI: + settings = Settings(levels_dir=FIXTURE_LEVELS_DIR, **settings_overrides) + return create_app(settings) + + +def record_of(store: SessionStore, token: str) -> SessionRecord: + """Narrow `store.get` for the tests (the record exists).""" + record = store.get(token) + assert record is not None + return record + + +def won( + level: Level, *, score: int = 100, stars: int = 2, served: int = 1 +) -> dict[str, object]: + """A `result_payload`-shaped `WON` body (the sim's exact key set).""" + return { + "result": "won", + "score": score, + "target": 0, + "stars": stars, + "time_left": 1.0, + "counters": {"served": served, "missed": 0, "wasted": 0}, + "longest_streak": 0, + "players": [], + "level_id": level.id, + "seed": 0, + "ticks": 0, + } + + +def lost(level: Level, *, served: int = 0) -> dict[str, object]: + """A `result_payload`-shaped `LOST` body.""" + return { + "result": "lost", + "score": 0, + "target": 0, + "stars": 0, + "time_left": 0.0, + "counters": {"served": served, "missed": 1, "wasted": 0}, + "longest_streak": 0, + "players": [], + "level_id": level.id, + "seed": 0, + "ticks": 0, + } + + +def drive_countdown(room: Room, now: float = 0.0) -> float: + """The 3 s countdown in 1 s frames; returns the final `now`, with the + room in `PLAYING` (the round built).""" + room.tick_once(now=now) + for offset in (1.0, 2.0, 3.0): + room.tick_once(now=now + offset) + assert room.state is RoomState.PLAYING + return now + 3.0 + + +def play_round_to_end(room: Room, now: float = 3.0) -> float: + """1 s frames through the 3.0 s round and its 1.0 s settle window, + until `ROUND_END`; returns the final `now`.""" + while room.state is RoomState.PLAYING: + now += 1.0 + room.tick_once(now=now) + assert room.state is RoomState.ROUND_END + return now + + +# ------------------------------------------------------------ get / create + + +def test_fresh_token_auto_creates_at_the_campaign_floor() -> None: + bundle = two_level_bundle() + store, clock = make_store(bundle) + record = store.get_or_create("tok-a") + assert record.token == "tok-a" # noqa: S105 - a test token, not a credential + assert record.unlocked_level == 1 # the campaign's floor + assert record.best == {} + assert record.rounds_played == 0 and record.dishes_served == 0 + assert record.endless_best is None + assert record.created_at == 0.0 and record.last_seen == 0.0 + assert store.get("tok-a") is record # the same object on the second read + clock.advance(1.0) + again = store.get_or_create("tok-a") # a touch, not a re-create + assert again is record + assert again.last_seen == 1.0 and again.created_at == 0.0 + + +def test_get_never_auto_creates_a_record() -> None: + """A lookup is not an identity: `get` never creates (only + `get_or_create` / `put` do).""" + bundle = two_level_bundle() + store, _ = make_store(bundle) + assert store.get("tok-unknown") is None + assert len(store) == 0 + + +def test_the_cap_must_be_at_least_one() -> None: + with pytest.raises(ValueError, match="max_entries"): + SessionStore(max_entries=0) + + +def test_put_keeps_an_existing_record_alone() -> None: + """Progression is never replaced: a `put` for a known token is a + no-op (a re-creator keeps their unlocks).""" + bundle = two_level_bundle() + store, clock = make_store(bundle) + first = new_session("Ada", clock) + store.put(first) + store.record_result(first.token, bundle.levels[0], won(bundle.levels[0])) + assert record_of(store, first.token).unlocked_level == 2 + + impostor = SessionRecord( + token=first.token, display_name="Imp", created_at=clock.now(), unlocked_level=1 + ) + store.put(impostor) + assert store.get(first.token) is first # the original, intact + assert record_of(store, first.token).unlocked_level == 2 + + +# ---------------------------------------------------------------- unlocking + + +def test_unlock_happens_only_on_a_win() -> None: + bundle = two_level_bundle() + store, _ = make_store(bundle) + level1 = bundle.levels[0] + store.record_result("tok-a", level1, lost(level1)) + assert record_of(store, "tok-a").unlocked_level == 1 # a loss: no unlock + store.record_result( + "tok-a", level1, {"result": "aborted", "score": 0, "stars": 0, "counters": {}} + ) + assert record_of(store, "tok-a").unlocked_level == 1 # an abort: no unlock + store.record_result("tok-a", level1, won(level1)) + assert record_of(store, "tok-a").unlocked_level == 2 # a win: order + 1 + + +def test_unlock_is_monotonic_across_wins_and_losses() -> None: + """Win L1, lose L2: L2 stays unlocked (the task's criterion); a + lower-order win and a loss never decrease the unlock.""" + bundle = three_level_bundle() + store, _ = make_store(bundle) + l1, l2, l3 = bundle.levels + store.record_result("tok-a", l1, won(l1)) + assert record_of(store, "tok-a").unlocked_level == 2 + store.record_result("tok-a", l2, lost(l2)) + assert record_of(store, "tok-a").unlocked_level == 2 # a loss: still unlocked + store.record_result("tok-a", l1, won(l1)) # a lower-order win + assert record_of(store, "tok-a").unlocked_level == 2 # never decreases + store.record_result("tok-a", l2, won(l2)) + assert record_of(store, "tok-a").unlocked_level == 3 + store.record_result("tok-a", l3, lost(l3)) + assert record_of(store, "tok-a").unlocked_level == 3 + + +# --------------------------------------------------------------- best runs + + +def test_best_score_replaces_only_when_higher() -> None: + """A lower score never overwrites a stored best; a tie keeps the + older run (and its `completed_at`); a higher one replaces — with + the new run's `completed_at`.""" + bundle = two_level_bundle() + store, clock = make_store(bundle) + level1 = bundle.levels[0] + store.record_result("tok-a", level1, won(level1, score=100, stars=2)) + first = record_of(store, "tok-a").best["05a_first"] + assert first.score == 100 + + clock.advance(1.0) + store.record_result("tok-a", level1, won(level1, score=90, stars=1)) + assert record_of(store, "tok-a").best["05a_first"] is first # lower: kept + + clock.advance(1.0) + store.record_result("tok-a", level1, won(level1, score=100, stars=3)) + assert record_of(store, "tok-a").best["05a_first"] is first # a tie: the older kept + assert first.completed_at == 0.0 # the first to set it + + clock.advance(1.0) + store.record_result("tok-a", level1, won(level1, score=120, stars=3)) + best = record_of(store, "tok-a").best["05a_first"] + assert best is not first + assert best.score == 120 + assert best.completed_at == 3.0 + + +def test_stars_are_recorded_with_the_best_run() -> None: + bundle = two_level_bundle() + store, _ = make_store(bundle) + level1 = bundle.levels[0] + store.record_result("tok-a", level1, won(level1, score=50, stars=1, served=2)) + assert record_of(store, "tok-a").best["05a_first"].stars == 1 + store.record_result("tok-a", level1, won(level1, score=60, stars=3)) + assert record_of(store, "tok-a").best["05a_first"].stars == 3 # the new best's stars + + +# --------------------------------------------------------------- counters + + +def test_counters_accumulate_across_rooms() -> None: + """`rounds_played` per finished round (a loss counts too), + `dishes_served` per served dish — summed over `record_result` calls + that stand in for different rooms.""" + bundle = two_level_bundle() + store, _ = make_store(bundle) + store.record_result("tok-a", bundle.levels[0], lost(bundle.levels[0], served=3)) + store.record_result("tok-a", bundle.levels[1], won(bundle.levels[1], served=5)) + record = record_of(store, "tok-a") + assert record.rounds_played == 2 + assert record.dishes_served == 8 + + +def test_record_result_copies_floats_and_reads_malformed_types() -> None: + """The payload is a plain-JSON contract: a float is read as an int; + a wrong type (or a non-integral float) reads as the default — + never a crash.""" + bundle = two_level_bundle() + store, _ = make_store(bundle) + level1 = bundle.levels[0] + float_payload = won(level1) + float_payload["score"] = 42.0 # a float is read as an int + store.record_result("tok-a", level1, float_payload) + assert record_of(store, "tok-a").best["05a_first"].score == 42 + bad_payload = won(level1) + bad_payload["score"] = 50 + bad_payload["stars"] = "three" # a wrong type reads as the default + bad_payload["counters"] = {"served": 2.5} # a non-integral float: default + store.record_result("tok-a", level1, bad_payload) + best = record_of(store, "tok-a").best["05a_first"] + assert best.score == 50 # 50 > 42: the higher one still replaces + assert best.stars == 0 # the `stars` default, not a crash + assert record_of(store, "tok-a").dishes_served == 1 # 1 + (2.5 read as 0) + + +def test_the_store_exposes_its_clock_and_cap() -> None: + bundle = two_level_bundle() + store, clock = make_store(bundle) + assert store.max_entries == 10_000 + assert store.clock is clock + store.get_or_create("tok-a") + assert len(store) == 1 + + +# ---------------------------------------------------------------- to_public + + +def test_to_public_leaks_nothing_beyond_the_public_shape() -> None: + bundle = two_level_bundle() + store, _clock = make_store(bundle) + store.get_or_create("tok-secret-123") + store.record_result("tok-secret-123", bundle.levels[0], won(bundle.levels[0])) + public = store.to_public("tok-secret-123") + assert set(public) == { + "display_name", + "unlocked", + "best", + "rounds_played", + "dishes_served", + "endless_best", + } + assert set(public["best"]["05a_first"]) == {"score", "stars", "completed_at"} + text = json.dumps(public) + assert "tok-secret-123" not in text # the token never leaves the store + assert "created_at" not in public and "last_seen" not in public + assert public["unlocked"] == 2 and public["rounds_played"] == 1 + + +def test_to_public_for_a_fresh_token_reads_the_floor_and_an_empty_best() -> None: + """The task's completion criterion, at the store: a fresh token + auto-creates and reads `unlocked: 1` with an empty `best`.""" + bundle = two_level_bundle() + store, _ = make_store(bundle) + public = store.to_public("tok-fresh-999") + assert public["unlocked"] == 1 + assert public["best"] == {} + assert store.get("tok-fresh-999") is not None # the read created it + + +# -------------------------------------------------------------------- LRU + + +def test_lru_evicts_the_stalest_and_never_the_current_session() -> None: + clock = FakeClock() + store = SessionStore(max_entries=3, clock=clock) + store.get_or_create("tok-a") + clock.advance(1.0) + store.get_or_create("tok-b") + clock.advance(1.0) + store.get_or_create("tok-c") + # Touch c, then b: a is now the stalest + clock.advance(1.0) + store.get_or_create("tok-c") + clock.advance(1.0) + store.get_or_create("tok-b") + # Insert d: a goes — never d (the session being created) + clock.advance(1.0) + store.get_or_create("tok-d") + assert store.get("tok-a") is None + assert store.get("tok-b") is not None + assert store.get("tok-c") is not None + assert store.get("tok-d") is not None + assert len(store) == 3 + assert store.stats()["evictions"] == 1 + # Full again; touch d, insert e: the stalest of {b, c, d} goes — c + clock.advance(1.0) + store.get_or_create("tok-d") + clock.advance(1.0) + store.get_or_create("tok-e") + assert store.get("tok-c") is None + assert store.get("tok-b") is not None + assert store.get("tok-d") is not None + assert store.get("tok-e") is not None + assert len(store) == 3 + assert store.stats()["evictions"] == 2 + + +def test_lru_never_evicts_the_session_being_touched() -> None: + """Cap 1: a re-touch at the cap evicts nothing (the store holds only + the current session); a *new* token evicts the old one — and is + never evicted itself.""" + clock = FakeClock() + store = SessionStore(max_entries=1, clock=clock) + store.get_or_create("tok-a") + assert len(store) == 1 + clock.advance(1.0) + record = store.get_or_create("tok-a") + assert len(store) == 1 + assert record is store.get("tok-a") # still there, untouched + clock.advance(1.0) + store.get_or_create("tok-b") + assert store.get("tok-a") is None + assert store.get("tok-b") is not None + assert len(store) == 1 + + +def test_ten_thousand_one_sessions_stay_bounded() -> None: + clock = FakeClock() + store = SessionStore(max_entries=10_000, clock=clock) + seen_max = 0 + for index in range(10_001): + store.get_or_create(f"t-{index:05d}") + seen_max = max(seen_max, len(store)) + assert seen_max == 10_000 # never above the cap + assert store.stats() == { + "sessions": 10_000, + "max_entries": 10_000, + "evictions": 1, # only the 10 001st insert evicted (one) + } + + +def test_stats_reports_the_census() -> None: + clock = FakeClock() + store = SessionStore(max_entries=2, clock=clock) + store.get_or_create("tok-a") + store.get_or_create("tok-b") + store.get_or_create("tok-c") # evicts a + assert store.stats() == {"sessions": 2, "max_entries": 2, "evictions": 1} + + +# ------------------------------------------------------ is_unlocked / next + + +def test_is_unlocked_reads_the_campaign_and_the_session() -> None: + bundle = two_level_bundle() + store, _ = make_store(bundle) + assert store.is_unlocked("tok-a", "05a_first") is True # the floor, no record + assert store.is_unlocked("tok-a", "05b_second") is False + assert store.is_unlocked("tok-a", "05_zzz") is False # unknown: locked by default + store.record_result("tok-a", bundle.levels[0], won(bundle.levels[0])) + assert store.is_unlocked("tok-a", "05b_second") is True + bare = SessionStore(clock=FakeClock()) + assert bare.is_unlocked("tok-a", "05a_first") is False # no campaign: nothing + + +def test_next_level_id_walks_the_campaign() -> None: + bundle = three_level_bundle() + store, _ = make_store(bundle) + l1, l2, l3 = bundle.levels + assert store.next_level_id("tok-a", bundle) == "05a_first" # fresh: the floor + store.record_result("tok-a", l1, won(l1)) + assert store.next_level_id("tok-a", bundle) == "05b_second" # after a win + store.record_result("tok-a", l2, won(l2)) + assert store.next_level_id("tok-a", bundle) == "05c_third" + store.record_result("tok-a", l3, lost(l3)) # a loss: retry the level just played + assert store.next_level_id("tok-a", bundle) == "05c_third" + store.record_result("tok-a", l3, won(l3)) + assert store.next_level_id("tok-a", bundle) is None # the campaign is done + + +# ------------------------------------------------------------------ wiring + + +def test_the_supervisor_owns_the_session_store_and_the_rooms_get_it() -> None: + bundle = two_level_bundle() + clock = FakeClock() + supervisor = RoomSupervisor(Settings(), clock, bundle) + assert isinstance(supervisor.sessions, SessionStore) + session = new_session("Ada", clock) + room = supervisor.create_room(session, "Ada") + assert room.store is supervisor.sessions # the same store the room records into + assert supervisor.session_for(session.token) is session + supervisor.join(room.code, "tok-join", "Bob") + record = supervisor.session_for("tok-join") + assert record is not None + assert record.display_name == "Bob" + assert record.unlocked_level == 1 # a fresh session: the campaign's floor + + +# ------------------------------------------------------- POST /api/rooms + + +def test_post_api_rooms_takes_the_token_from_the_header() -> None: + with TestClient(make_app()) as client: + response = client.post( + "/api/rooms", + json={"name": "Ada"}, + headers={"X-Undercooked-Token": "tok-header-1"}, + ) + assert response.status_code == 201, response.text + body = response.json() + assert body["you"]["token"] == "tok-header-1" # noqa: S105 - a test token, not a credential + assert len(body["seats"]) == 1 and body["seats"][0]["name"] == "Ada" + + +def test_post_api_rooms_takes_the_token_from_the_body_when_no_header() -> None: + with TestClient(make_app()) as client: + response = client.post("/api/rooms", json={"name": "Bob", "token": "tok-body-2"}) + assert response.status_code == 201, response.text + assert response.json()["you"]["token"] == "tok-body-2" # noqa: S105 - a test token, not a credential + + +def test_the_header_token_wins_over_the_body_token() -> None: + with TestClient(make_app()) as client: + response = client.post( + "/api/rooms", + json={"token": "tok-body"}, + headers={"X-Undercooked-Token": "tok-header"}, + ) + assert response.status_code == 201, response.text + assert response.json()["you"]["token"] == "tok-header" # noqa: S105 - a test token, not a credential + + +def test_post_api_rooms_without_any_token_mints_one_and_it_lives_in_the_store() -> None: + app = make_app() + store = app.state.supervisor.sessions + with TestClient(app) as client: + body = client.post("/api/rooms", json={}).json() + token = body["you"]["token"] + assert len(token) == 32 # token_urlsafe(24) + assert store.get(token) is not None + assert record_of(store, token).unlocked_level == 1 + assert record_of(store, token).created_at >= 0.0 + + +def test_a_known_tokens_progression_survives_a_recreate() -> None: + """A token that already has progression (and a room) re-creating a + room keeps it: the one-token-one-room eviction drops the old seat, + the store's record is untouched.""" + app = make_app() + store = app.state.supervisor.sessions + with TestClient(app) as client: + first = client.post( + "/api/rooms", json={"name": "Ada"}, headers={"X-Undercooked-Token": "tok-p"} + ) + assert first.status_code == 201, first.text + standalone = make_level("zz_progression", 1) + store.record_result( + "tok-p", standalone, {"result": "won", "score": 5, "stars": 1, + "counters": {"served": 1}} + ) + assert record_of(store, "tok-p").unlocked_level == 2 + second = client.post("/api/rooms", json={}, headers={"X-Undercooked-Token": "tok-p"}) + assert second.status_code == 201, second.text + assert first.json()["you"]["token"] == "tok-p" == second.json()["you"]["token"] # noqa: S105 - a test token, not a credential + record = record_of(store, "tok-p") + assert record.unlocked_level == 2 # intact + assert record.best["zz_progression"].score == 5 + + +# --------------------------------------------------------- GET /api/session + + +def test_get_api_session_for_a_fresh_token_returns_unlocked_1_and_empty_best() -> None: + """The task's completion criterion, over HTTP.""" + with TestClient(make_app()) as client: + response = client.get( + "/api/session", headers={"X-Undercooked-Token": "tok-fresh-42"} + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["unlocked"] == 1 + assert body["best"] == {} + assert body["display_name"] is None + assert body["rounds_played"] == 0 + assert body["dishes_served"] == 0 + assert body["endless_best"] is None + + +def test_get_api_session_without_a_header_is_400_no_token() -> None: + with TestClient(make_app()) as client: + response = client.get("/api/session") + assert response.status_code == 400 + assert response.json() == {"code": "no_token"} + + +def test_get_api_session_shows_recorded_progression() -> None: + app = make_app() + store = app.state.supervisor.sessions + with TestClient(app) as client: + created = client.post( + "/api/rooms", json={"name": "Ada"}, headers={"X-Undercooked-Token": "tok-s1"} + ) + assert created.status_code == 201, created.text + store.record_result( + "tok-s1", + make_level("zz_progression", 1), + {"result": "won", "score": 40, "stars": 2, "counters": {"served": 3}}, + ) + body = client.get( + "/api/session", headers={"X-Undercooked-Token": "tok-s1"} + ).json() + assert body["unlocked"] == 2 + assert body["display_name"] == "Ada" + assert body["rounds_played"] == 1 + assert body["dishes_served"] == 3 + assert body["best"]["zz_progression"]["score"] == 40 + assert body["best"]["zz_progression"]["stars"] == 2 + assert isinstance(body["best"]["zz_progression"]["completed_at"], float) + + +# --------------------------------------------------- the room's store verbs + + +def test_round_over_records_the_result_into_every_seated_players_sessions() -> None: + """`round_over`'s half (a loss here — the store's win semantics are + pinned above): every seated player's session counts the round; + spectators are not players and are not recorded.""" + bundle = two_level_bundle() + room, _clock, store = make_room_with_store(bundle) + room.add_seat("tok-a", "Ada", True) + room.add_seat("tok-b", "Bob", False) + room.set_ready("tok-b", True) # so the host's start can run + room.add_spectator("tok-s", "Spy") + room.request_start() + now = drive_countdown(room) + play_round_to_end(room, now) + + assert room.last_result is not None + assert room.last_result["result"] == "lost" + assert record_of(store, "tok-a").rounds_played == 1 + assert record_of(store, "tok-b").rounds_played == 1 + assert record_of(store, "tok-a").unlocked_level == 1 # a loss: no unlock + assert store.get("tok-s") is None # the spectator was not recorded + assert room.last_result is not None # the payload itself is intact + + +def test_set_level_asks_the_store_is_unlocked() -> None: + bundle = two_level_bundle() + room, _clock, store = make_room_with_store(bundle) + room.add_seat("tok-a", "Ada", True) + + with pytest.raises(UnknownLevel): + room.set_level("tok-a", "05_zzz") # still checked before the unlock + with pytest.raises(LevelLocked, match="order 2"): + room.set_level("tok-a", "05b_second") # a fresh session: unlocked 1 + + store.record_result("tok-a", bundle.levels[0], won(bundle.levels[0])) + picked = room.set_level("tok-a", "05b_second") # now unlocked 2 + assert picked.id == "05b_second" + assert record_of(store, "tok-a").unlocked_level == 2 + + +def test_next_level_id_reads_the_store() -> None: + bundle = two_level_bundle() + room, _clock, store = make_room_with_store(bundle) + assert room.next_level_id("tok-a") == "05a_first" # fresh: the floor + store.record_result("tok-a", bundle.levels[0], won(bundle.levels[0])) + assert room.next_level_id("tok-a") == "05b_second" + store.record_result("tok-a", bundle.levels[1], won(bundle.levels[1])) + assert room.next_level_id("tok-a") is None # the campaign is done + + bare = Room( + code="T2", + settings=Settings(), + clock=FakeClock(), + content=bundle, + level_id=bundle.levels[0].id, + ) + assert bare.next_level_id("tok-a") is None # no store: no advice diff --git a/tests/integration/test_supervisor.py b/tests/integration/test_supervisor.py new file mode 100644 index 0000000..b2af310 --- /dev/null +++ b/tests/integration/test_supervisor.py @@ -0,0 +1,475 @@ +"""Integration: the `RoomSupervisor` — create/join/leave, capacity, rejoin, +GC, and teardown, all driven by a `FakeClock` (no test sleeps). + +`RoomSupervisor` is the process-wide registry (PLAN §6): code minting with +collision checks, `max_rooms` / `max_players` capacity, the one-token- +one-room index (a token joining a second room is evicted from the first, +and the rejoin path keeps *disconnected* seats in place), the 5 s GC +(empty rooms past `SEAT_GRACE_S`, idle `LOBBY` rooms past +`lobby_idle_timeout_s`, closed rooms dropped, stale tokens purged), and +`start`/`shutdown` as the only task teardown. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest + +from undercooked.config import Settings +from undercooked.server.clock import FakeClock +from undercooked.server.codes import CROCKFORD +from undercooked.server.room import Room, RoomState +from undercooked.server.rooms import ( + GC_INTERVAL_S, + SEAT_GRACE_S, + PlayerNotInRoom, + RoomCapacityError, + RoomClosed, + RoomFull, + RoomNotFound, + RoomSupervisor, +) +from undercooked.server.sessions import new_session +from undercooked.sim.content import ContentBundle +from undercooked.sim.recipe import RecipeBook + +pytestmark = pytest.mark.integration + + +def empty_bundle() -> ContentBundle: + """A legal empty campaign: the supervisor's tests never need real levels.""" + return ContentBundle( + recipe_book=RecipeBook(recipes={}, ingredients=[]), + levels=(), + ) + + +def make_supervisor( + **settings_overrides: Any, +) -> tuple[RoomSupervisor, FakeClock, ContentBundle]: + """A supervisor on a `FakeClock`, ready to be driven by hand.""" + settings = Settings(**settings_overrides) + clock = FakeClock() + bundle = empty_bundle() + supervisor = RoomSupervisor(settings, clock, bundle) + return supervisor, clock, bundle + + +def advancing_sleep(clock: FakeClock) -> Callable[[float], Awaitable[None]]: + """A sleep that advances the `FakeClock` instead of waiting in real time.""" + + async def sleep(seconds: float) -> None: + clock.advance(seconds) + await asyncio.sleep(0) + + return sleep + + +# ---------------------------------------------------------------- create / join + + +async def test_create_mints_a_code_and_seats_the_creator_as_host() -> None: + supervisor, clock, _ = make_supervisor() + session = new_session("Ada", clock) + room = supervisor.create_room(session, "Ada") + + assert len(room.code) == 5 + assert set(room.code) <= set(CROCKFORD) + assert room.state is RoomState.LOBBY + assert room.level_id is None # the empty campaign has no default level + seat = room.seats[0] + assert seat.token == session.token + assert seat.host and seat.seat_index == 0 and seat.connected + assert seat.display_name == "Ada" + assert room.created_at == clock.now() + + assert supervisor.get(room.code) is room + assert supervisor.get(f" {room.code.lower()}-") is room # normalized lookup + assert supervisor.by_token(session.token) is room + + +async def test_create_uses_the_session_name_when_no_name_is_typed() -> None: + supervisor, clock, _ = make_supervisor() + session = new_session("Ada", clock) + room = supervisor.create_room(session, None) + assert room.seats[0].display_name == "Ada" + anonymous = new_session(None, clock) + room = supervisor.create_room(anonymous, None) + assert room.seats[0].display_name == "Cook #1" + + +async def test_create_evicts_a_token_from_its_previous_room() -> None: + """One token, one room: the creator's old seat is dropped first.""" + supervisor, clock, _ = make_supervisor() + session = new_session("Ada", clock) + first = supervisor.create_room(session, "Ada") + supervisor.create_room(new_session("Bob", clock), "Bob") + second = supervisor.create_room(session, "Ada") + + assert first.seats == [] + assert first.empty_since == clock.now() + assert supervisor.by_token(session.token) is second + + +async def test_join_seats_the_player_and_normalizes_the_code() -> None: + supervisor, clock, _ = make_supervisor() + room = supervisor.create_room(new_session("Ada", clock), "Ada") + bob = new_session("Bob", clock) + + joined = supervisor.join(f" {room.code.lower()}-", bob.token, "Bob") + + assert joined is room + seat = room.seat_for_token(bob.token) + assert seat is not None + assert seat.seat_index == 1 and not seat.host + assert seat.display_name == "Bob" + assert supervisor.by_token(bob.token) is room + assert room.last_activity == clock.now() + + +async def test_join_unknown_and_malformed_codes_are_indistinguishable() -> None: + """US-02 criterion 4: a bad format must fail exactly like an unknown code, + so the supervisor (and the API above it) is not a room-existence oracle.""" + supervisor, _, _ = make_supervisor() + for bad in ("ZZZZZ", "!!!", "", "ABC", "ABCDEF", "ABCDE?", "AB.CD"): + with pytest.raises(RoomNotFound): + supervisor.join(bad, "token", "x") + assert supervisor.get("ZZZZZ") is supervisor.get("!!!") is None + + +async def test_join_a_full_room_raises_room_full() -> None: + supervisor, clock, _ = make_supervisor(max_players=2) + room = supervisor.create_room(new_session("Ada", clock), "Ada") + supervisor.join(room.code, new_session("Bob", clock).token, "Bob") + + with pytest.raises(RoomFull, match="full"): + supervisor.join(room.code, new_session("Cy", clock).token, "Cy") + assert len(room.seats) == 2 + + +async def test_create_at_max_rooms_raises_room_capacity_error() -> None: + supervisor, clock, _ = make_supervisor(max_rooms=3) + for name in ("Ada", "Bob", "Cy"): + supervisor.create_room(new_session(name, clock), name) + + with pytest.raises(RoomCapacityError, match="capacity"): + supervisor.create_room(new_session("Dee", clock), "Dee") + assert supervisor.stats()["rooms"] == 3 + + +# ------------------------------------------------------------------ leave / rejoin + + +async def test_leave_removes_the_seat_and_starts_the_grace_clock() -> None: + supervisor, clock, _ = make_supervisor() + ada = new_session("Ada", clock) + room = supervisor.create_room(ada, "Ada") + bob = new_session("Bob", clock) + supervisor.join(room.code, bob.token, "Bob") + + supervisor.leave(room.code, bob.token) + assert not room.is_empty and room.empty_since is None + assert supervisor.by_token(bob.token) is None + + supervisor.leave(room.code, ada.token) + assert room.is_empty and room.empty_since == clock.now() + assert supervisor.stats()["tokens"] == 0 + + +async def test_leave_by_a_stranger_raises_player_not_in_room() -> None: + supervisor, clock, _ = make_supervisor() + room = supervisor.create_room(new_session("Ada", clock), "Ada") + with pytest.raises(PlayerNotInRoom): + supervisor.leave(room.code, "stranger") + + +async def test_mark_disconnected_keeps_the_seat_for_the_rejoin_path() -> None: + supervisor, clock, _ = make_supervisor() + room = supervisor.create_room(new_session("Ada", clock), "Ada") + supervisor.mark_disconnected(room.code, room.seats[0].token) + + seat = room.seats[0] + assert seat.connected is False + assert seat.last_seen == clock.now() # the seat is kept, not removed + + +async def test_mark_disconnected_by_a_stranger_raises() -> None: + supervisor, clock, _ = make_supervisor() + room = supervisor.create_room(new_session("Ada", clock), "Ada") + with pytest.raises(PlayerNotInRoom): + supervisor.mark_disconnected(room.code, "stranger") + + +async def test_rejoin_reclaims_a_disconnected_seat() -> None: + supervisor, clock, _ = make_supervisor() + room = supervisor.create_room(new_session("Ada", clock), "Ada") + bob = new_session("Bob", clock) + supervisor.join(room.code, bob.token, "Bob") + supervisor.mark_disconnected(room.code, bob.token) + + clock.advance(10.0) + again = supervisor.join(room.code, bob.token, None) + + assert again is room + seat = room.seat_for_token(bob.token) + assert seat is not None + assert seat.connected is True + assert seat.seat_index == 1 # the same seat, not a second one + assert seat.last_seen == 10.0 + assert supervisor.stats()["seats"] == 2 + + +async def test_join_moves_a_token_between_rooms() -> None: + """One token, one room: joining room B evicts the seat in room A.""" + supervisor, clock, _ = make_supervisor() + room_a = supervisor.create_room(new_session("Ada", clock), "Ada") + room_b = supervisor.create_room(new_session("Bob", clock), "Bob") + ada = room_a.seats[0].token + + supervisor.join(room_b.code, ada, None) + + assert room_a.seats == [] + assert room_a.empty_since == clock.now() + assert supervisor.by_token(ada) is room_b + assert room_b.seat_for_token(ada) is not None + + +# ------------------------------------------------------------- stats / queries + + +async def test_stats_and_all_rooms_are_a_consistent_census() -> None: + supervisor, clock, _ = make_supervisor(max_rooms=7) + room_a = supervisor.create_room(new_session("Ada", clock), "Ada") + room_b = supervisor.create_room(new_session("Bob", clock), "Bob") + supervisor.join(room_a.code, new_session("Cy", clock).token, "Cy") + supervisor.mark_disconnected(room_a.code, room_a.seats[0].token) + + assert supervisor.stats() == { + "rooms": 2, + "max_rooms": 7, + "seats": 3, + "states": {"lobby": 2}, + "tokens": 3, + "spectators": 0, + } + assert [room.code for room in supervisor.all_rooms()] == sorted( + [room_a.code, room_b.code] + ) + + +async def test_close_room_keeps_the_room_visible_until_the_next_gc_pass() -> None: + supervisor, clock, _ = make_supervisor() + room = supervisor.create_room(new_session("Ada", clock), "Ada") + + await supervisor.close_room(room.code) + + assert room.state is RoomState.CLOSED + assert room.closed_at == clock.now() + with pytest.raises(RoomClosed): + supervisor.join(room.code, new_session("Bob", clock).token, "Bob") + + await supervisor.close_room(room.code) # idempotent + assert await supervisor.gc_pass() == 0 # the pass drops it, closes nothing + assert supervisor.get(room.code) is None + with pytest.raises(RoomNotFound): + supervisor.join(room.code, new_session("Bob", clock).token, "Bob") + + +# ------------------------------------------------------------------ lifecycle + + +async def test_room_close_cancels_its_tick_task_and_is_idempotent() -> None: + clock = FakeClock() + room = Room( + code="TEST1", settings=Settings(), clock=clock, sleep=advancing_sleep(clock) + ) + room.start_ticking() + first_task = room.task + assert first_task is not None + room.start_ticking() # idempotent while the task runs + assert room.task is first_task + + await room.close() + assert room.state is RoomState.CLOSED + assert room.task is not None + assert room.task.cancelled() + assert room.seats == [] + await room.close() # idempotent + assert room.closed_at is not None + + +async def test_tick_once_in_a_fresh_occupied_lobby_moves_nothing() -> None: + """Task 02: `tick_once` is a real tick (the FSM body, crash-isolated), + but a fresh room with a seat and no idle past a threshold stays + `LOBBY` — the idle rules are the only things a `LOBBY` tick does.""" + clock = FakeClock() + room = Room(code="TEST1", settings=Settings(), clock=clock) + room.add_seat("tok-a", "Ada", True) + room.tick_once() + room.tick_once(now=1.0) # 1 s < the 900 s lobby idle timeout + assert room.state is RoomState.LOBBY # nothing moved + + +async def test_shutdown_cancels_the_gc_loop_and_every_room_task() -> None: + clock = FakeClock() + supervisor = RoomSupervisor( + Settings(), clock, empty_bundle(), sleep=advancing_sleep(clock) + ) + await supervisor.start() + assert supervisor.gc_task is not None and not supervisor.gc_task.done() + + room = supervisor.create_room(new_session("Ada", clock), "Ada") + room.start_ticking() + assert room.task is not None and not room.task.done() + + await supervisor.shutdown() + + assert room.state is RoomState.CLOSED + assert room.task is not None + assert room.task.cancelled() + assert supervisor.gc_task is None + assert supervisor.stats()["rooms"] == 0 + assert all(room_.state is RoomState.CLOSED for room_ in supervisor.all_rooms()) + + +async def test_start_is_idempotent_and_a_second_start_restarts_the_gc_loop() -> None: + clock = FakeClock() + supervisor = RoomSupervisor( + Settings(), clock, empty_bundle(), sleep=advancing_sleep(clock) + ) + await supervisor.start() + first = supervisor.gc_task + assert first is not None + await supervisor.start() + assert supervisor.gc_task is first # not restarted while it runs + await supervisor.shutdown() + await supervisor.start() + assert supervisor.gc_task is not first + assert supervisor.gc_task is not None + + +# --------------------------------------------------------------------------- GC + + +async def test_gc_closes_exactly_what_it_should() -> None: + """The orphan rule, at the default 900 s idle timeout: the 30 s grace is + the only clock that can fire, and it must fire on the empty room alone.""" + supervisor, clock, _ = make_supervisor() + # A: empties at t=0 (grace clock starts then) + ada = new_session("Ada", clock) + room_a = supervisor.create_room(ada, "Ada") + bob = new_session("Bob", clock) + supervisor.join(room_a.code, bob.token, "Bob") + supervisor.leave(room_a.code, bob.token) + supervisor.leave(room_a.code, ada.token) + assert room_a.empty_since == 0.0 + # C: a lobby with a seat, activity at t=0 (idle 900 s — survives) + supervisor.create_room(new_session("Cy", clock), "Cy") + # E: a PLAYING room — the idle-lobby rule must never touch it + room_e = supervisor.create_room(new_session("Eve", clock), "Eve") + room_e.state = RoomState.PLAYING + + assert await supervisor.gc_pass() == 0 # t=0: nothing is past a threshold + clock.advance(5.0) + assert await supervisor.gc_pass() == 0 # t=5: 5 < 30 grace + clock.advance(20.0) # t=25 + assert await supervisor.gc_pass() == 0 # 25 < 30 grace: still no + + clock.advance(5.5) # t=30.5 + assert await supervisor.gc_pass() == 1 # A only: grace 30.5 > 30 + assert supervisor.get(room_a.code) is None + assert supervisor.get(room_e.code) is not None + assert supervisor.stats() == { + "rooms": 2, + "max_rooms": 200, + "seats": 2, + "states": {"lobby": 1, "playing": 1}, + "tokens": 2, + "spectators": 0, + } + assert SEAT_GRACE_S == 30.0 # the constant the test measures against + + +async def test_gc_closes_idle_lobbies_past_the_timeout() -> None: + """The idle-lobby rule: a `LOBBY` room with no activity for longer than + `lobby_idle_timeout_s` is closed — an empty room stays `LOBBY`, so the + idle rule reaps it too (the grace rule is the *other* way an empty + room dies, covered above).""" + supervisor, clock, _ = make_supervisor(lobby_idle_timeout_s=10.0) + # A: empties at t=0 (stays LOBBY, last activity t=0) + ada = new_session("Ada", clock) + room_a = supervisor.create_room(ada, "Ada") + supervisor.leave(room_a.code, ada.token) + # C: an idle lobby with a seat (last activity t=0) + room_c = supervisor.create_room(new_session("Cy", clock), "Cy") + # D: a lobby with recent activity (t=1) + room_d = supervisor.create_room(new_session("Dee", clock), "Dee") + room_d.note_activity(1.0) + # E: a PLAYING room — the idle-lobby rule must never touch it + room_e = supervisor.create_room(new_session("Eve", clock), "Eve") + room_e.state = RoomState.PLAYING + + assert await supervisor.gc_pass() == 0 # t=0: nothing is past 10 s + clock.advance(10.5) + assert await supervisor.gc_pass() == 2 # A and C: idle 10.5 > 10 + assert supervisor.get(room_a.code) is None + assert supervisor.get(room_c.code) is None + assert supervisor.get(room_d.code) is not None # idle 9.5 < 10 + assert supervisor.get(room_e.code) is not None # not a LOBBY + + clock.advance(20.0) # t=30.5 + assert await supervisor.gc_pass() == 1 # D: idle 29.5 > 10 + assert supervisor.get(room_d.code) is None + assert supervisor.get(room_e.code) is not None + assert supervisor.stats()["rooms"] == 1 + + +async def test_gc_purges_stale_token_entries_but_keeps_disconnected_ones() -> None: + supervisor, clock, _ = make_supervisor() + ada = new_session("Ada", clock) + room = supervisor.create_room(ada, "Ada") + bob = new_session("Bob", clock) + supervisor.join(room.code, bob.token, "Bob") + supervisor.mark_disconnected(room.code, ada.token) # Ada's seat is kept + + await supervisor.close_room(room.code) + assert supervisor.by_token(bob.token) is room # registry still holds it + assert supervisor.by_token(ada.token) is room + + clock.advance(1.0) + await supervisor.gc_pass() # drops the closed room, purges both tokens + assert supervisor.get(room.code) is None + assert supervisor.by_token(bob.token) is None + assert supervisor.by_token(ada.token) is None + assert supervisor.stats()["tokens"] == 0 + + # a merely disconnected seat in a *live* room keeps its index entry + live = supervisor.create_room(new_session("Cy", clock), "Cy") + supervisor.mark_disconnected(live.code, live.seats[0].token) + clock.advance(5.0) + await supervisor.gc_pass() + assert supervisor.by_token(live.seats[0].token) is live + + +async def test_gc_loop_runs_on_the_injected_cadence_and_is_cancellable() -> None: + clock = FakeClock() + loop_task: dict[asyncio.Task[None], None] = {} + + async def stopper_sleep(seconds: float) -> None: + assert seconds == GC_INTERVAL_S + clock.advance(seconds) + if clock.now() >= 3 * GC_INTERVAL_S: + next(iter(loop_task)).cancel() + await asyncio.sleep(0) + + supervisor = RoomSupervisor( + Settings(), clock, empty_bundle(), sleep=stopper_sleep + ) + loop_task[asyncio.create_task(supervisor.gc_loop())] = None + + with pytest.raises(asyncio.CancelledError): + await next(iter(loop_task)) + assert clock.now() == 3 * GC_INTERVAL_S # exactly three 5-second cycles diff --git a/tests/unit/test_clock.py b/tests/unit/test_clock.py new file mode 100644 index 0000000..de1bd98 --- /dev/null +++ b/tests/unit/test_clock.py @@ -0,0 +1,57 @@ +"""Unit: `Clock`, `SystemClock` and `FakeClock` — the injectable time contract. + +`FakeClock` is the test clock every time-dependent server object is driven +with (phase 05: "no test sleeps — all time via `FakeClock`"); these tests +lock its behaviour so that contract is honest. +""" + +from __future__ import annotations + +import pytest + +from undercooked.server.clock import Clock, FakeClock, SystemClock + +pytestmark = pytest.mark.unit + + +def test_system_clock_reads_a_monotonic_clock() -> None: + clock = SystemClock() + first = clock.now() + second = clock.now() + assert second >= first + + +def test_system_clock_accepts_a_substitute_time_function() -> None: + values = iter([1.0, 2.5, 7.25]) + clock = SystemClock(lambda: next(values)) + assert clock.now() == 1.0 + assert clock.now() == 2.5 + assert clock.now() == 7.25 + + +def test_fake_clock_starts_at_the_given_instant() -> None: + assert FakeClock().now() == 0.0 + assert FakeClock(start=12.5).now() == 12.5 + + +def test_fake_clock_advances_and_reports_the_new_instant() -> None: + clock = FakeClock(start=10.0) + assert clock.advance(5.0) == 15.0 + assert clock.now() == 15.0 + assert clock.advance(0.5) == 15.5 + assert clock.advance(0) == 15.5 + + +def test_fake_clock_never_goes_backwards() -> None: + clock = FakeClock() + with pytest.raises(ValueError, match="cannot go backwards"): + clock.advance(-1.0) + assert clock.now() == 0.0 + + +def test_both_clocks_satisfy_the_protocol() -> None: + """The `Clock` protocol is a structural contract: both implementations + must be usable anywhere a `Clock` is demanded.""" + clocks: list[Clock] = [SystemClock(), FakeClock()] + for clock in clocks: + assert isinstance(clock.now(), float) diff --git a/tests/unit/test_codes.py b/tests/unit/test_codes.py new file mode 100644 index 0000000..521abbf --- /dev/null +++ b/tests/unit/test_codes.py @@ -0,0 +1,194 @@ +"""Unit: the Crockford code alphabet, minting and input normalisation (A15). + +`random.Random` seeds are deliberate: the minting contract is *determinism +given a seed*, so the suite re-plays the same draws. The production path +(`rng=None`) is exercised for its shape, not its bytes. +""" + +from __future__ import annotations + +import random + +import pytest + +from undercooked.server.codes import ( + CODE_LEN, + CROCKFORD, + MAX_MINT_RETRIES, + CodeSpaceFull, + new_code, + normalize_code, +) + +pytestmark = pytest.mark.unit + +EXPECTED_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + + +# ------------------------------------------------------------------------ alphabet + + +def test_the_alphabet_is_crockford_base32_excluding_ilo_u() -> None: + assert len(CROCKFORD) == 32 + assert set(CROCKFORD) == set(EXPECTED_ALPHABET) + assert len(set(CROCKFORD)) == 32 # no duplicate symbols + for excluded in "ILOU": + assert excluded not in CROCKFORD, f"{excluded} must be excluded" + for digit in "0123456789": + assert digit in CROCKFORD + + +def test_codes_are_five_characters_long() -> None: + assert CODE_LEN == 5 + + +# ------------------------------------------------------------------------ minting + + +def test_twenty_thousand_minted_codes_are_unique_and_in_the_alphabet() -> None: + taken: set[str] = set() + rng = random.Random(0x5EED_0401) # noqa: S311 - seeded, deliberate test draws + for _ in range(20_000): + code = new_code(taken, rng) + taken.add(code) + assert len(taken) == 20_000 + for code in taken: + assert len(code) == CODE_LEN + assert set(code) <= set(CROCKFORD) + sample = min(taken) + assert "I" not in sample and "L" not in sample and "O" not in sample and "U" not in sample + + +def test_new_code_retries_a_collision_until_the_draw_is_free() -> None: + rng = random.Random(7) # noqa: S311 - seeded, deliberate test draws + first = new_code(set(), rng) + second = new_code({first}, rng) + assert second != first + assert second not in {first} + assert len(second) == CODE_LEN + + +def test_a_deterministic_seed_mints_a_deterministic_first_code() -> None: + """The same seed must mint the same first code — the A4/A5 determinism + contract, applied to the minting draws.""" + a = new_code(set(), random.Random(42)) # noqa: S311 - seeded, deliberate + b = new_code(set(), random.Random(42)) # noqa: S311 - seeded, deliberate + assert a == b + assert len(a) == CODE_LEN + assert set(a) <= set(CROCKFORD) + + +class _AlwaysTaken: + def __contains__(self, _code: str) -> bool: + return True + + +class _CountingRandom: + """A `random.Random` lookalike that counts draws and always collides.""" + + def __init__(self) -> None: + self.choice_calls = 0 + + def choice(self, _sequence: str) -> str: + self.choice_calls += 1 + return "A" + + +def test_a_full_space_raises_code_space_full_after_exactly_100_draws() -> None: + rng = _CountingRandom() + with pytest.raises(CodeSpaceFull): + new_code(_AlwaysTaken(), rng) # type: ignore[arg-type] - duck-typed lookalike + assert rng.choice_calls == MAX_MINT_RETRIES * CODE_LEN + + +def test_code_space_full_names_the_retries_in_its_message() -> None: + with pytest.raises(CodeSpaceFull, match="100 draws all collided"): + new_code(_AlwaysTaken()) + + +def test_production_minting_without_an_rng_yields_a_legal_code() -> None: + code = new_code(set()) # secrets.choice path + assert len(code) == CODE_LEN + assert set(code) <= set(CROCKFORD) + + +# ------------------------------------------------------------------------ normalisation + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("ABCDE", "ABCDE"), + ("abcde", "ABCDE"), + ("aBcDe", "ABCDE"), + ("a b c d e", "ABCDE"), + ("A B-C D-E", "ABCDE"), + (" AB\tCD E ", "ABCDE"), + ("12345", "12345"), + ("99999", "99999"), + ("VWXYZ", "VWXYZ"), + ], +) +def test_normalize_accepts_legal_codes_with_case_and_separators( + raw: str, expected: str +) -> None: + assert normalize_code(raw) == expected + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("i2345", "12345"), + ("I2345", "12345"), + ("l2345", "12345"), + ("L2345", "12345"), + ("o2345", "02345"), + ("O2345", "02345"), + ("u2345", "V2345"), + ("U2345", "V2345"), + ], +) +def test_normalize_maps_every_excluded_confusable_to_its_intended_symbol( + raw: str, expected: str +) -> None: + assert normalize_code(raw) == expected + + +@pytest.mark.parametrize( + "raw", + [ + "", + "A", + "AB", + "ABC", # too short + "ABCDEF", + "ABCDEFG", # too long + "AB12CD\n", # six characters after the newline is stripped + "ABC?E", # the question mark is not an alphabet character + "AB.CD", # the dot is not a separator: it stays, and it is invalid + "AB-CD", # the dash is stripped, leaving four characters + "I", # maps to `1`, which is still one character, not five + "U", # maps to `V`, still one character + "O", # maps to `0`, still one character + "L", # maps to `1`, still one character + "?????", + ], +) +def test_normalize_rejects_wrong_lengths_and_invalid_characters(raw: str) -> None: + assert normalize_code(raw) is None + + +@pytest.mark.parametrize("raw", ["ABCDE\n", "ABCDE ", "ABC DE"]) +def test_normalize_strips_whitespace_without_making_the_code_illegal(raw: str) -> None: + """Whitespace is stripped, not fatal: `ABCDE\n` and `ABCDE ` are still + five-character codes, and `ABC DE` normalises to `ABCDE`.""" + assert normalize_code(raw) == "ABCDE" + + +def test_normalize_is_a_pure_format_check_never_a_registry_query() -> None: + """The US-02 criterion-4 contract: the result depends on the input + alone, so a caller cannot tell "malformed" from "no such room".""" + for _ in range(5): + assert normalize_code("i2345") == "12345" + assert normalize_code("nope!") is None + assert normalize_code("ABCDE") == normalize_code("ABCDE") diff --git a/tests/unit/test_docs_index.py b/tests/unit/test_docs_index.py index 48e6601..9d8ca60 100644 --- a/tests/unit/test_docs_index.py +++ b/tests/unit/test_docs_index.py @@ -210,6 +210,7 @@ def test_the_docs_on_disk_are_the_shipped_documents() -> None: "docs/DESIGN.md", "docs/DEPLOYMENT.md", "docs/LEVELS.md", + "docs/ROOMS.md", "docs/SIMULATION.md", "docs/TESTING.md", } diff --git a/tests/unit/test_sanitize_name.py b/tests/unit/test_sanitize_name.py new file mode 100644 index 0000000..63fd7c7 --- /dev/null +++ b/tests/unit/test_sanitize_name.py @@ -0,0 +1,103 @@ +"""Unit: the display-name boundary (phase 05, task 03; US-02 criterion 6). + +`sanitize_name` is the one place a player-typed name becomes a stored name +(`server/seats.py`): strip Unicode control and format characters (zero-width +marks, RTL overrides, soft hyphen) and `<>"'`, collapse runs of whitespace, +trim, cap at 16; an empty result falls back to `Cook #{seat_index + 1}`. +Names are non-unique by design and are rendered by the client as text +(`textContent`, never HTML) — this table pins the shape of that boundary: +the output is always `len <= 16`, never contains `<`, `>` or `"`, and an +empty name names the seat. +""" + +from __future__ import annotations + +import pytest + +from undercooked.server.rooms import sanitize_name as rooms_sanitize +from undercooked.server.seats import NAME_MAX_LEN, sanitize_name + +pytestmark = pytest.mark.unit + + +def test_the_table_of_raw_to_stored_names() -> None: + """The boundary, case by case: fallbacks, control/format stripping, + whitespace collapsing, the 16-char cap, and the non-ASCII keeps.""" + cases: list[tuple[str | None, int, str]] = [ + # The fallback: no name (or an empty one) names the seat. + (None, 0, "Cook #1"), + (None, 2, "Cook #3"), + ("", 0, "Cook #1"), + (" \t\n ", 1, "Cook #2"), + # Plain names pass (whitespace runs collapse to one space). + ("Ada", 0, "Ada"), + ("Tom Jerry", 0, "Tom Jerry"), + (" Hello World ", 0, "Hello World"), + ("Ünïcödé ☂", 0, "Ünïcödé ☂"), + # Control characters (Cc) are stripped, not collapsed: the newline + # between two letters disappears. + ("A\nB\x00C\x07D", 0, "ABCD"), + ("\tMi la \t", 0, "Mi la"), + # Markup (the angle/quote pair) is stripped — the `textContent` + # backstop. + ("O'Brien", 0, "OBrien"), + ('"quoted"', 0, "quoted"), + ("", 0, "scriptalert(1)/s"), + # The cap: 16 chars, trimmed after the cut. + ("A" * 40, 0, "A" * 16), + ("x" * 1000, 0, "x" * 16), + ("a" * 15 + " b", 0, "a" * 15), + ("a b " * 20, 0, "a b a b a b a b"), + ("a" * 15 + "b", 0, "a" * 15 + "b"), + # Unicode format characters (Cf) are stripped: zero-width, RTL + # override, soft hyphen — a zero-width-only name is a fallback. + ("\u202bHELLO", 0, "HELLO"), + ("\u200b\u200b\u200b", 0, "Cook #1"), + ("\u200bA\u200bB", 0, "AB"), + ("Mi\u00adna", 0, "Mina"), + # Emoji and accents are kept (So / Ll), and the cap counts them. + ("🍳🍅🧅🐟", 0, "🍳🍅🧅🐟"), + ("🍳" * 20, 0, "🍳" * 16), + ("Café ☕ ", 0, "Café ☕"), + ] + for raw, seat_index, expected in cases: + assert sanitize_name(raw, seat_index) == expected, (raw, seat_index) + assert NAME_MAX_LEN == 16 + + +def test_the_output_is_bounded_and_markup_free() -> None: + """The shape contract, property-style, over the nasty inputs: the + output is always `len <= 16` and never carries `<`, `>` or `"` — + whatever the input tried to do.""" + nasty = [ + "", + "<<<>>>>>>>", + '"', + "A" * 1000, + (" " * 500) + "x" * 500, + "\u200b" * 100, + "\u202b\u202c\u202d\u202e\u2066\u2067" + "Name" * 100, + "Mi\u00adna\u00ad\u00ad" * 10, + "<'\"\\>\x00\x01\x1f\x7f", + "é" * 500, + ] + for raw in nasty: + out = sanitize_name(raw, 3) + assert len(out) <= 16, (raw[:40], out) + assert not {"<", ">", '"'} & set(out), (raw[:40], out) + + +def test_the_fallback_names_the_seat() -> None: + """An empty (or control-only) name is `Cook #{seat_index + 1}` — the + client can always address the cook.""" + assert sanitize_name(None, 0) == "Cook #1" + assert sanitize_name(None, 7) == "Cook #8" + assert sanitize_name(" \x00\t ", 4) == "Cook #5" + assert sanitize_name("\u200b\u202b", 9) == "Cook #10" + + +def test_rooms_reexports_the_same_boundary() -> None: + """`server/rooms.py` keeps its task-01 export: the supervisor's + boundary and the seats' boundary are the one function (imported, not + duplicated).""" + assert rooms_sanitize is sanitize_name diff --git a/tests/unit/test_server_clock_discipline.py b/tests/unit/test_server_clock_discipline.py new file mode 100644 index 0000000..dc70635 --- /dev/null +++ b/tests/unit/test_server_clock_discipline.py @@ -0,0 +1,60 @@ +"""Unit (grep-style): no wall-clock calls anywhere in `server/`. + +The phase-05 contract (task 01, work item 1): every time-dependent server +object receives a `Clock`; the *only* file in `src/undercooked/server/` +allowed to import `time` is `clock.py`, and even there the clock function +is held as a reference — `time.monotonic()` / `time.time()` are never +called in server code. This test greps for the literals (and for stray +`time` imports) so a future edit cannot reintroduce a bare wall-clock call +without failing the suite. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.unit + +REPO_ROOT = Path(__file__).resolve().parents[2] +SERVER_DIR = REPO_ROOT / "src" / "undercooked" / "server" + +#: The wall-clock calls the contract forbids in server code. +FORBIDDEN_CALLS = ("time.monotonic()", "time.time()") + +#: `time` may be imported by exactly one module. +TIME_IMPORT = re.compile(r"^\s*(?:import time\b|from time import\b)", re.MULTILINE) + + +def test_no_wall_clock_call_lives_in_server_code() -> None: + offenders = [ + f"{path.name}: {literal}" + for path in sorted(SERVER_DIR.rglob("*.py")) + for literal in FORBIDDEN_CALLS + if literal in path.read_text(encoding="utf-8") + ] + assert offenders == [], "wall-clock calls leaked into server/:\n " + "\n ".join( + offenders + ) + + +def test_only_the_clock_module_imports_time() -> None: + offenders = [ + path.name + for path in sorted(SERVER_DIR.rglob("*.py")) + if path.name != "clock.py" and TIME_IMPORT.search(path.read_text(encoding="utf-8")) + ] + assert offenders == [], ( + f"modules that import `time` outside clock.py: {', '.join(offenders)}" + ) + + +def test_the_guard_is_not_vacuous() -> None: + """A guard that never fires is no guard: prove both checks detect a + planted violation in this very file's patterns.""" + assert FORBIDDEN_CALLS[0] in "x = time.monotonic()\n" + assert TIME_IMPORT.search("import time\n") is not None + assert SERVER_DIR.is_dir() + assert (SERVER_DIR / "clock.py").is_file() diff --git a/tests/unit/test_session_minting.py b/tests/unit/test_session_minting.py new file mode 100644 index 0000000..6eae150 --- /dev/null +++ b/tests/unit/test_session_minting.py @@ -0,0 +1,45 @@ +"""Unit: session token minting and `SessionRecord` (A16). + +The token is the player's only identity: 24 random bytes, urlsafe +base64 — the same entropy the browser side generates with +`crypto.getRandomValues`. These tests lock the shape that +`SessionStore` (task 04) builds on. +""" + +from __future__ import annotations + +import string + +import pytest + +from undercooked.server.clock import FakeClock +from undercooked.server.sessions import mint_token, new_session + +pytestmark = pytest.mark.unit + +_URLSAFE = set(string.ascii_letters + string.digits + "-_") + + +def test_mint_token_is_24_random_bytes_of_urlsafe_base64() -> None: + token = mint_token() + assert len(token) == 32 # 24 bytes -> 32 base64url characters + assert set(token) <= _URLSAFE + assert "+" not in token and "/" not in token and "=" not in token + + +def test_minted_tokens_are_distinct() -> None: + tokens = {mint_token() for _ in range(100)} + assert len(tokens) == 100 + + +def test_new_session_mints_a_token_and_stamps_the_injected_clock() -> None: + clock = FakeClock(start=42.0) + session = new_session("Ada", clock) + assert len(session.token) == 32 + assert session.display_name == "Ada" + assert session.created_at == 42.0 + + anonymous = new_session(None, clock) + assert anonymous.display_name is None + assert anonymous.token != session.token + assert anonymous.created_at == 42.0 diff --git a/tests/unit/test_session_privacy.py b/tests/unit/test_session_privacy.py new file mode 100644 index 0000000..bd57e6c --- /dev/null +++ b/tests/unit/test_session_privacy.py @@ -0,0 +1,144 @@ +"""Unit: the session's privacy surface (phase 05, task 04, US-07). + +The store is anonymous by design (A16): the identity is a 24-byte +random token and *nothing else*. These tests lock the surface so a PII +field can never join without a test failing first: + +* **the `SessionRecord` field set** — exactly the task's nine fields + (identity, name, progression, counters, stamps), +* **a `repr()` scan** — a representative record (and a representative + store) never carries `ip`, `user_agent`, `authorization` or `cookie` + (case-insensitive), +* **`SessionStore` has no attribute holding raw headers** — neither the + record's fields nor the store's attributes name a `header` of any + kind, and no dict-valued store attribute holds a header-like key. +""" + +from __future__ import annotations + +import dataclasses + +import pytest + +from undercooked.server.clock import FakeClock +from undercooked.server.sessions import BestRun, SessionRecord, SessionStore + +pytestmark = pytest.mark.unit + +#: The only fields a session record may hold (task 04's list). +ALLOWED_RECORD_FIELDS = frozenset( + { + "token", + "display_name", + "created_at", + "unlocked_level", + "best", + "rounds_played", + "dishes_served", + "last_seen", + "endless_best", + } +) + +#: The strings a session's repr must never contain (the PII markers). +FORBIDDEN_REPR_SUBSTRINGS = ("ip", "user_agent", "authorization", "cookie") + +#: A request-header key that must never appear in the store's own data. +HEADER_LIKE_KEYS = frozenset( + {"host", "user-agent", "authorization", "cookie", "x-forwarded-for"} +) + + +def _benign_record() -> SessionRecord: + """A representative record with fixed, benign values (a random + base64url token could incidentally contain a marker; the test needs + a *guaranteed* shape, so the token is fixed too).""" + return SessionRecord( + token="t" * 32, + display_name="Ada", + created_at=1.0, + unlocked_level=2, + best={"01_kitchen": BestRun(score=100, stars=3, completed_at=2.0)}, + rounds_played=4, + dishes_served=9, + last_seen=3.0, + endless_best=None, + ) + + +def _benign_store() -> SessionStore: + """A representative store: a `FakeClock`, one campaign entry, and one + record inside (so a walk of its data covers the record's shape).""" + store = SessionStore( + max_entries=10, clock=FakeClock(start=1.0), campaign={"01_kitchen": 1} + ) + record = _benign_record() + store.put(record) + return store + + +def test_the_record_field_set_is_exactly_the_task_list() -> None: + fields = {field.name for field in dataclasses.fields(SessionRecord)} + assert fields == ALLOWED_RECORD_FIELDS + + +def test_the_record_repr_carrys_no_pii_markers() -> None: + text = repr(_benign_record()).lower() + for marker in FORBIDDEN_REPR_SUBSTRINGS: + assert marker not in text, f"the record's repr carries {marker!r}" + + +def test_the_store_repr_carrys_no_pii_markers() -> None: + text = repr(_benign_store()).lower() + for marker in FORBIDDEN_REPR_SUBSTRINGS: + assert marker not in text, f"the store's repr carries {marker!r}" + + +def test_the_record_and_the_best_run_are_slots_dataclasses() -> None: + """Slots (not a `__dict__` bag) is the shape that keeps the field + set auditable: a new field is a named, visible change.""" + assert hasattr(SessionRecord, "__slots__") + assert hasattr(BestRun, "__slots__") + + +def test_neither_record_nor_store_names_a_header_attribute() -> None: + record_fields = {field.name for field in dataclasses.fields(SessionRecord)} + assert not any("header" in name.lower() for name in record_fields) + store = _benign_store() + assert not any("header" in name.lower() for name in dir(store)) + assert not any("header" in name.lower() for name in vars(store)) + + +def test_the_store_holds_no_request_header_mapping() -> None: + """Every dict-valued attribute of the store is either `token -> + record` or `level id -> order`: neither may hold a header-like key.""" + store = _benign_store() + for name, value in vars(store).items(): + if isinstance(value, dict): + assert not ( + set(value) & HEADER_LIKE_KEYS + ), f"store attribute {name!r} holds header-like keys" + record = _benign_record() + for field in dataclasses.fields(record): + assert field.name.lower() not in HEADER_LIKE_KEYS, f"record field {field.name!r}" + value = getattr(record, field.name) + if isinstance(value, dict): + assert not ( + set(value) & HEADER_LIKE_KEYS + ), f"record field {field.name!r} holds header-like keys" + + +def test_the_store_never_minted_with_headers_keeps_none() -> None: + """A store built the way the supervisor builds it (a clock and the + campaign mapping only) exposes exactly its own five attributes — no + header bag can hide in a constructor that does not take one.""" + store = SessionStore( + clock=FakeClock(), campaign={"01_kitchen": 1} + ) + assert set(vars(store)) == { + "_max_entries", + "_clock", + "_campaign", + "_records", + "_evictions", + }