phase: 05_anonymous_lobbies_and_room_lifecycle

All green. Final report:

**Phase 05 final verification pass — complete, 100% green.**

**Verified (all 5 tasks already in `complete/`):**
- `RoomSupervisor` import, `POST /api/rooms` → 5-char Crockford code (`VBE8Y`), `GET /api/rooms/{code}` → seat list, join/session/404 flows — all live over HTTP
- `docs/ROOMS.md` present with FSM diagram, crash-isolation and "What is lost on restart" sections
- Phase tests all pass: lifecycle 4-seat `WON`→`LOBBY` (7 tests), crash isolation, GC/idle via `FakeClock` (no sleeps), FSM, tick cadence, supervisor, sessions

**Results:**
- `uv run pytest` → **1506 passed, 0 failed**; coverage **98.89%** (>90% floor; `room.py` 99%, `rooms.py` 97%, `sessions.py` 99% — all ≥95% per-module gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run undercooked-levels validate` → content OK; server boots, `/healthz` 200, client at `/` 200
- e2e US-01 suite re-run after fixes: 10 passed

**Documentation — inconsistencies found and fixed:**
- `README.md`: status now lists phases 01–05 complete, next pending `06_realtime_websocket_protocol`; dropped "(phase 05, in progress)"; 3 stale "Not implemented until phase 05." references corrected
- `app/static/js/app.js` + `tests/e2e/test_us01_landing_surface.py`: placeholder string updated in lockstep to "Not implemented until phase 07." (phase 07 task 02 wires the buttons), comments updated to reflect phase-05 completion
- `AGENTS.md`: reviewed in full — §6 already documents all phase-05 modules accurately; no changes needed

**Decisions:** no code behavior changes beyond the user-visible placeholder string; no live-infrastructure touch (only a local loopback server, stopped).

**Next pending phase:** `06_realtime_websocket_protocol`.
This commit is contained in:
2026-10-08 05:17:21 -04:00
parent 79b7f1e4e0
commit f51ba919af
35 changed files with 8391 additions and 32 deletions
+443
View File
@@ -0,0 +1,443 @@
"""Integration: the `Room`'s tick ownership (phase 05, task 02) — the 20 Hz
loop, the synchronous `tick_once` core, and crash isolation.
What this file pins:
* `tick_once` drives the sim forward — a scripted `move` input moves the
cook (the room queues the frame, the kernel steps it, the buffer drains),
* the cadence has no drift — 100 manual calls at 0.05 s land on exactly
100 substeps and exactly 5.0 s of wall time, and the async loop
(deadline-scheduled, PLAN §6) makes one tick per 0.05 s of wall time for
100 ticks,
* a clock jump resynchronises instead of paying a catch-up burst (the sim's
`MAX_DT` clamp makes a burst worthless; the tick count proves there was
none),
* the event buffer drains exactly once (`take_events`),
* crash isolation — an exception in the tick (the `step` seam, or the round
build) is logged (code + round, never a player token), aborts the sim
(`sim.round_state.abort`), buffers an `err` event, fires `close`, and
never propagates out of `tick_once` or the loop; the loop runs to its
own exit (the task ends normally, not cancelled),
* a crash closes *only* that room: a second live room (registered in the
supervisor, tick task running) keeps ticking, and the supervisor stays
alive,
* a `Room` driven by `tick_once` reaches `WON` on the shipped level 1 with
a scripted cook (the phase-04 full-round policy, through the room's own
build, queue and settle).
All time is a `FakeClock`; no test sleeps in real time.
"""
from __future__ import annotations
import asyncio
import logging
import sys
from pathlib import Path
from typing import Any
import pytest
from test_room_fsm import (
make_bundle,
make_level,
make_room,
seat_solo_host,
)
from undercooked.config import Settings
from undercooked.server.clock import FakeClock
from undercooked.server.room import Room, RoomState
from undercooked.server.rooms import RoomSupervisor
from undercooked.server.sessions import new_session
from undercooked.sim.content import ContentBundle, load_level_file
from undercooked.sim.model import Event, EventKind, ItemKind, KitchenState, RoundResult, Vec2
from undercooked.sim.recipe import RecipeBook
from undercooked.sim.step import PlayerInput
from undercooked.sim.step import step as real_step
pytestmark = pytest.mark.integration
#: The scripted full-round harness lives with the sim's tests (test-only).
_SIM_TESTS = Path(__file__).resolve().parents[1] / "sim"
if str(_SIM_TESTS) not in sys.path:
sys.path.insert(0, str(_SIM_TESTS))
from helpers import ( # noqa: E402 # type: ignore[reportMissingImports]
LEVEL1_YAML,
CookController,
load_book,
shelf_id_for,
)
#: One 20 Hz tick, in wall seconds (the sim's `STEP_DT`).
PERIOD = 0.05
def drive_countdown_via_clock(room: Room, clock: FakeClock) -> None:
"""The 3.0 s countdown in 0.05 s wall frames (clock-driven, the way the
manual `tick_once` tests run); leaves the room in `PLAYING`."""
for _ in range(80):
clock.advance(PERIOD)
room.tick_once()
if room.state is RoomState.PLAYING:
break
assert room.state is RoomState.PLAYING
assert room.sim_state is not None
def make_playing_room(**settings: Any) -> tuple[Room, FakeClock]:
"""A room in `PLAYING` with a built sim state (the countdown already ran)."""
bundle = make_bundle(make_level("06_playing"))
room, clock = make_room(bundle, **settings)
seat_solo_host(room)
room.request_start()
drive_countdown_via_clock(room, clock)
return room, clock
# ------------------------------------------------------------- tick_once
def test_tick_once_drives_the_sim_forward_under_a_scripted_input() -> None:
room, clock = make_playing_room()
state = room.sim_state
assert state is not None
start_x = state.players[0].pos.x
# An empty press at open floor: a named refusal (a sim event in the
# buffer, drained with the rest). The cook is on open floor at spawn,
# facing down into a wall row: no target in reach at all.
room.queue_input(
"tok-a",
PlayerInput(player_id="tok-a", action=True, face=state.players[0].facing),
)
clock.advance(PERIOD)
room.tick_once()
events = room.take_events()
denied = [e for e in events if e.kind is EventKind.DENIED]
assert denied, "the empty press must land a named refusal in the buffer"
assert denied[0].payload["reason"] == "nothing_here"
# And 50 scripted move ticks move the cook (the queue feeds `step`).
for _ in range(50):
room.queue_input("tok-a", PlayerInput(player_id="tok-a", move=Vec2(1.0, 0.0)))
clock.advance(PERIOD)
room.tick_once()
state = room.sim_state
assert state is not None
assert state.tick == 51 # one 0.05 s substep per wall 0.05 s: no slip
player = state.players[0]
assert player.pos.x > start_x # the scripted intent moved the cook
assert player.facing is not None and player.facing.value == "right"
# ---------------------------------------------------------------- cadence
def test_tick_cadence_has_no_drift_over_100_manual_calls() -> None:
# A 60 s level: the 100 ticks (5.0 s of sim) must not reach the verdict.
bundle = make_bundle(make_level("06_cadence", time_limit=60.0))
room, clock = make_room(bundle)
seat_solo_host(room)
room.request_start()
drive_countdown_via_clock(room, clock)
assert room.sim_state is not None and room.sim_state.tick == 0
wall_at_start = clock.now()
for _ in range(100):
clock.advance(PERIOD)
room.tick_once()
state = room.sim_state
assert state is not None
assert state.tick == 100 # exactly 100 substeps: no tick lost or doubled
# Wall time and sim time agree to the float: 100 ticks x 0.05 s = 5.0 s.
assert abs((clock.now() - wall_at_start) - 5.0) < 1e-9
async def test_the_tick_loop_schedules_on_deadlines_without_drift() -> None:
"""The async loop (PLAN §6 "one asyncio tick task") at the 20 Hz
deadline cadence: 100 ticks over exactly 5.0 s of `FakeClock` time —
the first the room's prime (dt 0), 60 the 3.0 s countdown, 39 the
round — with the wall time and the sim tick count agreeing to the
float."""
clock = FakeClock()
# A 60 s level: the 5.0 s of sim must not reach the verdict.
bundle = make_bundle(make_level("06_loop", time_limit=60.0))
async def yield_sleep(_seconds: float) -> None:
await asyncio.sleep(0) # the test is the time source (no pre-advance)
room = Room(
code="TEST1",
settings=Settings(),
clock=clock,
content=bundle,
level_id=bundle.levels[0].id,
sleep=yield_sleep,
)
seat_solo_host(room)
room.request_start()
room.start_ticking()
assert room.task is not None and not room.task.done()
await asyncio.sleep(0) # the room runs its first wait (0.05 s) and parks
for _ in range(100):
clock.advance(PERIOD)
await asyncio.sleep(0) # the room ticks to the deadline: one tick per period
state = room.sim_state
assert state is not None
assert room.state is RoomState.PLAYING
assert state.tick == 39 # 100 ticks: 1 prime (dt 0) + 60 countdown + 39 playing
assert abs(clock.now() - 5.0) < 1e-9 # 100 x 0.05 s, no drift
assert room.task is not None and not room.task.done() # still ticking
await room.close()
assert room.state is RoomState.CLOSED and room.task.cancelled()
async def test_the_tick_loop_resynchronises_after_a_clock_jump() -> None:
"""A clock jump (a lid, a test advancing the `FakeClock`) must not buy a
catch-up burst: the room takes at most one (clamped) catch-up tick and
then waits the normal period. A burst would show as an inflated sim tick
count (each catch-up tick clamps to 0.1 s = 2 substeps)."""
clock = FakeClock()
bundle = make_bundle(make_level("06_jump"))
async def yield_sleep(_seconds: float) -> None:
await asyncio.sleep(0) # the test is the time source (no pre-advance)
room = Room(
code="TEST1",
settings=Settings(),
clock=clock,
content=bundle,
level_id=bundle.levels[0].id,
sleep=yield_sleep,
)
seat_solo_host(room)
room.request_start()
room.start_ticking()
for _ in range(10): # 0.5 s: 1 prime + 9 decrements, the countdown is at 2.55 s
clock.advance(PERIOD)
await asyncio.sleep(0)
clock.advance(10.0) # the jump
await asyncio.sleep(0) # the one catch-up tick: dt 10.0 (clamped), the countdown ends
for _ in range(10): # 0.5 s of playing
clock.advance(PERIOD)
await asyncio.sleep(0)
state = room.sim_state
assert state is not None
assert room.state is RoomState.PLAYING
assert state.tick == 10 # the 0.5 s of playing: no burst (a burst would be >= 200)
assert abs(clock.now() - 11.0) < 1e-9 # 0.5 + 10.0 + 0.5
await room.close()
# --------------------------------------------------------- crash isolation
def test_an_injected_exception_aborts_and_closes_without_raising(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
caplog.set_level(logging.ERROR, logger="undercooked.server.room")
room, clock = make_playing_room()
assert room.sim_state is not None and room.sim_state.result is None
def boom(*args: object, **kwargs: object) -> tuple[KitchenState, tuple[Event, ...]]:
msg = "sim exploded"
raise RuntimeError(msg)
monkeypatch.setattr("undercooked.server.room.step", boom)
clock.advance(PERIOD)
room.tick_once() # must not propagate
assert room.state is RoomState.CLOSED
assert room.sim_state is not None
assert room.sim_state.result is RoundResult.ABORTED # the sim's `abort` seam
assert room.abort_reason is not None
assert "RuntimeError" in room.abort_reason
events = room.take_events()
crash_events = [
e for e in events if e.kind is EventKind.ERROR and e.payload.get("reason") == "tick_crash"
]
assert len(crash_events) == 1
assert crash_events[0].payload["round"] == room.round_no
# The log line carries the code and the round — never a player token.
assert "TEST1" in caplog.text and "round 1" in caplog.text
assert "tok-a" not in caplog.text
def test_a_crash_building_the_round_closes_the_room(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`countdown_done` builds the round; a build that raises (a room with no
level can be put in `COUNTDOWN` by the table directly) is crash-isolated
the same way — the room ends `CLOSED`, no state, no propagation."""
room, clock = make_room(None) # no content bundle
seat_solo_host(room)
room._fire("host_start") # test setup: the table's row
for _ in range(80):
clock.advance(PERIOD)
room.tick_once()
assert room.state is RoomState.CLOSED # the build raised; the crash isolated it
assert room.sim_state is None
assert room.abort_reason is not None and "RuntimeError" in room.abort_reason
async def test_the_tick_loop_swallows_the_crash_and_exits_cleanly(
monkeypatch: pytest.MonkeyPatch,
) -> None:
clock = FakeClock()
bundle = make_bundle(make_level("06_loop_crash"))
async def advancing_sleep(seconds: float) -> None:
clock.advance(seconds)
await asyncio.sleep(0)
room = Room(
code="TEST1",
settings=Settings(),
clock=clock,
content=bundle,
level_id=bundle.levels[0].id,
sleep=advancing_sleep,
)
seat_solo_host(room)
room.request_start()
def boom(*args: object, **kwargs: object) -> tuple[KitchenState, tuple[Event, ...]]:
msg = "sim exploded"
raise RuntimeError(msg)
monkeypatch.setattr("undercooked.server.room.step", boom)
room.start_ticking()
for _ in range(80):
if room.task is not None and room.task.done():
break
await asyncio.sleep(0)
assert room.state is RoomState.CLOSED
assert room.task is not None and room.task.done()
assert not room.task.cancelled() # the loop ran to its own exit
assert room.sim_state is not None
assert room.sim_state.result is RoundResult.ABORTED
assert await room.task is None # no exception escaped the loop
async def test_a_crash_closes_only_that_room(monkeypatch: pytest.MonkeyPatch) -> None:
"""The phase's completion criterion: an injected sim exception aborts and
closes one room; a second live room (supervisor-registered, tick task
running) keeps ticking, and the supervisor stays alive."""
clock = FakeClock()
bundle = make_bundle(make_level("06_bad"), make_level("06_good"))
async def parked_sleep(_seconds: float) -> None:
await asyncio.Event().wait() # never set: the tasks run but never step
supervisor = RoomSupervisor(Settings(), clock, bundle, sleep=parked_sleep)
room_bad = supervisor.create_room(new_session("Ada", clock), "Ada")
room_good = supervisor.create_room(new_session("Bob", clock), "Bob")
await supervisor.start() # starts the GC loop and every room's tick task
room_good.select_level("06_good")
room_bad.request_start()
room_good.request_start()
for _ in range(80): # both to PLAYING
clock.advance(PERIOD)
room_bad.tick_once()
room_good.tick_once()
await asyncio.sleep(0)
assert room_bad.state is RoomState.PLAYING
assert room_good.state is RoomState.PLAYING
assert room_good.task is not None and not room_good.task.done() # a live room
assert room_good.sim_state is not None
good_tick_before = room_good.sim_state.tick
def selective(
state: KitchenState,
dt: float,
inputs: tuple[PlayerInput, ...],
book: RecipeBook | None,
) -> tuple[KitchenState, tuple[Event, ...]]:
if state.level_id == "06_bad":
msg = "sim exploded in the bad room"
raise RuntimeError(msg)
return real_step(state, dt, inputs, book)
monkeypatch.setattr("undercooked.server.room.step", selective)
for _ in range(30):
clock.advance(PERIOD)
room_bad.tick_once() # the first one crashes; the rest are no-ops
room_good.tick_once()
await asyncio.sleep(0)
# The bad room: aborted and closed, its exception isolated.
assert room_bad.state is RoomState.CLOSED
assert room_bad.sim_state is not None
assert room_bad.sim_state.result is RoundResult.ABORTED
assert room_bad.abort_reason is not None
# The good room: untouched — still playing, still ticking, task alive.
assert room_good.state is RoomState.PLAYING
assert room_good.sim_state is not None
assert room_good.sim_state.tick == good_tick_before + 30
assert room_good.task is not None and not room_good.task.done()
# And the supervisor itself is alive.
assert supervisor.stats()["rooms"] == 2
await supervisor.shutdown()
assert room_bad.state is RoomState.CLOSED
assert room_good.state is RoomState.CLOSED # the shutdown teardown, not a crash
# ------------------------------------------------------------------ level 1
def test_a_room_driven_by_tick_once_reaches_won_on_level_1() -> None:
"""The phase's completion criterion: a `Room` driven by `tick_once` — its
own build (level + seat + seed + the room's shelf/prop seeding), its
input queue, its settle — reaches `WON` on the shipped level 1 with the
phase-04 scripted cook (the same policy `test_full_rounds.py` proves
headless, through the room instead of the bare kernel)."""
level = load_level_file(LEVEL1_YAML)
bundle = ContentBundle(recipe_book=load_book(), levels=(level,))
clock = FakeClock()
room = Room(
code="TEST1",
settings=Settings(),
clock=clock,
content=bundle,
level_id=level.id,
seed=0x5EED_0406, # the phase-04 harness's seed: round 1 plays it as-is
)
room.add_seat("tok-0", "cook 1", True)
room.request_start() # the solo host
drive_countdown_via_clock(room, clock)
assert room.sim_state is not None
assert room.sim_state.seed == room.round_seed
controller = CookController(0, shelf_id_for(room.sim_state, ItemKind.TOMATO), "any")
for _ in range(4000): # 150 s of level + the settle, with slack
if room.state is not RoomState.PLAYING:
break
clock.advance(PERIOD)
if room.sim_state.result is None:
frame = controller.next_frame(room.sim_state, room.sim_state.tick)
assert room.queue_input(frame.player_id, frame)
room.tick_once()
assert room.state is RoomState.ROUND_END
state = room.sim_state
assert state is not None
assert state.result is RoundResult.WON
assert state.score >= state.target_score
assert room.last_result is not None
assert room.last_result["result"] == "won"
assert room.last_result["score"] == state.score
assert room.last_result["stars"] in (1, 2, 3) # a win grades at least one star
# The verdict's events (incl. the one `ROUND_END`) landed in the buffer,
# and the buffer drains exactly once.
events = room.take_events()
assert any(e.kind is EventKind.ROUND_END for e in events)
assert any(e.kind is EventKind.SERVED for e in events)
assert room.take_events() == () # the second drain is empty
assert room.snapshot_seq == 2