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:
@@ -89,6 +89,13 @@
|
|||||||
which is the only writer of `os.environ` (so a uvicorn `--reload` child
|
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`
|
re-reads the same values). `DEBUGPY=1` binds a debugger listener on `0.0.0.0`
|
||||||
— dev-only, never on a public host.
|
— 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`
|
- **No external CDNs or remote assets.** `tests/unit/test_no_external_assets.py`
|
||||||
fails the build on `http://`/`https://`/`//host` references, `@import url(`,
|
fails the build on `http://`/`https://`/`//host` references, `@import url(`,
|
||||||
`srcset`, an absolute-URL `fetch()`/`import`, an absolute-URL `WebSocket`, or
|
`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` |
|
| Serve resolution and scoring: the dish's named rejections (`NO_ORDERS`/`WRONG_RECIPE`/`INCOMPLETE`/`BURNT`/`NO_TARGET`), FIFO order matching, `round(base × quality × streak × time_bonus)` with the capped streak and the deadline share, the miss penalty and streak reset, the per-player contribution (rules: `docs/SIMULATION.md` §"Serve scoring") | `src/undercooked/sim/scoring.py` |
|
||||||
| Level/recipe content | `levels/*.yaml` |
|
| Level/recipe content | `levels/*.yaml` |
|
||||||
| Content schema, ASCII-grid parser, content loader, playability validator (contract: `docs/LEVELS.md`) | `src/undercooked/sim/recipe.py`, `level.py`, `content.py`, `validation.py` |
|
| Content schema, ASCII-grid parser, content loader, playability validator (contract: `docs/LEVELS.md`) | `src/undercooked/sim/recipe.py`, `level.py`, `content.py`, `validation.py` |
|
||||||
| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py`, `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` |
|
| Wire messages + validation | `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md` |
|
||||||
| HTTP app (`/`, `/healthz`, `/static`), lifespan, startup content gate | `src/undercooked/server/app.py` |
|
| 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` |
|
| Content HTTP API (`/api/levels`, `/api/levels/{id}`, `/api/config`) + strict response models | `src/undercooked/server/routes_content.py` |
|
||||||
|
|||||||
@@ -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
|
Phases **01** (runnable service scaffold), **02** (level content, schema and
|
||||||
validation), **03** (the deterministic simulation kernel: fixed-step tick,
|
validation), **03** (the deterministic simulation kernel: fixed-step tick,
|
||||||
movement and collision, the action button, the hands, the replay goldens)
|
movement and collision, the action button, the hands, the replay goldens),
|
||||||
and **04** (the kitchen pipeline: station state machines, the ticket rail,
|
**04** (the kitchen pipeline: station state machines, the ticket rail,
|
||||||
serve scoring, freshness and the dirty loop, the round verdict with stars)
|
serve scoring, freshness and the dirty loop, the round verdict with stars)
|
||||||
are **complete**; the next pending phase is
|
and **05** (anonymous lobbies and room lifecycle: the `RoomSupervisor`,
|
||||||
`05_anonymous_lobbies_and_room_lifecycle`. What
|
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:
|
runs today:
|
||||||
|
|
||||||
- **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
|
- **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
|
||||||
single typed, validated `Settings` object (`DEBUGPY` support included), and
|
single typed, validated `Settings` object (`DEBUGPY` support included), and
|
||||||
`uv run undercooked` starts a FastAPI server that serves the client shell at `/`,
|
`uv run undercooked` starts a FastAPI server that serves the client shell at `/`,
|
||||||
the browser assets from `app/static/`, and `GET /healthz` — which answers
|
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
|
`{"status":"ok","version":"0.1.0","rooms":<live room count>}` from the room
|
||||||
a supervisor) — with the security headers (CSP `default-src 'self'`) on every
|
supervisor (phase 05) — with the security headers (CSP `default-src 'self'`) on
|
||||||
response.
|
every response.
|
||||||
- **The content.** The recipe book and the first two campaign levels live in
|
- **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
|
`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
|
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);
|
byte-stability across two identical runs and across the settle window);
|
||||||
the contract is `docs/SIMULATION.md` §"Round end — the verdict, the
|
the contract is `docs/SIMULATION.md` §"Round end — the verdict, the
|
||||||
settle, the stars".
|
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
|
- **The rules are tests, not promises.** The no-external-asset rule is
|
||||||
`tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is
|
`tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is
|
||||||
`tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by
|
`tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by
|
||||||
`tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
|
`tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
|
||||||
phase pipeline runs after every task.
|
phase pipeline runs after every task.
|
||||||
|
|
||||||
What does not exist yet: the hazards (phase 10), rooms and the
|
What does not exist yet: the hazards (phase 10), the
|
||||||
WebSocket
|
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
|
playable Canvas renderer (07). Execute phases with the `phased-execution` skill
|
||||||
(`auto-phase.sh`); it always takes the lowest-numbered directory still in
|
(`auto-phase.sh`); it always takes the lowest-numbered directory still in
|
||||||
`.agents/phases/todo/`.
|
`.agents/phases/todo/`.
|
||||||
@@ -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
|
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
|
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,
|
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`.
|
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
|
What actually works today is the shell of that plus the server-side room shell:
|
||||||
five uppercase alphanumerics (codes themselves are Crockford base32, and only get issued
|
the page loads, normalises a typed code to five uppercase alphanumerics (codes
|
||||||
once phase 05 runs the room supervisor), and answers "Not implemented until phase 05."
|
themselves are Crockford base32, minted by the room supervisor — `server/rooms.py`,
|
||||||
Movement keys and touch controls are defined by the phase-07/08 client and documented in
|
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.
|
`docs/CLIENT.md` once that phase writes it.
|
||||||
|
|
||||||
## Quickstart (development)
|
## Quickstart (development)
|
||||||
@@ -217,6 +270,9 @@ uv run undercooked --reload # dev reload
|
|||||||
uv run undercooked --levels-dir levels # point at another content dir
|
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/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 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
|
`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,
|
src/undercooked/tools/levels.py the `undercooked-levels` author CLI — validate, list,
|
||||||
show [--grid], new, axes; same loader, same rules, no
|
show [--grid], new, axes; same loader, same rules, no
|
||||||
hard-coded content list (docs/LEVELS.md "Authoring workflow")
|
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`,
|
src/undercooked/server/routes_content.py `/api/levels`, `/api/levels/{id}`, `/api/config`,
|
||||||
plus the startup gate: broken content refuses startup
|
plus the startup gate: broken content refuses startup
|
||||||
src/undercooked/server/headers.py the security-header middleware
|
src/undercooked/server/headers.py the security-header middleware
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Undercooked — client shell entry point (phase 01).
|
// Undercooked — client shell entry point (phase 01).
|
||||||
//
|
//
|
||||||
// There is no game logic here on purpose. Phase 05 replaces the placeholder
|
// There is no game logic here on purpose. Phase 05 shipped the server-side
|
||||||
// button handlers with the lobby flow, phase 06 adds net/ (WebSocket + snapshots),
|
// room shell (supervisor, room FSM, session store); phase 06 adds the WebSocket
|
||||||
// phase 07 adds render/ (Canvas 2D). What this file owns today:
|
// 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 anonymous per-tab session token (PLAN.md A16),
|
||||||
// * the last room code typed in this tab, so a reload can rejoin fast,
|
// * the last room code typed in this tab, so a reload can rejoin fast,
|
||||||
// * mounting the lobby screen and announcing state into #status.
|
// * mounting the lobby screen and announcing state into #status.
|
||||||
@@ -12,7 +13,7 @@
|
|||||||
const TOKEN_KEY = "uc.token";
|
const TOKEN_KEY = "uc.token";
|
||||||
const LAST_CODE_KEY = "uc.lastCode";
|
const LAST_CODE_KEY = "uc.lastCode";
|
||||||
const CODE_LENGTH = 5;
|
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.";
|
const READY_MESSAGE = "Pick a kitchen to start.";
|
||||||
|
|
||||||
/** Base64url without padding, so the token is safe in a query string or header. */
|
/** 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) {
|
if (createButton) {
|
||||||
createButton.addEventListener("click", () => {
|
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);
|
announce(NOT_IMPLEMENTED, root);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -108,7 +110,8 @@ export function mount(root = document) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
rememberCode(code);
|
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);
|
announce(NOT_IMPLEMENTED, root);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+465
@@ -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.
|
||||||
@@ -1,23 +1,32 @@
|
|||||||
"""The FastAPI application factory — the HTTP surface of the server.
|
"""The FastAPI application factory — the HTTP surface of the server.
|
||||||
|
|
||||||
This is the process later phases attach to: rooms (`server/room.py`), the
|
This is the process later phases attach to: the room supervisor
|
||||||
WebSocket endpoint (`/ws/{code}`, phase 06) and the level manifest (phase 02)
|
(`server/rooms.py`, phase 05), the WebSocket endpoint (`/ws/{code}`,
|
||||||
all register on the app built here. Nothing game-like exists yet; the contract
|
phase 06) and the level manifest (phase 02) all register on the app built
|
||||||
is only:
|
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``,
|
* ``GET /`` — the client shell from ``app/static/index.html``,
|
||||||
* ``/static/*`` — the client's ES modules, CSS and art (never a CDN, A12),
|
* ``/static/*`` — the client's ES modules, CSS and art (never a CDN, A12),
|
||||||
* ``/api/levels``, ``/api/levels/{id}``, ``/api/config`` — the content API
|
* ``/api/levels``, ``/api/levels/{id}``, ``/api/config`` — the content API
|
||||||
(:mod:`undercooked.server.routes_content`), fed by the bundle loaded once
|
(:mod:`undercooked.server.routes_content`), fed by the bundle loaded once
|
||||||
here: content that will not load or fails PLAN §8 validation refuses
|
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,
|
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`).
|
* security headers on every response (:mod:`undercooked.server.headers`).
|
||||||
|
|
||||||
``create_app`` takes a :class:`~undercooked.config.Settings` as plain data, so a
|
``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``
|
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
|
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
|
from __future__ import annotations
|
||||||
@@ -34,8 +43,11 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from undercooked import __version__
|
from undercooked import __version__
|
||||||
from undercooked.config import ConfigError, Settings, get_settings
|
from undercooked.config import ConfigError, Settings, get_settings
|
||||||
from undercooked.debug import maybe_start_debugpy
|
from undercooked.debug import maybe_start_debugpy
|
||||||
|
from undercooked.server.clock import SystemClock
|
||||||
from undercooked.server.headers import SecurityHeadersMiddleware
|
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_content import content_router, load_validated_content
|
||||||
|
from undercooked.server.routes_rooms import rooms_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"STATIC_DIR",
|
"STATIC_DIR",
|
||||||
@@ -109,7 +121,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||||
logger.info("undercooked %s starting: %s", __version__, settings_log_line(resolved))
|
logger.info("undercooked %s starting: %s", __version__, settings_log_line(resolved))
|
||||||
maybe_start_debugpy(resolved)
|
maybe_start_debugpy(resolved)
|
||||||
|
await _app.state.supervisor.start()
|
||||||
yield
|
yield
|
||||||
|
await _app.state.supervisor.shutdown()
|
||||||
logger.info("undercooked %s stopped", __version__)
|
logger.info("undercooked %s stopped", __version__)
|
||||||
|
|
||||||
application = FastAPI(
|
application = FastAPI(
|
||||||
@@ -125,14 +139,18 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
)
|
)
|
||||||
application.state.settings = resolved
|
application.state.settings = resolved
|
||||||
application.state.content = content # loaded + validated once, above
|
application.state.content = content # loaded + validated once, above
|
||||||
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.add_middleware(SecurityHeadersMiddleware)
|
||||||
application.include_router(content_router)
|
application.include_router(content_router)
|
||||||
|
application.include_router(rooms_router)
|
||||||
|
|
||||||
@application.get("/healthz")
|
@application.get("/healthz")
|
||||||
async def healthz() -> dict[str, object]:
|
async def healthz() -> dict[str, object]:
|
||||||
"""Liveness probe. `rooms` is a literal until phase 05 owns a supervisor."""
|
"""Liveness probe; `rooms` is the supervisor's live room count."""
|
||||||
return {"status": "ok", "version": __version__, "rooms": 0}
|
supervisor: RoomSupervisor = application.state.supervisor
|
||||||
|
return {"status": "ok", "version": __version__, "rooms": len(supervisor.all_rooms())}
|
||||||
|
|
||||||
@application.get("/")
|
@application.get("/")
|
||||||
async def index() -> FileResponse:
|
async def index() -> FileResponse:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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]
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -24,7 +24,7 @@ pytestmark = pytest.mark.e2e
|
|||||||
# Strings a player sees, kept in sync with app/static/index.html and
|
# 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.
|
# app/static/js/app.js by hand: changing one must be a deliberate act.
|
||||||
READY = "Pick a kitchen to start."
|
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."
|
SHORT_CODE = "A room code is 5 characters."
|
||||||
TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,}$")
|
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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -210,6 +210,7 @@ def test_the_docs_on_disk_are_the_shipped_documents() -> None:
|
|||||||
"docs/DESIGN.md",
|
"docs/DESIGN.md",
|
||||||
"docs/DEPLOYMENT.md",
|
"docs/DEPLOYMENT.md",
|
||||||
"docs/LEVELS.md",
|
"docs/LEVELS.md",
|
||||||
|
"docs/ROOMS.md",
|
||||||
"docs/SIMULATION.md",
|
"docs/SIMULATION.md",
|
||||||
"docs/TESTING.md",
|
"docs/TESTING.md",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user