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:
@@ -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
|
||||
Reference in New Issue
Block a user