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`.
494 lines
28 KiB
Markdown
494 lines
28 KiB
Markdown
# 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 `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 frames carry their `received_at_tick` stamp from
|
||
`queue_input`) — **unless the tick advances no substep** (a wall-clock
|
||
`dt` too short to fill a `STEP_DT` once 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 (`result` set) still drains: the kernel ignores
|
||
its inputs there by design. 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, 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_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 (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):
|
||
|
||
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.
|