phase: 05_anonymous_lobbies_and_room_lifecycle

All green. Final report:

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

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

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

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

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

**Next pending phase:** `06_realtime_websocket_protocol`.
This commit is contained in:
2026-10-08 05:17:21 -04:00
parent 79b7f1e4e0
commit f51ba919af
35 changed files with 8391 additions and 32 deletions
+93 -15
View File
@@ -12,20 +12,22 @@ email, no password: you create a room, share the 5-character code, play.
Phases **01** (runnable service scaffold), **02** (level content, schema and
validation), **03** (the deterministic simulation kernel: fixed-step tick,
movement and collision, the action button, the hands, the replay goldens)
and **04** (the kitchen pipeline: station state machines, the ticket rail,
movement and collision, the action button, the hands, the replay goldens),
**04** (the kitchen pipeline: station state machines, the ticket rail,
serve scoring, freshness and the dirty loop, the round verdict with stars)
are **complete**; the next pending phase is
`05_anonymous_lobbies_and_room_lifecycle`. What
and **05** (anonymous lobbies and room lifecycle: the `RoomSupervisor`,
the guarded `Room` FSM, the in-memory session store, the lifecycle
integration suite) are **complete**; the next pending phase is
`06_realtime_websocket_protocol`. What
runs today:
- **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
single typed, validated `Settings` object (`DEBUGPY` support included), and
`uv run undercooked` starts a FastAPI server that serves the client shell at `/`,
the browser assets from `app/static/`, and `GET /healthz` — which answers
`{"status":"ok","version":"0.1.0","rooms":0}` (`rooms` stays `0` until phase 05 owns
a supervisor) — with the security headers (CSP `default-src 'self'`) on every
response.
`{"status":"ok","version":"0.1.0","rooms":<live room count>}` from the room
supervisor (phase 05) — with the security headers (CSP `default-src 'self'`) on
every response.
- **The content.** The recipe book and the first two campaign levels live in
`levels/`; `sim/level.py` parses them into typed models, `sim/validation.py` runs
PLAN §8's named rules, and `create_app` loads + validates that bundle at startup and
@@ -174,15 +176,58 @@ runs today:
byte-stability across two identical runs and across the settle window);
the contract is `docs/SIMULATION.md` §"Round end — the verdict, the
settle, the stars".
- **The room supervisor (phase 05).** The server-side lobby shell is
live: `server/rooms.py` mints 5-character Crockford base32 codes (collision-
checked, never reused), enforces `max_rooms` / `max_players` capacity, keeps a
one-token-one-room index (a token joining a second room is evicted from the
first; a *disconnected* seat is kept for the rejoin path), and runs the 5 s GC
(empty rooms past a 30 s grace, idle lobbies past `lobby_idle_timeout_s`, stale
tokens). Everything runs on an injected clock (`server/clock.py`), so the tests
drive the garbage collector with a `FakeClock` and never sleep. The lifecycle is
testable over HTTP before the phase-06 WebSocket transport exists:
`POST /api/rooms` (201, the session from the `X-Undercooked-Token`
header or the body's `token` — or a server-minted token, returned
once),
`GET /api/rooms/{code}`, `POST /api/rooms/{code}/join` and
`GET /api/session` — seats never leak
other players' tokens, a malformed code 404s exactly like an unknown one
(US-02 criterion 4), and a full room or a full process is a 503
`{"code": "room_full"}` (US-01 criterion 7). The `Room` itself is the guarded
FSM behind those routes — five states, a table of fourteen legal
transitions, one 20 Hz deadline-scheduled tick task per room and crash
isolation that closes only the room that crashed (contract: `docs/ROOMS.md`),
and the ready-up/host flow rides on it. The in-memory **session store**
(`server/sessions.py`) is live on top: per token it remembers the campaign
progression — `unlocked_level` (a `WON` round raises it to `order + 1`,
never decreases it), `best[level_id]` (a `BestRun` of `score`/`stars`/
`completed_at`, replaced only when the score is higher), the
`rounds_played` / `dishes_served` counters — LRU-capped at 10 000 (evicting
the stalest, never the session being created) and wiped on restart (A6);
`Room.set_level` asks the store's `is_unlocked`, `round_over` records the
result into every seated player's session, and `GET /api/session` answers
`to_public` (a fresh token reads `unlocked: 1` with an empty `best`; 400
`no_token` without the header) (contract: `docs/ROOMS.md` "Progression").
The **lifecycle integration suite** (phase 05 task 05) drives the whole
lifecycle over HTTP plus direct `tick_once`: create → 3 joins → ready-up
→ the host's start → the countdown → a scripted full-pipeline `WON` → the
`ROUND_END` payload → `next_level` → back to `LOBBY` with the same seats
and colours; plus a solo win with the unlock advanced, the 5th join's and
the process-capacity 503 `room_full`, malformed ≡ unknown codes (US-02
criterion 4), the seat-grace and lobby-idle expiries through the GC, and
one room's crash closing only itself (the test-only `_test_step_override`
seam on `Room`; contract: `docs/ROOMS.md` "Failure and expiry" and
"The lifecycle walkthrough"). The client
buttons still answer
"Not implemented until phase 07." until phases 06/07 wire the transport.
- **The rules are tests, not promises.** The no-external-asset rule is
`tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is
`tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by
`tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
phase pipeline runs after every task.
What does not exist yet: the hazards (phase 10), rooms and the
What does not exist yet: the hazards (phase 10), the
WebSocket
protocol (05-06 — the lobby buttons answer "Not implemented until phase 05.") and the
protocol (06 — the lobby buttons answer "Not implemented until phase 07.") and the
playable Canvas renderer (07). Execute phases with the `phased-execution` skill
(`auto-phase.sh`); it always takes the lowest-numbered directory still in
`.agents/phases/todo/`.
@@ -195,13 +240,21 @@ readies up, and the kitchen starts. Each cook has one movement control and **one
button** that picks up, uses and puts down whatever is in reach — one carried item at a
time. Orders appear in the HUD with a shrinking deadline; assemble the dish and serve it
at the window before the timer runs out. Levels escalate: more recipes, fewer stations,
hostile floors. Nothing is saved between rounds and nothing identifies you: the browser
hostile floors. Nothing identifies you: the browser
keeps only a random per-tab token and the last code you typed, both in `sessionStorage`.
The campaign does remember per token — unlocks and best scores live server-side,
in memory, and a server restart clears all progression (A6: no database).
What actually works today is the shell of that: the page loads, normalises a typed code to
five uppercase alphanumerics (codes themselves are Crockford base32, and only get issued
once phase 05 runs the room supervisor), and answers "Not implemented until phase 05."
Movement keys and touch controls are defined by the phase-07/08 client and documented in
What actually works today is the shell of that plus the server-side room shell:
the page loads, normalises a typed code to five uppercase alphanumerics (codes
themselves are Crockford base32, minted by the room supervisor — `server/rooms.py`,
phase 05, which is live and testable over `POST /api/rooms` / `GET /api/rooms/{code}` /
`POST /api/rooms/{code}/join` / `GET /api/session`), and the lobby buttons still answer "Not implemented
until phase 07." until the phase-06 WebSocket transport and the phase-07 client
wiring replace them. The ready-up, the 20 Hz tick ownership, the session store
(the unlocks and best scores, in memory — a server restart clears all
progression) and the lifecycle integration suite are live. Movement keys and
touch controls are defined by the phase-07/08 client and documented in
`docs/CLIENT.md` once that phase writes it.
## Quickstart (development)
@@ -217,6 +270,9 @@ uv run undercooked --reload # dev reload
uv run undercooked --levels-dir levels # point at another content dir
curl -fsS http://127.0.0.1:8000/api/levels # the campaign cards the lobby will show
curl -fsS http://127.0.0.1:8000/api/config # client-safe settings + protocol version
curl -fsS -X POST http://127.0.0.1:8000/api/rooms # create a room: 5-char code + your token (201)
curl -fsS http://127.0.0.1:8000/api/rooms/<CODE> # inspect it: the seat list, no tokens
curl -fsS -H 'X-Undercooked-Token: <TOKEN>' http://127.0.0.1:8000/api/session # your session: unlocked, best (in memory; a restart clears it)
```
`python -m undercooked` is the same command (the container uses it). A flag
@@ -363,7 +419,29 @@ src/undercooked/sim/replay.py the replay harness: the input-script format, the
src/undercooked/tools/levels.py the `undercooked-levels` author CLI — validate, list,
show [--grid], new, axes; same loader, same rules, no
hard-coded content list (docs/LEVELS.md "Authoring workflow")
src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`, mounts the content API
src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`, mounts the
content API and the room API; installs the supervisor
src/undercooked/server/clock.py injectable time: `Clock`, `SystemClock`, `FakeClock`
(the only server module that imports `time`; a grep
test keeps the wall clock out of the rest of `server/`)
src/undercooked/server/codes.py Crockford room codes: the alphabet, collision-checked
minting (`new_code`), input normalisation (confusables,
malformed ≡ unknown, US-02 criterion 4)
src/undercooked/server/room.py `Room`: code, seats (the rejoin path keeps
disconnected seats), `RoomState`, one owned tick task
src/undercooked/server/rooms.py `RoomSupervisor`: the registry, capacity, the
one-token-one-room index, the 5 s GC, `start`/`shutdown`
src/undercooked/server/sessions.py `SessionRecord` + `BestRun` + `mint_token`
+ the bounded LRU `SessionStore` (`record_result`'s win-only
unlock bump, best-score replacement, `is_unlocked` /
`next_level_id`, `to_public` — in memory; a process
restart clears all progression)
src/undercooked/server/routes_rooms.py `POST /api/rooms`, `GET /api/rooms/{code}`, `POST
/api/rooms/{code}/join`, `GET /api/session` — the lifecycle
over HTTP before the phase-06 WebSocket transport (session
from the `X-Undercooked-Token` header or the body's
`token`, minted otherwise; tokens returned once; seats never
leak tokens)
src/undercooked/server/routes_content.py `/api/levels`, `/api/levels/{id}`, `/api/config`,
plus the startup gate: broken content refuses startup
src/undercooked/server/headers.py the security-header middleware