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`.
26 KiB
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 sleeps 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— decrementcountdown_leftby the walldt(floored at 0); at zero, firecountdown_doneand build the round.PLAYING—sim.step(sim_state, dt, queued, recipe_book): the kernel's ownSTEP_DTsubsteps,dtclamped 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 stampsreceived_at_tick); the sim's events are appended toevents_since_snapshot. When the sim'sresultlands, 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 captureslast_result = result_payload(sim_state)and firesround_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_emptyonce the room has been empty pastSEAT_GRACE_S(30 s),idle_expiredoncenow - last_activitypassedlobby_idle_timeout_s.remove_seat/set_ready/queue_inputstamplast_activity(an empty room stampsempty_since).- the first tick of a room's life carries
dt = 0(the prime);dtis floored at 0, so a backwardsClocknever 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_roompast the cap is refused, and a room that isfullrefuses new seats; - the one-token-one-room index — a token sits in at most one room, ever;
create_roomrejects a token that already has a room, and a room'sremove_seatclears 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 pastSEAT_GRACE_Sand idle rooms pastlobby_idle_timeout_s, and prunes the room index ofCLOSEDrooms; start()(the GC loop + every room's tick task) andshutdown()(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_forreads through it (task 03'sset_levelunlock check andlobby_payload'sunlockedread the session'sunlocked_levelthrough this), the rooms get it as theirstore, andPOST /api/rooms/GET /api/sessionrun 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 thanSEAT_GRACE_Sago is closed, in any state (an orphaned room is an orphaned room).SEAT_GRACE_Sis a module constant inserver/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); onegc_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 occupiedLOBBYroom (theROUND_ENDstate runs the same check) whoselast_activityis past the timeout is closed; exactly at the timeout it is still up.last_activityis 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). Onegc_pass()every 5 s (the loop is cancellable; the sleep is injectable, so a test advances theFakeClockand never waits). One pass, in order: drop rooms alreadyCLOSED(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 playerjoins whileCLOSED(the window before the drop) is a named 410room_closed.
Disconnected seats. A dropped connection marks the seat
connected=false; the seat is kept for the rejoin path (a token
joins 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):
- Create.
POST /api/rooms(no body — the barecurl -X POSTcriterion) takes (or mints) the 24-byte session token (X-Undercooked-Tokenheader or bodytoken; 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 inyou. 503room_fullwhen the process is atmax_rooms(US-01 criterion 7; the existing rooms are untouched). - Join (×3).
POST /api/rooms/{code}/joinseats 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 503room_fulland the code is retained. AjoinafterLOBBYwould become a spectator ("The lobby"). - Ready-up. The ready flags (the phase-06
readymessage is the transport copy ofRoom.set_ready). The start precondition: a solo host, or ≥ 1 ready non-host —can_startrenders it for the client's button. - Start.
Room.start(token)—LOBBYonly, host only (the preconditions named:NotInLobby,NotHost,NothingToStart). Fireshost_start→COUNTDOWN:round_no1, a fresh seed, the 3.0 s countdown. - Countdown → the round. 60 frames of 0.05 s (no drift — the tick
cadence section); at zero
countdown_donebuilds the round (level + seats + seed + the room's shelf/prop seeding) and the room isPLAYING. PLAYING→ the verdict. Onetick_onceper 0.05 s frame: the kernel'sstepwith the per-tick input queue. A scripted full-pipeline round (the phase-04 policy) reaches the verdict; the room then settles forSETTLE_S(1.0 s, the sim's frozen stage-7 machine) beforeround_overfires andlast_result = result_payload(state)lands.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"): aWONround raisesunlocked_leveltoorder + 1(the solo path's "unlock advanced"), stores thebestrun, counts the round and the served dishes.next_level. The host's next campaign pick (the value the store'snext_level_idhands back — after a win,order + 1's level; after a loss, the level just played):select_level+round_no2 + a fresh seed +COUNTDOWN.retryis the same level with a fresh seed;to_lobbyreturns without a new round.- 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 answersGET /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); ajoinbecomes 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 anerr 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 atto_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.