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
@@ -1,52 +0,0 @@
# Phase 05 — Anonymous lobbies and room lifecycle
**Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`, `US-03_ready_up_and_start`
**Context:** `.agents/PLAN.md` §6 (room FSM, supervisor, sessions), A6, A15, A16
## Objective
Build the multiplayer shell without any sockets yet: a `RoomSupervisor` that mints and
reclaims anonymous room codes, a `Room` whose finite state machine walks
lobby → countdown → playing → round-end → closed, and an in-memory session store that
remembers unlocks and best scores. Everything is exercised through direct Python calls
plus thin HTTP endpoints, so the transport (phase 06) is a thin layer over a tested core.
## Dependencies
- `04_kitchen_pipeline_and_scoring` (complete) — `build_state`, `step`,
`result_payload`, round results
## Tasks
1. `01_room_supervisor_and_codes.md` — code minting, capacity, registry, GC task
2. `02_room_state_machine.md` — `Room` FSM with guarded transitions and tick ownership
3. `03_lobby_state_and_presence.md` — seats, ready, host election/handoff, level pick, spectators
4. `04_session_store_and_progression.md` — in-memory LRU session records, unlocks, best scores
5. `05_lifecycle_integration_suite.md` — HTTP probes + full lifecycle tests, GC/idle behaviour, crash isolation
## Testing & Quality
- Unit: code alphabet and collision retry, capacity rejection, FSM transition legality
(every illegal pair rejected), host handoff ordering, GC decisions, LRU eviction,
unlock monotonicity.
- Integration: `create → join(×3) → ready → start → tick to result → round_end → next
level → lobby`; solo start; join-during-play becomes spectator; host leaves mid-round;
all-seats-empty closes the room; `max_rooms` refusal; a room whose tick raises is aborted
and isolated.
- Coverage: **>90%** on new/modified code; `server/room.py`, `server/rooms.py`,
`server/sessions.py` ≥ 95%.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks add `docs/ROOMS.md` (FSM diagram, supervisor rules, session semantics, what is
lost on restart) and update `AGENTS.md` §6 "where things go" if a new module appears.
## Completion Criteria
- [ ] `uv run python -c "from undercooked.server.rooms import RoomSupervisor"` succeeds
- [ ] `curl -fsS -X POST http://127.0.0.1:8000/api/rooms` returns a 5-char Crockford code; `curl -fsS http://127.0.0.1:8000/api/rooms/{code}` shows the seat list
- [ ] lifecycle integration test drives a 4-seat room to `WON` and back to `LOBBY` without sockets
- [ ] an exception injected into one room's tick aborts that room only; other rooms keep ticking (test)
- [ ] idle lobby GC closes a room within 2 GC cycles (test with a fake clock)
- [ ] `docs/ROOMS.md` exists with the FSM diagram and restart-semantics note
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -1,64 +0,0 @@
# Task 01 — Room supervisor, code minting, capacity, GC
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`
## Objective
Own the registry of live rooms: mint unguessable-but-shoutable codes, enforce capacity,
reclaim dead rooms, and do it all behind an injectable clock so tests are deterministic.
## Work
1. `src/undercooked/server/clock.py` — `Clock` protocol (`now() -> float`) with
`SystemClock` and `FakeClock(start=0.0)` (`advance(seconds)`); every time-dependent
server object receives a `clock` — **no bare `time.monotonic()` calls** in `server/`
(add a grep-style test in task 05).
2. `src/undercooked/server/codes.py`:
- `CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"` (5 chars → excludes I, L, O, U);
`CODE_LEN = 5`.
- `new_code(taken: Container[str], rng: random.Random | None = None) -> str` using
`secrets.choice`; retries up to 100 times on collision, then raises `CodeSpaceFull`.
- `normalize_code(raw: str) -> str | None` — uppercase, strip spaces/dashes, map
confusables (`I`/`l`→`1`, `O`→`0`), reject wrong length or invalid chars → `None`
(never a partial match, and the caller must not be able to distinguish "bad format"
from "no such room" — see US-02 criterion 4).
3. `src/undercooked/server/rooms.py`:
- `RoomSupervisor(settings, clock, content)` holding `dict[str, Room]`,
`dict[str, str]` token→code index, `stats()`.
- `create_room(session: SessionRecord, display_name: str) -> Room` → raises
`RoomCapacityError` when `len(rooms) >= max_rooms`; the creator takes seat 0 as host.
- `join(code, token, display_name) -> Room` → `RoomNotFound`, `RoomFull`,
`RoomClosed`; a known token re-joining its own room reclaims its seat even if the
seat was marked disconnected (rejoin path, phase 12 refines it).
- `leave(code, token)`, `get(code)`, `by_token(token)`, `all_rooms()`.
- `async def gc_pass()` — close rooms whose last seat left more than
`SEAT_GRACE_S` ago, close `LOBBY` rooms idle past `lobby_idle_timeout_s`, drop stale
token index entries; `async def gc_loop()` every 5 s, cancellable.
- `async def start()` / `async def shutdown()` — cancel all room tasks and await them.
- Registers itself in `app.state.supervisor` from `create_app()` (feature-flagged on
`Settings.levels_dir` content being loaded), and exposes
`POST /api/rooms`, `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join` in
`src/undercooked/server/routes_rooms.py` so the lifecycle is testable over HTTP
before WebSockets exist. Responses carry `room_code`, `state`, `seats`, `you` (token,
seat, host flag) — tokens are generated server-side with
`secrets.token_urlsafe(24)` and returned once.
4. `tests/unit/test_codes.py` — alphabet excludes I/L/O/U, length, 20 000 codes without
duplicates, collision retry via a stubbed `taken` set, `CodeSpaceFull`, all confusable
normalizations, rejection cases.
5. `tests/integration/test_supervisor.py` — create/join/leave matrix, `RoomFull` at
`max_players`, `RoomCapacityError` at `max_rooms` (with `max_rooms=3`), rejoin by token,
unknown code indistinguishable from malformed code at the API boundary, `stats()`
accuracy, shutdown cancels everything, GC with `FakeClock` closes exactly what it should.
6. `tests/integration/test_rooms_api.py` — the three HTTP endpoints: status codes, body
shapes, no token leakage for *other* players, `POST /api/rooms` at capacity → 503 with
`{code:"room_full"}`.
## Testing & Quality
- Unit + integration as listed; no test sleeps — all time via `FakeClock`.
- Coverage: **>90%** on `rooms.py`, `codes.py`, `clock.py`, `routes_rooms.py`.
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_codes.py tests/integration/test_supervisor.py tests/integration/test_rooms_api.py -q` → green
- [ ] `curl -fsS -X POST http://127.0.0.1:8000/api/rooms` returns a 5-char code from the Crockford alphabet
- [ ] no `time.monotonic()`/`time.time()` literal in `src/undercooked/server/` (grep-asserted test)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,58 +0,0 @@
# Task 02 — Room state machine and tick ownership
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-03_ready_up_and_start`
## Objective
Implement `Room` as an explicit, guarded FSM that owns exactly one 20 Hz tick task and
turns a level plus seats into a running simulation and a result.
## Work
1. `src/undercooked/server/room.py`:
- `RoomState` enum: `LOBBY, COUNTDOWN, PLAYING, ROUND_END, CLOSED`.
- `RoomTransition` table as data (`{(from, event): to}`); `Room._fire(event)` raises
`IllegalTransition` for anything not in the table — no ad-hoc state writes.
- Events: `host_start`, `countdown_done`, `round_over`, `to_lobby`, `retry`,
`next_level`, `close`, `all_seats_empty`, `idle_expired`.
- State data: `code`, `seats: list[Seat]` (`Seat(player_id, token, display_name,
color, ready, host, connected, last_seen, seat_index)`), `level: Level`,
`state`, `state_since`, `countdown_left`, `sim_state`, `last_result`,
`round_no`, `events_since_snapshot` buffer, `snapshot_seq`.
- `async def tick_loop()` — `while` loop targeting `1/tick_hz` using
`clock.now()`-based scheduling (no `asyncio.sleep(1/hz)` drift: compute the
next deadline), calling `self.tick_once(now)`.
- `def tick_once(now: float | None = None)` — synchronous core so tests can drive it
manually: `PLAYING` → `sim.step(sim_state, dt, queued_inputs)`, drain events into the
buffer, on `result is not None` fire `round_over`; `COUNTDOWN` → decrement, fire
`countdown_done`; `LOBBY`/`ROUND_END` → idle-expiry checks.
- `queue_input(player_id, input)` — only legal in `PLAYING`, unknown player →
`err` event; input queue is per-tick and cleared after each step.
- Crash isolation: `tick_once` wrapped so any exception logs (code + round, never
player tokens), calls `sim.round_state.abort(...)`, fires `close`, and never
propagates out of the loop (one room must not kill the server).
- `start()`/`close()` manage the task; `close()` is idempotent and frees seats.
2. `docs/ROOMS.md` — the FSM diagram (ASCII), the transition table, and what each state
accepts from clients (forward reference to phase 06's whitelist).
3. `tests/integration/test_room_fsm.py` — every legal transition, every illegal pair
raises, `host_start` requires ≥ 1 ready non-host or a solo host, countdown counts 3
frames of 1 s, `round_over` stores `last_result` and payload, `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` idempotent,
`all_seats_empty` closes after grace, queued inputs dropped outside `PLAYING`.
4. `tests/integration/test_room_tick.py` — `tick_once` drives the sim forward
(player positions change under a scripted input), tick cadence has no drift over 100
manual calls, event buffer drains exactly once, injected exception aborts the round and
leaves the room `CLOSED` without raising out of `tick_loop`.
## Testing & Quality
- Integration: full transition table, tick cadence with `FakeClock`, crash isolation.
- Unit: seat helpers, input legality, seed freshness on retry.
- Coverage: **>90%** on `room.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_room_fsm.py tests/integration/test_room_tick.py -q` → green
- [ ] the illegal-transition test covers the full `states × events` matrix except legal pairs
- [ ] a `Room` driven by `tick_once` reaches `WON` on level 1 with a scripted input
- [ ] injected sim exception closes only that room (asserted via a second live room)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,59 +0,0 @@
# Task 03 — Seats, ready-up, host election, spectators
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-02_join_by_code`, `US-03_ready_up_and_start`
## Objective
Make the lobby mean something: named seats with colors, ready toggles, host authority and
handoff, level selection restricted to unlocks, and spectators for people who arrive
mid-round.
## Work
1. Extend `src/undercooked/server/room.py` (or split into `server/seats.py` if it exceeds
~400 lines):
- `Seat` behavior: `toggle_ready()`, `mark_connected()/mark_disconnected(now)`,
`is_host`, `display_name` sanitized at the boundary.
- `sanitize_name(raw: str | None, seat_index: int) -> str` — strip control chars and
`<>"'`, collapse whitespace, trim, cap at 16 chars, fall back to `Cook #{seat+1}`;
duplicate names allowed; the result is rendered as text by the client (never HTML).
- Color assignment: first free color from the 4-color palette in seat order; reconnect
keeps the same color.
- Host rules: seat 0 of the creator is host; `transfer_host()` picks the lowest
occupied, connected seat when the host leaves **in `LOBBY` or `ROUND_END`**; in
`PLAYING`/`COUNTDOWN` the round continues and the host slot is empty (no mid-round
election) — documented, tested. If the host returns, they do not automatically regain
the crown (explicit, avoids flip-flopping).
- `set_level(token, level_id)` — host only, level must exist and be unlocked for the
session; rejects with `NotHost`/`LevelLocked`/`UnknownLevel`; only in `LOBBY`.
- `start(token)` — host only; legal when `len(occupants) == 1` (solo) or
`any(seat.ready for seat in occupants if not seat.host)`; raises `NothingToStart`.
- Spectators: `join()` during `COUNTDOWN`/`PLAYING`/`ROUND_END` adds a spectator entry
(no seat, `role="spectator"`); `leave()` removes it; spectators receive state/snapshot
but their inputs are ignored (`err` `not_seated`).
- `lobby_payload()` — the `lobby` message body: `code`, `state`, `level`, `host_seat`,
`can_start`, `seats[{index, name, color, ready, host, connected}]`,
`spectators[{name, color}]`, `unlocked` (for the requesting session), `round_no`.
- Emotes: `emote(token, id)` validated against an `EMOTES` frozenset constant, throttled
to 2/s per connection, re-broadcast as an event (no chat in v1).
2. `docs/ROOMS.md` — host rules, spectator semantics, name rules, emote list.
3. `tests/integration/test_lobby.py` — ready toggles visible in `lobby_payload`,
solo-start allowed, 2-player start requires a non-host ready, non-host `start`/
`set_level` rejected, host handoff in lobby and *not* in play, spectator join during
play + ignored inputs, spectator removal on leave, color stability across reconnect,
emote whitelist + throttle, name sanitization table (control chars, 40 chars, emoji,
`"`, `<script>`, whitespace-only → fallback name).
4. `tests/unit/test_sanitize_name.py` — property-ish table incl. unicode, RTL marks, zero
width chars, and a length-1000 input; output length ≤ 16 and never contains `<`, `>`, `"`.
## Testing & Quality
- Integration: lobby behaviors above.
- Unit: sanitizer hardening.
- Coverage: **>90%** on the lobby code paths.
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_lobby.py tests/unit/test_sanitize_name.py -q` → green
- [ ] `<script>alert(1)</script>` as a display name comes back sanitized and ≤ 16 chars (asserted)
- [ ] no mid-round host election occurs (asserted explicitly, not by omission)
- [ ] a spectator's `input` is rejected with `not_seated` (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,56 +0,0 @@
# Task 04 — In-memory session store and campaign progression
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-07_campaign_progression`
## Objective
Remember, per browser session and purely in memory, which levels are unlocked and the
best score per level — the smallest thing that makes a campaign possible (A6).
## Work
1. `src/undercooked/server/sessions.py`:
- `SessionRecord` dataclass: `token`, `display_name`, `unlocked_level: int`
(campaign *order*, starts at 1), `best: dict[level_id, BestRun]`, `rounds_played`,
`dishes_served`, `created_at`, `last_seen`, `endless_best: int | None`.
- `BestRun`: `score`, `stars`, `completed_at` (monotonic-relative, not wall-clock PII —
store `time.time()` rounded to seconds is fine and documented; no timezone, no IP).
- `SessionStore(max_entries=10_000)`:
- `get_or_create(token) -> SessionRecord`, `record_result(token, level, result_payload)`
→ updates `best` when the score beats the stored one, bumps `unlocked_level` to
`level.order + 1` **only on `WON`** and never decreases it, increments counters;
- `is_unlocked(token, level_id) -> bool`, `next_level_id(token, content) -> str | None`,
- LRU eviction on insert (oldest `last_seen` first), `stats()` for `/api/rooms`
diagnostics, `to_public(token)` returning only what the client may see.
- Optional bounded persistence: **none** (A6). A comment at the top of the module
states "in-memory only; a process restart clears all progression" and the README
repeats it (US-07 criterion 8).
- No personal data: the module must never store IP, user-agent, or raw headers
(enforced by test).
2. Wire it: `RoomSupervisor` owns a `SessionStore`; `POST /api/rooms` creates/uses a
session from the `X-Undercooked-Token` header (or a `token` body field) and returns the
token in the body **once**; `GET /api/session` returns `to_public(...)`; the `Room`
asks `session_store.is_unlocked(...)` in `set_level` (task 03) and
`next_level_id(...)` for `next_level` (task 02); `round_over` calls
`record_result` for every seated player.
3. `tests/integration/test_sessions.py` — unlock only on win, unlock monotonic (win L1 →
L2 unlocked; lose L2 → still unlocked), best score replaces only when higher, stars
recorded, counters accumulate across rooms, `to_public` leaks nothing beyond the public
shape, unknown token auto-creates at `unlocked_level=1`, LRU eviction evicts the stalest
and never the current session, 10 001 sessions stay bounded in size.
4. `tests/unit/test_session_privacy.py` — assert the `SessionRecord` field set and a
`repr()` scan for `ip`, `user_agent`, `authorization`, `cookie`; assert
`SessionStore` has no attribute holding raw headers.
5. `docs/ROOMS.md` — "Progression and what survives a restart".
## Testing & Quality
- Integration: unlock/best/LRU/privacy behaviors.
- Unit: privacy surface.
- Coverage: **>90%** on `sessions.py`.
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_sessions.py tests/unit/test_session_privacy.py -q` → green
- [ ] winning level 1 then losing level 2 leaves level 2 unlocked (asserted)
- [ ] a lower score never overwrites a stored best (asserted)
- [ ] `GET /api/session` with a fresh token returns `unlocked: 1` and empty `best`
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,57 +0,0 @@
# Task 05 — Lifecycle integration, GC behaviour, crash isolation
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`, `US-03_ready_up_and_start`
## Objective
Prove the whole lobby/room lifecycle end to end over HTTP (still no WebSockets),
including the unglamorous parts: idle expiry, seat grace, capacity refusal, and one room
failing without taking the server down.
## Work
1. `tests/integration/test_lifecycle_end_to_end.py` — using `TestClient` + direct
`tick_once` driving (no sleeps):
- full happy path: create → 3 joins → readies → host start → countdown completes →
tick to `WON` → `ROUND_END` payload correct → `next_level` → back to `LOBBY` with the
same seats and colors;
- solo path: create → start (no other players) → win → unlock advanced;
- full-room path: 5th join refused `room_full`, code retained;
- bad-code path: `GET /api/rooms/ZZZZZ` and `GET /api/rooms/nope!` return the *same*
status and body shape (US-02 criterion 4 — no oracle for room existence beyond
"not here"), and the response is rate-limit friendly (no stack trace);
- abandon path: all players leave a `LOBBY` → after `SEAT_GRACE_S` + one `gc_pass()`
the room is `CLOSED` and its code no longer resolvable;
- idle lobby expiry with `FakeClock` advancing past `lobby_idle_timeout_s`;
- capacity: with `max_rooms=2`, the third `POST /api/rooms` → 503 `room_full`, and the
existing rooms are untouched.
2. `tests/integration/test_room_isolation.py`:
- two live rooms; inject an exception into room A's sim via a test-only
`UNDERCOOKED_TEST_HOOK` seam (a `Callable` on `Room` named `_test_step_override`,
set only in tests — never read from the network);
- assert: room A becomes `CLOSED`/aborted with an error event delivered, room B keeps
ticking correctly, the supervisor's `gc_pass()` reaps room A, and the server keeps
serving `/healthz`.
3. `tests/integration/test_no_wall_clock.py` — grep test: no `asyncio.sleep(` with a
non-zero literal and no `time.time()`/`time.monotonic()` inside
`src/undercooked/server/room*.py`, `rooms.py`, `sessions.py` (all timing through
`Clock`), so the lifecycle stays deterministically testable.
4. `docs/ROOMS.md` — add the "Lifecycle walkthrough" section mirroring the happy-path
test step by step, and the "Failure and expiry" section (grace windows, GC period,
crash policy).
5. Docs sweep: confirm README mentions the token header/`/api/session`, `SEAT_GRACE_S`,
`GUEST_IDLE_TIMEOUT_S` naming is consistent between README table, `config.py`, and
docs (rename in this task if they drifted, updating all three).
## Testing & Quality
- Integration: the full lifecycle, expiry, capacity, and isolation suites above.
- Coverage: **>90%** on new/modified code; the phase must leave `server/room.py`,
`server/rooms.py`, `server/sessions.py` each ≥ 95%.
- Full suite green; `bash .agents/validate.sh` exits 0.
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_lifecycle_end_to_end.py -q` → green (all five paths)
- [ ] `uv run pytest tests/integration/test_room_isolation.py -q` → one room's failure does not affect the other
- [ ] `uv run pytest tests/integration/test_no_wall_clock.py -q` → no direct clock calls in the lifecycle modules
- [ ] `curl` walk-through of create → join → ready → (HTTP has no start yet: document the gap in `docs/ROOMS.md` as closed by phase 06) works as written
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases