All green. Final verification complete — writing the report.
**Final pass report — phase 06 `realtime_websocket_protocol`**
- Verified: all 6 task files present in `complete/`; this was the final verification pass (no implementation work outstanding)
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors
- `uv run undercooked-levels validate` → content OK; `uv run pytest` (full suite) → **1751 passed, 0 failed, coverage 97.30% (>90% floor)**; slow sim stage (`-m slow tests/sim --no-cov`) → 2 passed; repo hygiene clean
- App launches: `/healthz` 200, `/` 200, `/api/levels`, `/api/config`, `POST /api/rooms` (201), `/api/stats` all verified live on an ephemeral port (server cleaned up)
- Criteria: scripted client room→hello→ready→start→countdown→≥20 snaps→round — PASS (`test_ws_round_two_clients.py`); identical `round` payloads both clients — PASS (`round_a == round_b`); version mismatch → `err protocol_mismatch` + close `4400` — PASS; 64 KB refused / 4 KB boundary, 500-burst → `rate_limited` → `4404` with bounded queue, 2000 malformed + 200 handshakes within memory bound — PASS; snap ≤ 1.5 KB (4p/4o/3h) and 64×64 ≤ 4 KB, downlink ≤ 60 KB/s over 100 ticks — PASS; `docs/PROTOCOL.md` model-sync test-enforced in both directions + full-round sequence diagram present — PASS
- Docs: README.md had 3 stale spots — status section (claimed 01–05 complete, 06 pending → now 01–06 complete, 07 pending), "until phases 06/07 wire the transport" wording, and the `PROTOCOL.md` layout note ("phase 06 completes it"). All fixed. AGENTS.md verified consistent — no changes needed
- No defects found in completed phases; no live infrastructure touched
Next pending phase: `07_canvas_client_and_hud`.
28 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 |
_queued_inputs / queue_input |
the per-tick input queue (capacity INPUT_QUEUE_PER_SEAT x len(seats); see "One 20 Hz tick task", the Inputs paragraph) |
stats() / note_input_stat |
the room's diagnostics census: the lobby/round counters plus the input counters (inputs_received, inputs_dropped, stale_seqs, clamped_moves, large_seq_gaps) |
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 frames carry theirreceived_at_tickstamp fromqueue_input) — unless the tick advances no substep (a wall-clockdttoo short to fill aSTEP_DTonce the accumulator is counted): then the queue is kept and the step is not called, so a frame is never dropped without being applied (a 20 Hz wall-clock loop jitters 48-51 ms; without the guard a queued frame would be lost on a good fraction of ticks). A settled state (resultset) still drains: the kernel ignores its inputs there by design. 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, connected token: an unknown token is an err event with
reason=not_seated (the player is not a seat at all) or reason=unknown_player
(the token is a seat but the sim id is not its) — the room's only in-band
error channel — while a DISCONNECTED seat's frame is a silent no-op (the
client is not at fault, so no error is queued) and last_seq on the
connection is left untouched. The queue is per-tick with a capacity of
INPUT_QUEUE_PER_SEAT x len(seats) (4 x the seated players): at capacity,
the oldest frame of the arriving player is dropped (the newest intent
wins; inputs_dropped bumped) — the queue never grows unbounded. The
queue is drained by the next tick that advances a substep (a zero-substep
tick keeps it — see the PLAYING bullet) and cleared then; stats()
counts the input traffic (inputs_received, inputs_dropped, stale_seqs,
clamped_moves — the clamp itself is the transport's, ws.to_player_input —
and large_seq_gaps, the tolerated-loss sibling).
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 (task 05). A dropped connection marks the seat
connected=false; the seat is kept for the rejoin path. The park
comes first: after PARK_AFTER_S (3 s) the cook's carried item is
released (onto the station on the tile, or counters["lost_items"]),
and the sim's cook sits in disconnected. The grace that drops the
seat is UNDERCOOKED_GUEST_IDLE_TIMEOUT_S (45 s), enforced by the
room's own tick in every state (SeatAbandoned): the seat is emptied
(one ghost_off event, a host transfer, empty_since for the close
rules) and any still-carried item is released at once. A rejoin inside
the grace reclaims the seat (welcome.rejoined; the snapshot stream
resumes within one tick); after it, the spectate/join rules apply. The
emptied room closes through the 30 s seat grace above (the room's own
check in LOBBY/ROUND_END; the GC in any state), and a closing room
closes its sockets with err room_gone + close 4401 — the client's
"the room died" signal (the contract in docs/PROTOCOL.md,
"Heartbeats, drops, and seat grace").
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.