**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`.
1676 lines
72 KiB
Python
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")
|