Files
undercooked/docs/ROOMS.md
T
ducoterra f51ba919af 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`.
2026-10-08 05:17:21 -04:00

466 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.