phase: 05_anonymous_lobbies_and_room_lifecycle

All green. Final report:

**Phase 05 final verification pass — complete, 100% green.**

**Verified (all 5 tasks already in `complete/`):**
- `RoomSupervisor` import, `POST /api/rooms` → 5-char Crockford code (`VBE8Y`), `GET /api/rooms/{code}` → seat list, join/session/404 flows — all live over HTTP
- `docs/ROOMS.md` present with FSM diagram, crash-isolation and "What is lost on restart" sections
- Phase tests all pass: lifecycle 4-seat `WON`→`LOBBY` (7 tests), crash isolation, GC/idle via `FakeClock` (no sleeps), FSM, tick cadence, supervisor, sessions

**Results:**
- `uv run pytest` → **1506 passed, 0 failed**; coverage **98.89%** (>90% floor; `room.py` 99%, `rooms.py` 97%, `sessions.py` 99% — all ≥95% per-module gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run undercooked-levels validate` → content OK; server boots, `/healthz` 200, client at `/` 200
- e2e US-01 suite re-run after fixes: 10 passed

**Documentation — inconsistencies found and fixed:**
- `README.md`: status now lists phases 01–05 complete, next pending `06_realtime_websocket_protocol`; dropped "(phase 05, in progress)"; 3 stale "Not implemented until phase 05." references corrected
- `app/static/js/app.js` + `tests/e2e/test_us01_landing_surface.py`: placeholder string updated in lockstep to "Not implemented until phase 07." (phase 07 task 02 wires the buttons), comments updated to reflect phase-05 completion
- `AGENTS.md`: reviewed in full — §6 already documents all phase-05 modules accurately; no changes needed

**Decisions:** no code behavior changes beyond the user-visible placeholder string; no live-infrastructure touch (only a local loopback server, stopped).

**Next pending phase:** `06_realtime_websocket_protocol`.
This commit is contained in:
2026-10-08 05:17:21 -04:00
parent 79b7f1e4e0
commit f51ba919af
35 changed files with 8391 additions and 32 deletions
+8 -1
View File
@@ -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` |
+93 -15
View File
@@ -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":<live room count>}` 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/<CODE> # inspect it: the seat list, no tokens
curl -fsS -H 'X-Undercooked-Token: <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
+9 -6
View File
@@ -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);
});
}
+465
View File
@@ -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.
+27 -9
View File
@@ -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:
+60
View File
@@ -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
+111
View File
@@ -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
File diff suppressed because it is too large Load Diff
+491
View File
@@ -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]
+252
View File
@@ -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": "<name>"}`**:
`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))
+251
View File
@@ -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
+363
View File
@@ -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
+1 -1
View File
@@ -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,}$")
@@ -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
+815
View File
@@ -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), "<script>alert(1)</script>")
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, "<script>alert(1)</script>")
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
+118
View File
@@ -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()
+695
View File
@@ -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
+312
View File
@@ -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
+443
View File
@@ -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
+282
View File
@@ -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
+755
View File
@@ -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
+475
View File
@@ -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
+57
View File
@@ -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)
+194
View File
@@ -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")
+1
View File
@@ -210,6 +210,7 @@ def test_the_docs_on_disk_are_the_shipped_documents() -> None:
"docs/DESIGN.md",
"docs/DEPLOYMENT.md",
"docs/LEVELS.md",
"docs/ROOMS.md",
"docs/SIMULATION.md",
"docs/TESTING.md",
}
+103
View File
@@ -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"),
("<script>alert(1)</script>", 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 = [
"<script>alert(1)</script>",
"<<<>>>>>>>",
'"',
"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
@@ -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()
+45
View File
@@ -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
+144
View File
@@ -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",
}