Files
undercooked/src/undercooked/server/room.py
T
ducoterra 95ca3fd1fa phase: 07_canvas_client_and_hud
**Final report — phase 07 final verification pass**

- **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found.
- **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase).
- **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔.
- **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry.
- **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`.
- **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate.
- **Next pending phase:** `08_client_prediction_and_touch`.
2026-10-10 18:03:52 -04:00

1676 lines
72 KiB
Python

"""One room: identity, seats, a guarded state machine, and one owned tick task.
A `Room` turns a level plus a set of seats into a running simulation and a
result. It is an **explicit, guarded FSM** (PLAN §6): every state change goes
through `_fire(event)`, which looks the `(state, event)` pair up in
`ROOM_TRANSITIONS` — data, not code — and raises `IllegalTransition` for
anything not in the table. There are no ad-hoc state writes; the one documented
exception is `close()`, the universal teardown, which takes the `close`
transition (legal from every live state) and then cancels the tick task and
frees the seats.
The room owns **exactly one asyncio tick task** (PLAN §6 "every room owns
exactly one asyncio tick task; task cancellation is the only teardown"):
`start_ticking()` creates it, `close()` cancels it, and nothing else ever stops the
room. The loop body is deadline-scheduled off the injected `Clock` — it
computes the next deadline instead of sleeping `1/tick_hz`, so the cadence
cannot drift, and a clock jump resynchronises instead of paying a catch-up
burst (the sim's `dt` clamp makes a burst worthless anyway). `tick_once(now)`
is the synchronous core the loop calls, which is also what tests drive
manually: `PLAYING` advances `sim.step` with the per-tick input queue and
drains the events into `events_since_snapshot`; `COUNTDOWN` decrements
`countdown_left` (3 s, PLAN §6) and fires `countdown_done` at zero;
`LOBBY`/`ROUND_END` run the idle-expiry checks (the empty-grace and the
`lobby_idle_timeout_s` rules, the same numbers the supervisor's GC applies
externally).
**The settle.** A sim state that carries a `result` is a finished round: the
room keeps ticking it for `sim.round_state.SETTLE_S` (1.0 s) — the sim's
frozen stage-7-and-tick machine, no events, no movement — before firing
`round_over` and storing `result_payload(state)` in `last_result`. That is
the window `docs/SIMULATION.md` promises: the verdict substep's events land in
the buffer, and the result is a function of the state at the verdict alone.
**Crash isolation.** `tick_once` wraps the whole body: any exception is logged
(code + round number — never a player token), the sim is marked `ABORTED`
(`sim.round_state.abort`), an `err` event is buffered, `close` fires, and the
exception never propagates out of the loop (one room must not kill the
server). A round that already won or lost keeps its verdict — a crash after
the buzzer cannot un-win it. The fault the integration suite injects arrives
through the test-only `_test_step_override` seam (the `UNDERCOOKED_TEST_HOOK`):
a `Callable` with `step`'s signature that the `PLAYING` branch calls in the
kernel's place when set — tests set it, the network never reads it.
**The lobby's verbs (task 03).** The seat/spectator *data* and the pure
helpers live in `server/seats.py`; this room is what walks them. `start(token)`
is the host's *Start* (US-03): `LOBBY` only, host only, then task 02's
preconditions — a solo host, or ≥ 1 ready non-host, and a selected level
(`NothingToStart`, a `StartRefused`, names the cause). `set_level(token,
level_id)` is the host's pick (PLAN §6 "limited to unlocked"): `LOBBY` only,
host only, the id in the campaign (`UnknownLevel`) and its `order` within
the requesting session's `unlocked_level` (`LevelLocked`). The host rules
(`transfer_host`): when the crown holder leaves or their connection drops in
`LOBBY`/`ROUND_END` the crown moves to the lowest occupied, *connected* seat;
in `COUNTDOWN`/`PLAYING` the host slot is emptied and **no mid-round election
happens** (the round continues, the crown stays empty); a returning host does
not automatically regain it. Spectators (PLAN §6 "late joins wait in
SPECTATING") are `join`ed tokens in `COUNTDOWN`/`PLAYING`/`ROUND_END` — no
seat, their inputs are an `err not_seated`, `to_lobby` promotes them into
free seats. `emote` validates against the `EMOTES` whitelist, throttles to 2
per 1.0 s per token, and re-broadcasts as an `ev` event. `lobby_payload`
builds the phase-06 `lobby` message body (seats + spectators + `can_start` +
the viewer's `unlocked`).
**Session progression (task 04).** The room holds a reference to the
supervisor's bounded LRU `SessionStore` (`store`; `None` for a test-built
room): `set_level` asks `store.is_unlocked(token, level_id)` when it owns
one (falling back to the `sessions` lookup: a token without a record
counts as unlocked 1), `next_level_id(token)` reads
`store.next_level_id(token, content)` — the value the `next_level` pick
comes from — and `round_over`'s half of the settle
(`_record_round_result`) calls `store.record_result` for **every seated
player**. A room without a store records nothing; spectators are not
players.
**The ghost and the seat grace (task 05).** A socket drop is
`on_disconnect`: the seat is marked `connected=False` (kept for the
rejoin path; the crown rule runs through the mark), one `GHOST_ON`
event is buffered, and the sim's cook is switched (`connected=False`,
state `DISCONNECTED` — the pure functions are `sim/ghost.py`). After
`PARK_AFTER_S` (3.0 s, `sim/ghost.py`) the cook's carried item is
released by `_park_ghosts` (every `PLAYING` tick, off `seat.last_seen`)
— onto the station on the cook's tile (a marked set-down, grabbable),
or into `counters["lost_items"]` (a `GHOST_PARK` event); an item is
never deleted silently and a release is idempotent (one per token per
round, `_parked`). A rejoin inside the grace is `on_reconnect`: the
seat is marked connected, one `GHOST_OFF` (only when it actually was a
ghost), the sim's cook restored (`connected=True`, `IDLE`, a
still-carried item kept). `SeatAbandoned` — a seat disconnected past
`settings.guest_idle_timeout_s` — is emptied by `_abandon_expired`
(every state, the room's own copy of the rule the supervisor's GC
applies externally): a forced release (the seat is going away; the sim's
player stays, parked), one `GHOST_OFF`, the seat out (a host transfer,
`empty_since` for the close rules). A room that empties closes through
the existing rules (the 30 s seat grace, the GC); closing it fires the
`on_close` hook the app installs — `err room_gone` + close `4401` on
the room's sockets, the client's "the room died" signal.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
import math
import secrets
from dataclasses import replace
from enum import StrEnum
from typing import TYPE_CHECKING, Final
from undercooked.server.seats import (
EMOTE_EVENT_KIND,
EMOTE_MAX_PER_WINDOW,
EMOTE_WINDOW_S,
EMOTES,
SEAT_PALETTE,
LevelLocked,
NothingToStart,
NotHost,
NotInLobby,
Seat,
Spectator,
StartRefused,
UnknownEmote,
UnknownLevel,
)
from undercooked.server.sessions import SessionRecord
from undercooked.sim.ghost import PARK_AFTER_S, mark_ghost, release_ghost_carry, restore_player
from undercooked.sim.model import (
Event,
EventKind,
Item,
ItemForm,
KitchenState,
PlayerState,
StationKind,
)
from undercooked.sim.round_state import SETTLE_S, abort, result_payload
from undercooked.sim.state_build import SeatSpec, build_state
from undercooked.sim.step import MAX_DT, STEP_DT, TICK_EPS, PlayerInput, step
if TYPE_CHECKING:
from collections.abc import Awaitable, Callable, Mapping, Sequence
from undercooked.config import Settings
from undercooked.server.clock import Clock
from undercooked.server.sessions import SessionStore
from undercooked.sim.content import ContentBundle
from undercooked.sim.level import Level
from undercooked.sim.recipe import RecipeBook
SessionLookup = Callable[[str], SessionRecord | None]
#: The `step` signature: the test-only `_test_step_override` seam must
#: stand in for the kernel exactly (the same four arguments, the same
#: return).
StepFn = Callable[
[KitchenState, float, Sequence[PlayerInput], RecipeBook | None],
tuple[KitchenState, tuple[Event, ...]],
]
#: The room's broadcast seam (task 04): the wire layer's fanout,
#: installed by the app. Called from the tick task with the audiences
#: whose encode pass is due (``"player"`` / ``"spectator"`` / ``"ev"``)
#: and the state frames to fan out (the ``countdown`` / ``round``
#: payloads; the fanout validates them against the protocol models).
Broadcaster = Callable[[set[str], tuple[dict[str, object], ...]], None]
__all__ = [
"COUNTDOWN_S",
"ROOM_EVENTS",
"ROOM_TRANSITIONS",
"SEAT_GRACE_S",
"TEST_MODE_SEED",
"IllegalTransition",
"Room",
"RoomState",
"RoomTransition",
"Seat",
"StartRefused",
]
logger = logging.getLogger(__name__)
#: How long the countdown runs (PLAN §6: "COUNTDOWN(3s)"); one `n` per whole
#: second left (3…2…1), so the broadcast is `ceil(countdown_left)`.
COUNTDOWN_S = 3.0
#: Seconds an empty room survives before it (or the GC) closes it (PLAN §6
#: "grace"). Defined here — the room's own tick check and the supervisor's GC
#: both read this one constant.
SEAT_GRACE_S = 30.0
#: Float-noise tolerances (repeated `0.05` s subtraction does not land exactly
#: on 0.0 — 3.0 - 60 x 0.05 keeps a 2.3e-15 residue; the same trick as the
#: sim's `TIME_EPS`/`TICK_EPS`).
_EPS_S: Final[float] = 1e-9
#: Splitmix64-style constants: the room's seed cursor is stepped with them, so
#: consecutive rounds always draw different seeds (freshness on retry) and a
#: test can pin the whole sequence from one starting value.
_SEED_MODULUS: Final[int] = 2**63
_SEED_MULTIPLIER: Final[int] = 6364136223847
_SEED_INCREMENT: Final[int] = 1442695040888963407
#: The fixed base seed a room is born with under `UNDERCOOKED_TEST_MODE=1`
#: (the test-only seam, the e2e flagship suite's `?seed=` — a test-mode
#: server's rounds are deterministic, so a page loaded with
#: `?seed=20240701` plays the same seed the room's sim actually draws).
#: A production room (`test_mode` off) instead draws fresh from
#: `secrets.randbelow(_SEED_MODULUS)`. The value is documented in the
#: README's configuration table and `tests/e2e/README.md`.
TEST_MODE_SEED: Final[int] = 20240701
class RoomState(StrEnum):
"""The room's lifecycle state (PLAN §6 FSM)."""
LOBBY = "lobby"
COUNTDOWN = "countdown"
PLAYING = "playing"
ROUND_END = "round_end"
CLOSED = "closed"
class IllegalTransition(RuntimeError): # noqa: N818 - name fixed by the phase task file
"""`_fire` was given a `(state, event)` pair that is not in the table."""
def __init__(self, state: RoomState, event: str) -> None:
super().__init__(f"no transition from {state.value} on {event!r}")
self.state = state
self.event = event
#: The nine events the FSM knows (PLAN §6 plus the two idle/close rules).
ROOM_EVENTS: Final[tuple[str, ...]] = (
"host_start",
"countdown_done",
"round_over",
"to_lobby",
"retry",
"next_level",
"close",
"all_seats_empty",
"idle_expired",
)
#: The transition table as data: `(from state, event) -> to state`. Anything
#: not listed is an `IllegalTransition` — there is no other way to change the
#: state (except `close()`, which takes the `close` entry).
RoomTransition = dict[tuple[RoomState, str], RoomState]
ROOM_TRANSITIONS: Final[RoomTransition] = {
(RoomState.LOBBY, "host_start"): RoomState.COUNTDOWN,
(RoomState.COUNTDOWN, "countdown_done"): RoomState.PLAYING,
(RoomState.PLAYING, "round_over"): RoomState.ROUND_END,
(RoomState.ROUND_END, "to_lobby"): RoomState.LOBBY,
(RoomState.ROUND_END, "retry"): RoomState.COUNTDOWN,
(RoomState.ROUND_END, "next_level"): RoomState.COUNTDOWN,
(RoomState.LOBBY, "close"): RoomState.CLOSED,
(RoomState.COUNTDOWN, "close"): RoomState.CLOSED,
(RoomState.PLAYING, "close"): RoomState.CLOSED,
(RoomState.ROUND_END, "close"): RoomState.CLOSED,
(RoomState.LOBBY, "all_seats_empty"): RoomState.CLOSED,
(RoomState.ROUND_END, "all_seats_empty"): RoomState.CLOSED,
(RoomState.LOBBY, "idle_expired"): RoomState.CLOSED,
(RoomState.ROUND_END, "idle_expired"): RoomState.CLOSED,
}
class Room:
"""One match: code + seats + a guarded FSM + one owned tick task.
Construction is the supervisor's alone (it mints the code); the room
knows its settings (seats cap, tick rate), its `Clock`, and — when the
supervisor gives it the content bundle — its campaign, so it can resolve
level ids into `Level` objects (`select_level`/`next_level`) and build
the round's state. A room built without a bundle (task-01 style) lives in
the lobby only: it cannot start a round it has no level for.
Every timestamp comes from the injected `Clock`; the loop's sleep is
injectable too, so tests advance a `FakeClock` and never sleep.
"""
def __init__(
self,
*,
code: str,
settings: Settings,
clock: Clock,
level_id: str | None = None,
sleep: Callable[[float], Awaitable[None]] | None = None,
content: ContentBundle | None = None,
seed: int | None = None,
sessions: SessionLookup | None = None,
store: SessionStore | None = None,
on_close: Callable[[Room], Awaitable[None]] | None = None,
) -> None:
self.code = code
self._settings = settings
self._clock = clock
self._sleep = sleep if sleep is not None else asyncio.sleep
self._content = content
#: The token → `SessionRecord` lookup (`set_level`'s unlock check and
#: `lobby_payload`'s `unlocked` read the session's `unlocked_level`
#: through it); `None` (a test-built room) counts as unlocked 1.
self._sessions = sessions
#: The supervisor's `SessionStore` (task 04; `None` for a test-built
#: room): `set_level` asks its `is_unlocked`, `next_level_id(token)`
#: reads its `next_level_id`, and `round_over` records the result
#: into every seated player's session through it.
self._store = store
#: The close hook (task 05): the app's "room died" close — every
#: live socket of a closing room gets `err room_gone` + close
#: `4401`, so a client can tell "the room died" apart from "you
#: left". `None` for a test-built room (nothing to close).
self._on_close = on_close
#: Late joins (PLAN §6 "SPECTATING"): tokens that arrived while the
#: room was already past `LOBBY`. No seat, `role="spectator"`.
self.spectators: list[Spectator] = []
#: A token's last emote timestamps (the 2-per-1.0 s throttle).
self._emote_times: dict[str, list[float]] = {}
#: A token → colour memory for the life of the room (task 03: a
#: reconnect keeps the same colour if it is still free).
self._color_memory: dict[str, str] = {}
#: The level id the round will play (the campaign's first by default;
#: host-pickable from task 03 on). `None` = no level selected.
self.level_id = level_id
#: The resolved `Level`, or `None` when there is no bundle or no id.
self.level: Level | None = (
content.by_id.get(level_id)
if content is not None and level_id is not None
else None
)
self.state = RoomState.LOBBY
self.seats: list[Seat] = []
now = clock.now()
self.created_at = now
self.last_activity = now
self.state_since = now
#: When the last seat left; `None` while the room is occupied.
self.empty_since: float | None = None
self.closed_at: float | None = None
# -- the round (task 02) -------------------------------------------
self.countdown_left: float = COUNTDOWN_S
self.sim_state: KitchenState | None = None
#: `result_payload` of the last finished round (the body of the
#: phase-06 `round` message), or `None` before the verdict.
self.last_result: Mapping[str, object] | None = None
#: Which round this is in this room (0 = none yet); each entry into
#: `COUNTDOWN` starts a new one.
self.round_no: int = 0
#: The round's seed (the sim's `rng_state` start): the base `seed`
#: (or a 63-bit `secrets` draw) for round 1, an LCG step for every
#: later round — always fresh, always pinned from one base.
self.round_seed: int | None = None
#: Why the last round ended in a crash (`None` when it did not).
self.abort_reason: str | None = None
#: Sim events since the last boundary (the phase-06 `ev`/snapshot
#: buffer): every entry is seq-tagged in `_event_seqs` (the wire's
#: cursor reads through `event_buffer`) and the list is capped at
#: `EVENT_BUFFER_CAP` (the oldest drops first).
self.events_since_snapshot: list[Event] = []
#: The monotonic seq of the last buffered event (0 = none yet).
self._event_seq: int = 0
#: The buffer's seqs, parallel to `events_since_snapshot`.
self._event_seqs: list[int] = []
#: Events lost to the per-frame cap (task 04, `stats()`).
self._events_dropped: int = 0
#: How many snapshot boundaries have passed since the room started.
self.snapshot_seq: int = 0
#: The snapshot pacing accumulators (task 04, in room-tick units):
#: the player pass is due every `tick_hz / snapshot_hz` ticks, the
#: spectator pass every `2 x` that (PLAN §4: spectators get half).
self._snap_player_accum: float = 0.0
self._snap_spectator_accum: float = 0.0
#: The whole second last broadcast in the countdown (task 04: the
#: `countdown` frame per whole second, 3 → 2 → 1).
self._countdown_last_n: int | None = None
#: The room's broadcast seam (task 04): the wire layer's fanout
#: (the app installs it; `None` for a test-built room — the event
#: buffer then keeps accumulating, its cap the only bound).
self._broadcaster: Broadcaster | None = None
self._queued_inputs: list[PlayerInput] = []
#: The tokens whose park release already landed (task 05): one
#: release per token per round; the release itself is idempotent
#: (`sim/ghost.py`), so the set only skips the re-check.
self._parked: set[str] = set()
#: The input discipline's counters (task 03, `stats()`):
#: `inputs_received` (frames queued), `inputs_dropped` (frames lost
#: to the per-tick queue's overflow), `stale_seqs` (backwards-seq
#: refusals), `clamped_moves` (frames whose `move` magnitude
#: exceeded 1.0 and was clamped) and `input_gaps` (accepted
#: frames whose `seq` jumped more than 3 ahead — tolerated,
#: counted).
self._input_stats: dict[str, int] = {
"inputs_received": 0,
"inputs_dropped": 0,
"stale_seqs": 0,
"clamped_moves": 0,
"input_gaps": 0,
}
self._settle_left: float | None = None
self._last_at: float | None = None
#: The test-only seam (the `UNDERCOOKED_TEST_HOOK`): a `Callable`
#: with `step`'s signature that, when set, stands in for `step`
#: in the `PLAYING` tick. Tests set it to inject a fault into one
#: room (the crash-isolation suite) — it is never read from the
#: network, and a production room has `None` here.
self._test_step_override: StepFn | None = None
self._seed_cursor = seed if seed is not None else secrets.randbelow(_SEED_MODULUS)
self._task: asyncio.Task[None] | None = None
# ------------------------------------------------------------------ seats
@property
def max_players(self) -> int:
return self._settings.max_players
@property
def is_full(self) -> bool:
return len(self.seats) >= self.max_players
@property
def is_empty(self) -> bool:
return not self.seats
@property
def settings(self) -> Settings:
return self._settings
@property
def content(self) -> ContentBundle | None:
return self._content
@property
def sessions(self) -> SessionLookup | None:
"""The room's token → session lookup (or `None` for a test-built
room; the supervisor always sets it)."""
return self._sessions
@property
def store(self) -> SessionStore | None:
"""The room's `SessionStore` (task 04; `None` for a test-built
room without one — such a room records nothing)."""
return self._store
def seat_for_token(self, token: str) -> Seat | None:
"""The occupant of the seat held by `token`, or `None`."""
for seat in self.seats:
if seat.token == token:
return seat
return None
def next_seat_index(self) -> int:
"""The lowest seat index nobody holds (0-based).
Raises:
IndexError: the room is full.
"""
taken = {seat.seat_index for seat in self.seats}
for index in range(self.max_players):
if index not in taken:
return index
msg = f"room {self.code} has no free seat ({self.max_players} max)"
raise IndexError(msg)
def add_seat(
self, token: str, display_name: str, host: bool, now: float | None = None
) -> Seat:
"""Seat a player in the lowest free slot and stamp the timestamps.
`player_id` is the sim's identity for the seat (the token — see
`Seat`); `color` is assigned by the colour pass (first free palette
colour in seat order; a returning token keeps its remembered colour).
Raises:
ValueError: the room is full.
"""
if self.is_full:
msg = f"room {self.code} is full ({self.max_players} max)"
raise ValueError(msg)
when = self._clock.now() if now is None else now
seat = Seat(
token=token,
display_name=display_name,
seat_index=self.next_seat_index(),
host=host,
connected=True,
last_seen=when,
player_id=token,
)
self.seats.append(seat)
self.empty_since = None
self.last_activity = when
self._assign_colors()
return seat
def remove_seat(self, token: str, now: float | None = None) -> bool:
"""Remove a seat; when the room empties, start the GC grace clock.
When the removed seat held the crown, `transfer_host` runs — the
host *left*, the documented trigger (docs/ROOMS.md "Host rules").
Returns:
True when a seat was removed, False when the token was not seated.
"""
when = self._clock.now() if now is None else now
seat = self.seat_for_token(token)
before = len(self.seats)
self.seats = [seat_ for seat_ in self.seats if seat_.token != token]
if len(self.seats) == before:
return False
if not self.seats:
self.empty_since = when
self.last_activity = when
if seat is not None and seat.host:
self.transfer_host()
return True
def mark_seat_disconnected(self, token: str, now: float | None = None) -> bool:
"""Mark a seat's connection dropped (the rejoin path keeps the
seat). The raw mark — the supervisor and the wire's `finally`
use `on_disconnect` (this verb + `GHOST_ON` + the sim switch).
When the marked seat held the crown, `transfer_host` runs — a
dropped host connection is a host *leave* for the crown's purposes
(docs/ROOMS.md "Host rules").
Returns:
True when a seated token was marked, False when it was not seated.
"""
when = self._clock.now() if now is None else now
seat = self.seat_for_token(token)
if seat is None:
return False
seat.mark_disconnected(when)
self.last_activity = when
if seat.host:
self.transfer_host()
return True
def mark_seat_connected(self, token: str, now: float | None = None) -> bool:
"""Mark a seat's connection restored (the rejoin path, US-02).
The raw mark — the supervisor's rejoin uses `on_reconnect` (this
verb + `GHOST_OFF` + the sim restore).
Deliberately does **not** run `transfer_host`: a returning host does
not automatically regain the crown (docs/ROOMS.md "Host rules" —
explicit, avoids flip-flopping).
Returns:
True when a seated token was marked, False when it was not seated.
"""
when = self._clock.now() if now is None else now
seat = self.seat_for_token(token)
if seat is None:
return False
seat.mark_connected(when)
self.last_activity = when
return True
def set_ready(self, token: str, ready: bool) -> bool:
"""Set the seat's ready flag (the ready-up, US-03).
Returns:
True when a seated token was marked, False when it was not seated.
"""
seat = self.seat_for_token(token)
if seat is None:
return False
seat.ready = ready
self.note_activity()
return True
def note_activity(self, now: float | None = None) -> None:
"""Stamp the last meaningful event — what the lobby-idle GC measures."""
self.last_activity = self._clock.now() if now is None else now
# ------------------------------------------- the ghost and seat grace (task 05)
def on_disconnect(self, token: str, now: float | None = None) -> bool:
"""A socket drop (task 05, US-09): the higher-level verb the
supervisor and the wire's `finally` use (above
`mark_seat_disconnected`, which stays the raw mark).
Marks the seat `connected=False` (kept for the rejoin path; the
crown rule runs through the mark), emits one `GHOST_ON` (the
ghost presentation for the others), and switches the sim's cook
(`connected=False`, state `DISCONNECTED` — `sim/ghost.py`). A
seat that is *already* a ghost is a no-op: one `GHOST_ON` per
drop, never a second.
Returns:
True when a seated token was (or already was) marked, False
when it was not seated.
"""
when = self._clock.now() if now is None else now
seat = self.seat_for_token(token)
if seat is None:
return False
if seat.connected:
self.mark_seat_disconnected(token, now=when)
self._emit_ghost(seat, EventKind.GHOST_ON)
self._switch_sim_player(token, ghost=True)
return True
def on_reconnect(self, token: str, now: float | None = None) -> bool:
"""A rejoin inside the grace (task 05): the higher-level verb the
supervisor's `join` rejoin path uses (above
`mark_seat_connected`, which stays the raw mark).
Marks the seat connected again, emits one `GHOST_OFF` (only when
the seat actually was a ghost — a duplicate `hello` on a live
socket emits nothing), and restores the sim's cook
(`connected=True`, state `IDLE` — a still-carried item is kept;
an already-parked cook comes back empty-handed).
Returns:
True when a seated token was marked, False when it was not
seated (the seat was abandoned: the spectate/join rules
apply — a new seat or a spectator, the supervisor's call).
"""
when = self._clock.now() if now is None else now
seat = self.seat_for_token(token)
if seat is None:
return False
was_ghost = not seat.connected
self.mark_seat_connected(token, now=when)
if was_ghost:
self._emit_ghost(seat, EventKind.GHOST_OFF)
self._switch_sim_player(token, ghost=False)
return True
def _emit_ghost(self, seat: Seat, kind: EventKind) -> None:
"""One ghost event, at the cook's position (0,0 in a lobby, where
the sim is not built yet): the payload carries the player id
(the wire's `i`)."""
x = y = 0.0
sim = self.sim_state
if sim is not None:
player = next(
(p for p in sim.players if p.id == seat.player_id), None
)
if player is not None:
x, y = player.pos.x, player.pos.y
self._buffer_event(
Event(kind=kind, x=x, y=y, payload={"player_id": seat.player_id})
)
def _switch_sim_player(self, token: str, ghost: bool) -> None:
"""The sim-side half of a (dis)connect (the pure functions in
`sim/ghost.py`, applied only when they would change anything):
a no-op when the round is not built, or the token has no sim
player (a seat added after the round's build), or the sim's cook
already holds the target state."""
sim = self.sim_state
if sim is None:
return
player = next((p for p in sim.players if p.id == token), None)
if player is None:
return
if ghost:
if player.connected or player.state is not PlayerState.DISCONNECTED:
self.sim_state = mark_ghost(sim, player)
elif not player.connected:
self.sim_state = restore_player(sim, player)
def _park_ghosts(self, when: float) -> None:
"""The park deadline, every `PLAYING` tick (task 05): a ghost
cook's carried item is released once `PARK_AFTER_S` (3.0 s)
of disconnection has passed — onto the station on the cook's
tile (a marked set-down, a `DROPPED`), or into
`counters["lost_items"]` (a `GHOST_PARK`) when no station sits
under the tile; an item is never deleted silently. One release
per token per round; the release is idempotent, so a double
call cannot double-release."""
sim = self.sim_state
if sim is None:
return
for seat in self.seats:
if seat.connected or seat.token in self._parked:
continue
player = next(
(p for p in sim.players if p.id == seat.player_id), None
)
if player is None:
continue
if (
player.connected
or player.state is not PlayerState.DISCONNECTED
):
sim = mark_ghost(sim, player)
player = next(p for p in sim.players if p.id == seat.player_id)
if when - seat.last_seen + _EPS_S >= PARK_AFTER_S:
sim, events = release_ghost_carry(sim, player)
for event in events:
self._buffer_event(event)
self._parked.add(seat.token)
self.sim_state = sim
def _abandon_expired(self, when: float) -> None:
"""`SeatAbandoned` (task 05): a seat disconnected for longer
than `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` is emptied — the rejoin
window is over. Runs in every state (the room's own copy; the
supervisor's GC applies the same rule externally)."""
limit = self._settings.guest_idle_timeout_s
for seat in list(self.seats):
if not seat.connected and when - seat.last_seen > limit:
self._abandon_seat(seat.token, when)
def _abandon_seat(self, token: str, when: float) -> None:
"""Empty one seat (the `SeatAbandoned`): a forced release of the
cook's carried item (the seat is going away; the sim's player
stays, parked — the release must not wait for the park deadline
when the grace is shorter), one `GHOST_OFF`, then the seat out
(a host transfer, `empty_since` for the close rules)."""
seat = self.seat_for_token(token)
if seat is None:
return
sim = self.sim_state
if sim is not None:
player = next(
(p for p in sim.players if p.id == seat.player_id), None
)
if (
player is not None
and (
player.carrying is not None
or player.connected
or player.state is not PlayerState.DISCONNECTED
)
):
sim, events = release_ghost_carry(sim, player)
for event in events:
self._buffer_event(event)
self.sim_state = sim
self._parked.add(token)
self._emit_ghost(seat, EventKind.GHOST_OFF)
self.remove_seat(token, now=when)
# ------------------------------------------------------------ the FSM
def _fire(self, event: str) -> RoomState:
"""Take one transition: table lookup, state write, nothing else.
Raises:
IllegalTransition: the pair is not in `ROOM_TRANSITIONS`.
"""
to = ROOM_TRANSITIONS.get((self.state, event))
if to is None:
raise IllegalTransition(self.state, event)
self.state = to
self.state_since = self._clock.now()
return to
def _begin_round(self) -> None:
"""Start a new round: `round_no` up, a fresh seed, the countdown reset.
Shared by `host_start`, `retry` and `next_level` (each fires its own
event first — the table decides the move, this decides the data).
The first round plays the base `seed` itself; every later round steps
the cursor, so the seed is fresh (and, from one base, pinned).
"""
self.round_no += 1
if self.round_no > 1:
self._seed_cursor = (
self._seed_cursor * _SEED_MULTIPLIER + _SEED_INCREMENT
) % _SEED_MODULUS
self.round_seed = self._seed_cursor
self.countdown_left = COUNTDOWN_S
self._queued_inputs = []
self._parked.clear()
self._settle_left = None
self._countdown_last_n = None
def request_start(self) -> None:
"""The host's *Start*: ≥ 1 ready non-host, or a solo host (PLAN §6),
and a selected level. Fires `host_start` (LOBBY -> COUNTDOWN).
Raises:
StartRefused: `"no_level"` or `"no_ready_players"`.
IllegalTransition: the room is not in `LOBBY`.
"""
if self.level is None:
raise StartRefused("no_level")
ready_others = sum(1 for seat in self.seats if seat.ready and not seat.host)
solo_host = len(self.seats) == 1 and self.seats[0].host
if ready_others == 0 and not solo_host:
raise StartRefused("no_ready_players")
self._begin_round()
self._fire("host_start")
def to_lobby(self) -> None:
"""Return to the lobby after a round (PLAN §6 "next-level / retry").
The ready flags reset — a fresh lobby readies up fresh — and the
round data (`sim_state`, `last_result`) stays for inspection until
the next build replaces it. Waiting spectators are promoted into the
free seats (PLAN §6: "late joins wait in `SPECTATING` until
`LOBBY`); when the room is full, the surplus stays watching.
"""
self._fire("to_lobby")
for seat in self.seats:
seat.ready = False
remaining: list[Spectator] = []
for spec in self.spectators:
if not self.is_full:
self.add_seat(
spec.token, spec.display_name, host=False, now=spec.joined_at
)
else:
remaining.append(spec)
self.spectators = remaining
def retry(self) -> None:
"""Replay the same level (PLAN §6 "retry") with a fresh seed.
Raises:
IllegalTransition: the room is not in `ROUND_END`.
"""
self._begin_round()
self._fire("retry")
def select_level(self, level_id: str) -> Level:
"""Resolve `level_id` in the room's campaign and make it `self.level`.
A data change, not a transition — `LOBBY`'s host pick (task 03) and
`next_level` both go through it.
Raises:
ValueError: the room has no content bundle, or the id is not in it.
"""
if self._content is None:
msg = f"room {self.code} has no content bundle to select a level from"
raise ValueError(msg)
level = self._content.by_id.get(level_id)
if level is None:
msg = f"level {level_id!r} is not in room {self.code}'s campaign"
raise ValueError(msg)
self.level = level
self.level_id = level_id
return level
def next_level(self, level_id: str) -> None:
"""Switch to `level_id` and start it (ROUND_END -> COUNTDOWN).
Raises:
ValueError: the room has no bundle, or the id is not in it
(the state is left untouched).
IllegalTransition: the room is not in `ROUND_END`.
"""
self.select_level(level_id)
self._begin_round()
self._fire("next_level")
def next_level_id(self, token: str) -> str | None:
"""The next campaign level for `token`'s session (task 04 wires
the `next_level` value from the session store): the store's
`next_level_id` over the room's campaign — the lowest unlocked
level with no best run yet (after a win, `order + 1`'s level;
after a loss, the level just played).
`None` when the room has no store or campaign (a test-built
room cannot advise), or the campaign is done for that session.
"""
if self._store is None or self._content is None:
return None
return self._store.next_level_id(token, self._content)
# ------------------------------------------------- the lobby's verbs (task 03)
@property
def can_start(self) -> bool:
"""Whether a start pressed now would be accepted: `LOBBY`, a
selected level, a crown holder, and the ready-up precondition
(a solo host, or ≥ 1 ready non-host — PLAN §6). The client's
Start button renders off this.
"""
if self.state is not RoomState.LOBBY or self.level is None:
return False
if not any(seat.host for seat in self.seats):
return False
ready_others = sum(1 for seat in self.seats if seat.ready and not seat.host)
return ready_others > 0 or (len(self.seats) == 1 and self.seats[0].host)
def start(self, token: str) -> None:
"""The host's *Start*, by token (US-03): the guards, then the same
preconditions as `request_start`.
Raises:
NotInLobby: the room is not in `LOBBY`.
NotHost: the token holds no seat, or a non-host one.
NothingToStart: a `StartRefused` — `"no_level"` or
`"no_ready_players"` (a solo host needs neither).
"""
if self.state is not RoomState.LOBBY:
msg = f"room {self.code} is not in the lobby ({self.state.value})"
raise NotInLobby(msg)
if not self._is_host(token):
msg = f"only the host can start room {self.code}"
raise NotHost(msg)
try:
self.request_start()
except StartRefused as exc:
raise NothingToStart(exc.reason) from exc
def set_level(self, token: str, level_id: str) -> Level:
"""The host's level pick (PLAN §6 "level pick (host; limited to
unlocked)"): `LOBBY` only, host only, the level in the campaign
and unlocked by the requesting session.
Raises:
NotInLobby: the room is not in `LOBBY`.
NotHost: the token is not the crown holder.
UnknownLevel: the id is not in the campaign (a bundle-less
room has no campaign).
LevelLocked: the level's `order` is past the session's
`unlocked_level` — the room asks its `SessionStore`'s
`is_unlocked` when it owns one (falling back to the
`sessions` lookup: a token with no session record counts
as unlocked 1 — the campaign's floor).
"""
if self.state is not RoomState.LOBBY:
msg = f"room {self.code} is not in the lobby ({self.state.value})"
raise NotInLobby(msg)
if not self._is_host(token):
msg = f"only the host can pick the level in room {self.code}"
raise NotHost(msg)
level = self._content.by_id.get(level_id) if self._content is not None else None
if level is None:
msg = f"level {level_id!r} is not in room {self.code}'s campaign"
raise UnknownLevel(msg)
record = (
self._store.get(token)
if self._store is not None
else self._session_for(token)
)
unlocked = record.unlocked_level if record is not None else 1
locked = (
not self._store.is_unlocked(token, level_id)
if self._store is not None
else level.order > unlocked
)
if locked:
msg = (
f"level {level_id!r} is campaign order {level.order}; "
f"the session is unlocked to {unlocked}"
)
raise LevelLocked(msg)
return self.select_level(level_id)
def transfer_host(self) -> bool:
"""Move the crown when the host leaves (or their connection
drops) — called by `remove_seat` / `mark_seat_disconnected` when
the affected seat held it.
In `LOBBY` and `ROUND_END` the crown goes to the lowest occupied,
*connected* seat (by seat index); when no connected seat remains
the host slot is left empty. In `COUNTDOWN`/`PLAYING` the round
continues: the host slot is emptied and **no mid-round election
happens** (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
(docs/ROOMS.md "Host rules").
Returns:
True when a new host was elected, False when the slot is empty.
"""
for seat in self.seats:
seat.host = False
if self.state not in (RoomState.LOBBY, RoomState.ROUND_END):
return False
for seat in sorted(self.seats, key=lambda s: (s.seat_index, s.token)):
if seat.connected:
seat.host = True
return True
return False
def _is_host(self, token: str) -> bool:
seat = self.seat_for_token(token)
return seat is not None and seat.host
def _session_for(self, token: str) -> SessionRecord | None:
if self._sessions is None:
return None
return self._sessions(token)
# ------------------------------------------------------ spectators (task 03)
def spectator_for_token(self, token: str) -> Spectator | None:
"""The spectator entry held by `token`, or `None`."""
for spec in self.spectators:
if spec.token == token:
return spec
return None
def add_spectator(
self, token: str, display_name: str, now: float | None = None
) -> Spectator:
"""Register (or reclaim) a spectator: a token that arrived while
the room was already past `LOBBY` (PLAN §6 "SPECTATING"). No
seat, `role="spectator"`; a colour from the same first-free
palette (seats take first). Reclaiming marks the entry connected
again and updates the name when a new one was typed.
"""
when = self._clock.now() if now is None else now
existing = self.spectator_for_token(token)
if existing is not None:
existing.mark_connected(when)
if display_name:
existing.display_name = display_name
self._assign_colors()
self.note_activity(when)
return existing
spec = Spectator(
token=token,
display_name=display_name,
color="",
joined_at=when,
last_seen=when,
)
self.spectators.append(spec)
self._assign_colors()
self.note_activity(when)
return spec
def remove_spectator(self, token: str) -> bool:
"""Remove a spectator entry (the `leave` for a watcher).
Returns:
True when an entry was removed, False when the token was not
watching.
"""
before = len(self.spectators)
self.spectators = [spec for spec in self.spectators if spec.token != token]
return len(self.spectators) != before
def mark_spectator_disconnected(self, token: str, now: float | None = None) -> bool:
"""Mark a spectator's connection dropped (the entry is kept; a
genuine `leave` removes it).
Returns:
True when a watching token was marked, False when it was not.
"""
when = self._clock.now() if now is None else now
spec = self.spectator_for_token(token)
if spec is None:
return False
spec.mark_disconnected(when)
self.note_activity(when)
return True
def mark_spectator_connected(self, token: str, now: float | None = None) -> bool:
"""Mark a spectator's connection restored (the entry is kept).
Returns:
True when a watching token was marked, False when it was not.
"""
when = self._clock.now() if now is None else now
spec = self.spectator_for_token(token)
if spec is None:
return False
spec.mark_connected(when)
self.note_activity(when)
return True
# ------------------------------------------------------------------ colours
def _assign_colors(self) -> None:
"""Hand out the 4-colour palette (task 03): first free colour, in
seat order — seats by seat index, then spectators in join order.
A token's colour is remembered for the life of the room, so 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 keeps
`""` — the palette is exactly four.)
"""
ordered = (
sorted(self.seats, key=lambda s: (s.seat_index, s.token))
+ list(self.spectators)
)
used: set[str] = set()
for entry in ordered:
remembered = self._color_memory.get(entry.token)
if remembered is not None and remembered not in used:
entry.color = remembered
used.add(remembered)
for entry in ordered:
if entry.color:
if entry.color in SEAT_PALETTE:
used.add(entry.color)
continue
for color in SEAT_PALETTE:
if color not in used:
entry.color = color
self._color_memory[entry.token] = color
used.add(color)
break
# ------------------------------------------------------------------ emotes
def emote(self, token: str, emote_id: str, now: float | None = None) -> bool:
"""Validate, throttle and broadcast one emote (PLAN §7: the
`emote` whitelist; no chat in v1). A seated player or a
spectator may emote; a token that holds neither is dropped.
The room must be `LOBBY` or `PLAYING` — elsewhere the emote is
dropped (the phase-06 per-state whitelist is the wire-side copy
of this rule). An id outside `EMOTES` is an `UnknownEmote`, never
a silent no-op. The throttle is 2 per 1.0 s per connection (one
connection per token): a third emote inside the window is
dropped, not queued.
Raises:
UnknownEmote: the id is not in `EMOTES`.
Returns:
True when the emote was accepted and buffered as an `ev`
event (kind `"emote"`; the id and the token ride in the
payload); False when it was dropped.
"""
when = self._clock.now() if now is None else now
if self.state not in (RoomState.LOBBY, RoomState.PLAYING):
return False
if emote_id not in EMOTES:
msg = f"{emote_id!r} is not an emote (whitelist: {sorted(EMOTES)})"
raise UnknownEmote(msg)
if self.seat_for_token(token) is None and self.spectator_for_token(token) is None:
return False
recent = [t for t in self._emote_times.get(token, ()) if when - t < EMOTE_WINDOW_S]
if len(recent) >= EMOTE_MAX_PER_WINDOW:
return False
recent.append(when)
self._emote_times[token] = recent
self._buffer_event(
Event(
kind=EMOTE_EVENT_KIND,
payload={"emote": emote_id, "player_id": token},
)
)
self.note_activity(when)
return True
# ------------------------------------------------------------- the payload
def lobby_payload(self, viewer_token: str | None = None) -> dict[str, object]:
"""The body of the phase-06 `lobby` message (PLAN §7), as the
`viewer_token` session sees it (an anonymous viewer — or no
lookup — sees `"unlocked": null`).
Shape: `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`), `round_no`. Tokens
never leave the room in this payload.
"""
session = (
self._session_for(viewer_token) if viewer_token is not None else None
)
host = next((seat for seat in self.seats if seat.host), None)
return {
"code": self.code,
"state": self.state.value,
"level": self.level_id,
"host_seat": host.seat_index if host is not None else None,
"can_start": self.can_start,
"seats": [
{
"index": seat.seat_index,
"name": seat.display_name,
"color": seat.color,
"ready": seat.ready,
"host": seat.host,
"connected": seat.connected,
}
for seat in sorted(self.seats, key=lambda s: (s.seat_index, s.token))
],
"spectators": [
{"name": spec.display_name, "color": spec.color}
for spec in self.spectators
],
"unlocked": session.unlocked_level if session is not None else None,
"round_no": self.round_no,
}
# -------------------------------------------------------------- input
#: The `stats()` vocabulary — anything else is a programming error.
INPUT_STAT_NAMES: Final[tuple[str, ...]] = (
"inputs_received",
"inputs_dropped",
"stale_seqs",
"clamped_moves",
"input_gaps",
)
#: The per-tick input queue's capacity, per seat (task 03): at 4
#: seats the queue holds 16 frames; overflow drops the *oldest*
#: entry for the arriving player (the newest intent wins) and bumps
#: `inputs_dropped` — the queue never grows unbounded.
INPUT_QUEUE_PER_SEAT: Final[int] = 4
#: A `seq` may jump at most this far ahead of the last accepted one
#: before the jump is counted in `input_gaps` (task 03: "gaps of
#: ≤ 3 are tolerated (UDP-ish loss), larger gaps accepted but logged
#: in room stats").
TOLERATED_SEQ_GAP: Final[int] = 3
#: The event buffer's cap (task 04): the oldest drops first. In a
#: live room the wire's cursor has already consumed everything but
#: the current boundary, so the cap only bounds a room without a
#: wire (a test-built one keeps accumulating; its bound is this).
EVENT_BUFFER_CAP: Final[int] = 1024
def stats(self) -> dict[str, object]:
"""A fixed-shape census of the room's input discipline (task 03):
`inputs_received`, `inputs_dropped`, `stale_seqs`, `clamped_moves`
and `input_gaps` — plus `events_dropped` (task 04: events lost
to the per-frame cap, `MAX_EVENTS_PER_FRAME`)."""
return {**dict(self._input_stats), "events_dropped": self._events_dropped}
def note_event_dropped(self, amount: int) -> None:
"""Count the events a boundary lost to the per-frame cap
(task 04: `stats().events_dropped`)."""
self._events_dropped += amount
def _buffer_event(self, event: Event) -> None:
"""One event into the buffer, seq-tagged (the wire's cursor
reads the pairs through `event_buffer`); the buffer is capped at
`EVENT_BUFFER_CAP` — the oldest drops first."""
self._event_seq += 1
self.events_since_snapshot.append(event)
self._event_seqs.append(self._event_seq)
while len(self.events_since_snapshot) > self.EVENT_BUFFER_CAP:
self.events_since_snapshot.pop(0)
self._event_seqs.pop(0)
@property
def event_buffer(self) -> list[tuple[int, Event]]:
"""The buffer's `(seq, event)` pairs (task 04): the wire's
cursor (`server.ws.SnapshotFanout`) reads through this and never
truncates it — the buffer's cap is the only thing that drops
entries, and only the oldest."""
return list(zip(self._event_seqs, self.events_since_snapshot, strict=True))
def set_broadcaster(self, broadcaster: Broadcaster | None) -> None:
"""Install (or remove) the broadcast seam (task 04): the wire
layer's fanout, called from the tick task with the audiences due
(`"player"` / `"spectator"` / `"ev"`) and the state frames to
fan out (the `countdown` / `round` payloads)."""
self._broadcaster = broadcaster
@property
def broadcaster(self) -> Broadcaster | None:
"""The installed broadcast seam, or `None` (a test-built room)."""
return self._broadcaster
@property
def snapshot_period_ticks(self) -> float:
"""Room ticks between the player encode passes (PLAN §4): `tick_hz
/ snapshot_hz` (≥ 1); the spectator passes run at `2 x` that.
"""
return self._settings.tick_hz / self._settings.snapshot_hz
def _snapshot_pacing_tick(self) -> None:
"""One PLAYING tick's snapshot pacing (task 04): the player pass
is due every `tick_hz / snapshot_hz` ticks, the spectator pass
every `2 x` that — due passes reach the broadcaster, which owns
the encode (the encoding itself is `server/encoding.py`, the
fan-out `server.ws.SnapshotFanout`)."""
if self._broadcaster is None:
return
self._snap_player_accum += 1.0
self._snap_spectator_accum += 1.0
period = self.snapshot_period_ticks
due: set[str] = set()
if self._snap_player_accum >= period - 1e-9:
due.add("player")
self._snap_player_accum = 0.0
if self._snap_spectator_accum >= 2.0 * period - 1e-9:
due.add("spectator")
self._snap_spectator_accum = 0.0
if due:
self._broadcaster(due, ())
def _broadcast_state(self, payload: dict[str, object]) -> None:
"""One state frame (the `countdown` / `round` payload, PLAN §7)
to the wire — to every connection (the fanout fans it out; the
`round` frame's viewer-specific `unlocked` it fills in per
connection)."""
if self._broadcaster is not None:
self._broadcaster(set(), (payload,))
def _broadcast_round(self) -> None:
"""The phase-06 `round` frame (PLAN §7): `result_payload`'s exact
keys plus `unlocked` — the `None` placeholder the fanout replaces
with the viewer's next level id."""
if self.last_result is None:
return
payload: dict[str, object] = {"type": "round", "unlocked": None}
payload.update(dict(self.last_result))
self._broadcast_state(payload)
def note_input_stat(self, name: str, amount: int = 1) -> None:
"""Bump one input-stat counter (a fixed vocabulary — the
discipline's layers in `ws.py` and `queue_input` report through
this, so the counters can never misspell).
Raises:
KeyError: `name` is not one of `INPUT_STAT_NAMES`.
"""
if name not in self.INPUT_STAT_NAMES:
msg = f"not an input stat: {name!r}"
raise KeyError(msg)
self._input_stats[name] += amount
def note_stale_seq(self, player_id: str, seq: int) -> None:
"""A backwards `seq` refusal (task 03): count it (`stale_seqs`)
and buffer the `resync` event the client hard-snapshots on.
The `err` frame itself (with the offending `seq`) is the
wire layer's; this is the room's half of the contract."""
self.note_input_stat("stale_seqs")
self._buffer_event(
Event(
kind=EventKind.RESYNC,
payload={"player_id": player_id, "seq": seq},
)
)
def queue_input(self, player_id: str, frame: PlayerInput) -> bool:
"""Queue one frame of intent for the next tick.
Only legal in `PLAYING` (elsewhere the frame is dropped — no error:
a client that sent late is not at fault); a `DISCONNECTED` seat is
silently ignored (task 03 — the connection that dropped is not the
one the frame arrived on, and the sim parks the cook anyway); an id
no seat holds is a buffered `err` event (`unknown_player`), never an
exception. The room stamps `received_at_tick` with the sim's current
tick, so input ordering never depends on asyncio scheduling or the
client's counter. The queue is per-tick with a capacity of
`INPUT_QUEUE_PER_SEAT x len(seats)`: at capacity, the *oldest* entry
for the arriving player is dropped (the newest intent wins,
`inputs_dropped` bumped) — the queue never grows unbounded.
`tick_once` passes it to `step` — on the next tick that advances a
substep (a wall-clock tick too short to fill one `STEP_DT` keeps
the queue, so a frame is never dropped without being applied) —
and clears it.
Returns:
True when the frame was queued; False when it was dropped
(wrong state, a disconnected seat, the overflow) or rejected
(`unknown_player`).
"""
if self.state is not RoomState.PLAYING:
return False
seat = self.seat_for_token(player_id)
if seat is None:
reason = (
"not_seated"
if self.spectator_for_token(player_id) is not None
else "unknown_player"
)
self._buffer_event(
Event(
kind=EventKind.ERROR,
payload={
"reason": reason,
"player_id": player_id,
"seq": frame.seq,
},
)
)
return False
if not seat.connected:
return False # silently ignored (task 03)
capacity = self.INPUT_QUEUE_PER_SEAT * len(self.seats)
if len(self._queued_inputs) >= capacity:
for index, queued in enumerate(self._queued_inputs):
if queued.player_id == player_id:
del self._queued_inputs[index]
break
else:
# The queue is full and none of it is this player's:
# the new frame is the drop (a player's own entries can
# only ever evict their own oldest — one spammer cannot
# clear another player's tick).
self.note_input_stat("inputs_dropped")
return False
self.note_input_stat("inputs_dropped")
tick = self.sim_state.tick if self.sim_state is not None else 0
self._queued_inputs.append(replace(frame, received_at_tick=tick))
self.note_input_stat("inputs_received")
return True
def take_events(self) -> tuple[Event, ...]:
"""Drain `events_since_snapshot` (the phase-06 `ev`/snapshot
boundary).
The buffer is drained exactly once per call: what is returned is
everything since the last call, and the next call starts empty
(the seq list drains with it — the wire's cursor is an absolute
seq, so a drain cannot make it skip or re-send). `snapshot_seq`
counts the boundary.
"""
events = tuple(self.events_since_snapshot)
self.events_since_snapshot.clear()
self._event_seqs.clear()
self.snapshot_seq += 1
return events
# ------------------------------------------------------- tick ownership
@property
def tick_period(self) -> float:
return 1.0 / self._settings.tick_hz
@property
def task(self) -> asyncio.Task[None] | None:
"""The room's one owned tick task, or `None` before `start_ticking()`."""
return self._task
def start_ticking(self) -> None:
"""Create the tick task (idempotent while it is running).
(The lobby's *Start* by token is `start(token)` — task 03; the
no-token host start is task 02's `request_start`.)
"""
if self._task is not None and not self._task.done():
return
self._task = asyncio.create_task(self.tick_loop(), name=f"room-{self.code}")
async def close(self) -> None:
"""Teardown — idempotent. Takes the `close` transition (legal from
every live state), cancels the tick task and (unless this *is* the
tick task's own call path) awaits it, then frees the seats. Task
cancellation is the only teardown (PLAN §6); there is no other way
to stop a room.
"""
if self.state is not RoomState.CLOSED:
self._fire("close")
self.closed_at = self._clock.now()
if self._on_close is not None:
await self._on_close(self)
if self._task is not None and not self._task.done():
self._task.cancel()
if self._task is not None and asyncio.current_task() is not self._task:
with contextlib.suppress(asyncio.CancelledError):
await self._task
self.seats.clear()
self.spectators.clear()
self._color_memory.clear()
self._emote_times.clear()
self._parked.clear()
async def tick_loop(self) -> None:
"""The room's one task: one `tick_once` per period until closed.
Deadline-scheduled off the injected clock — the next deadline is
computed (`deadline += period`), not slept as `1/tick_hz`, so the
cadence cannot accumulate drift. When the clock jumps (a lid, a test
advancing the `FakeClock`), a deadline that is more than a period in
the past resynchronises: the room catches up with at most one clamped
tick (the sim's `MAX_DT` clamp makes a burst worthless) and then
waits the normal period.
"""
period = self.tick_period
deadline = self._clock.now() + period
while self.state is not RoomState.CLOSED:
await self._sleep(max(0.0, deadline - self._clock.now()))
self.tick_once(self._clock.now())
deadline += period
if deadline < self._clock.now() - period:
deadline = self._clock.now() + period
def tick_once(self, now: float | None = None) -> None:
"""Advance the room by one tick — the synchronous core.
`now` (or the clock) sets the wall time; the step `dt` is the time
since the previous tick (0 on the first, floored at 0). The whole
body is crash-isolated: any exception logs (code + round, never a
player token), aborts the sim, buffers an `err` event, fires `close`
and never propagates out of the loop (one room must not kill the
server — `CancelledError` is not an `Exception`, so a teardown cancel
passes through untouched).
"""
when = self._clock.now() if now is None else float(now)
dt = 0.0 if self._last_at is None else max(0.0, when - self._last_at)
self._last_at = when
if self.state is RoomState.CLOSED:
return
try:
self._tick(dt, when)
except Exception as exc: # crash isolation: *any* tick failure aborts the room
self._crash_isolate(exc)
def _tick(self, dt: float, when: float) -> None:
"""One tick, by state (the body `tick_once` crash-isolates)."""
state = self.state
# The `SeatAbandoned` rule (task 05) runs in every state, before
# the boundary flush — a `GHOST_OFF` that lands this tick rides
# the same boundary.
self._abandon_expired(when)
# The non-`PLAYING` boundary: buffered events flush to the wire
# as the standalone `ev` frame (task 04) — the `PLAYING` events
# ride the snapshot pass instead. A room without a wire keeps
# its buffer (the cap is the bound).
if (
self._broadcaster is not None
and state is not RoomState.PLAYING
and self.events_since_snapshot
):
self._broadcaster({"ev"}, ())
if state is RoomState.COUNTDOWN:
self.countdown_left = max(0.0, self.countdown_left - dt)
n = math.ceil(self.countdown_left - _EPS_S)
if n >= 1 and n != self._countdown_last_n:
self._countdown_last_n = n
self._broadcast_state({"type": "countdown", "n": n})
if self.countdown_left <= _EPS_S:
self._fire("countdown_done")
self._build_sim_state()
return
if state is RoomState.PLAYING:
if self.sim_state is None or self._content is None:
msg = f"room {self.code} is PLAYING with no built state (internal invariant)"
raise RuntimeError(msg)
self._snapshot_pacing_tick()
# The park deadline (task 05): wall-clock rule, it runs on
# every `PLAYING` tick (a substep-less tick parks too).
self._park_ghosts(when)
sim = self.sim_state
# A room tick whose wall-clock `dt` (clamped exactly as `step`
# clamps it) does not fill one `STEP_DT` substep must not
# consume the per-tick input queue: `step()` would drop the
# frames without applying them, and a 20 Hz wall-clock loop
# jitters (48-51 ms), so a queued frame would be lost on a
# good fraction of ticks. Keeping the queue lets the next
# tick — the one that does advance substeps — apply it (the
# sim's contract: "inputs received since the previous call").
# A settled state (`result` set) still drains: `step` ignores
# its inputs there by design (the verdict is in).
delta = min(dt if dt >= 0.0 else 0.0, MAX_DT)
if sim.result is None and sim.accumulator + delta < STEP_DT - TICK_EPS:
return
queued = tuple(self._queued_inputs)
self._queued_inputs = []
stepper = (
self._test_step_override
if self._test_step_override is not None
else step
)
self.sim_state, events = stepper(
self.sim_state, dt, queued, self._content.recipe_book
)
# Through `_buffer_event`: every buffer entry is seq-tagged in
# `_event_seqs` (the wire's cursor reads the pairs non-
# destructively — an untagged extend would break the parallel
# and the next `event_buffer` read would raise).
# Through `_buffer_event`: every buffer entry is seq-tagged in
# `_event_seqs` (the wire's cursor reads the pairs non-
# destructively — an untagged extend would break the parallel
# and the next `event_buffer` read would raise).
for event in events:
self._buffer_event(event)
if self.sim_state.result is not None:
# The settle (sim/round_state.SETTLE_S): the first tick the
# verdict lands opens the window; it closes SETTLE_S of wall
# time later — the sim is the frozen stage-7 machine in
# between — and only then does the round end.
self._settle_left = (
SETTLE_S if self._settle_left is None else self._settle_left - dt
)
if self._settle_left <= _EPS_S:
self.last_result = result_payload(self.sim_state)
self._record_round_result()
self._fire("round_over")
self._broadcast_round()
return
# LOBBY or ROUND_END: the idle checks, the same rules and numbers the
# supervisor's GC applies externally (the room's own copy, so a room
# closes itself even if the GC is slow by a cycle).
if (
self.is_empty
and self.empty_since is not None
and when - self.empty_since > SEAT_GRACE_S
):
self._fire("all_seats_empty")
return
if when - self.last_activity > self._settings.lobby_idle_timeout_s:
self._fire("idle_expired")
def _build_sim_state(self) -> None:
"""Build tick 0 of the round: level + seated players + fresh seed.
The room does the seeding `sim/state_build.py` leaves to it: the
spawn shelves' stock from `level.station_items` (the sim carries no
legend) and the prop stock counters from `level.station_limits`
(`sim/stations.py`'s `DEFAULT_STOCK` is the stand-in a round built
without them sees; a room-built round is capped by the level).
"""
if self.level is None:
msg = f"room {self.code} has no level to build a round from"
raise RuntimeError(msg)
if self._content is None:
msg = f"room {self.code} has no content bundle to build a round from"
raise RuntimeError(msg)
assert self.round_seed is not None # _begin_round ran before the countdown
seat_specs = [
SeatSpec(
player_id=seat.player_id,
seat=seat.seat_index,
display_name=seat.display_name,
color=seat.color,
)
for seat in sorted(self.seats, key=lambda seat: (seat.seat_index, seat.token))
]
state = build_state(self.level, seat_specs, self.round_seed, self._content.recipe_book)
stations = list(state.stations)
for index, station in enumerate(stations):
if station.kind is StationKind.SPAWN_SHELF:
kind = self.level.station_items[station.id]
stations[index] = replace(
station, contents=(Item(kind, ItemForm.RAW),) * 8
)
limits = self.level.station_limits
self.sim_state = replace(
state,
stations=tuple(stations),
counters={
"plates_clean": limits.plates,
"plates_dirty": 0,
"pots_clean": limits.pots,
"pots_dirty": 0,
},
)
def _record_round_result(self) -> None:
"""The `round_over` half (task 04): record the finished round
into every seated player's session. A room without a store
(a test-built one) records nothing; spectators are not players
and are not recorded. A `LOST`/`ABORTED` verdict is still a
finished round: `rounds_played` / `dishes_served` count it,
and only a `WON` one touches the progression (the store's
rule)."""
if self._store is None or self.last_result is None or self.level is None:
return
for seat in self.seats:
self._store.record_result(seat.token, self.level, self.last_result)
def _crash_isolate(self, exc: Exception) -> None:
"""The crash-isolation seam: log, abort, tell the players, close.
The log line carries the code and the round number only — never a
player token (PLAN §11). The sim is marked `ABORTED` through
`sim.round_state.abort` (a round that already won or lost keeps its
verdict — the sim's own rule), an `err` event is buffered for the
phase-06 `ev`/`err` broadcast, and `close` fires (legal from every
live state) — the exception itself never propagates out of
`tick_once`.
"""
logger.exception(
"room %s round %d: the tick crashed (%s); aborting the room",
self.code,
self.round_no,
type(exc).__name__,
)
self.abort_reason = f"round {self.round_no} tick crashed: {type(exc).__name__}"
if self.sim_state is not None:
self.sim_state = abort(self.sim_state, self.abort_reason)
self._buffer_event(
Event(
kind=EventKind.ERROR,
payload={"reason": "tick_crash", "round": self.round_no},
)
)
self._fire("close")