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,312 @@
|
||||
"""Integration: one room's crash does not take the server down (phase 05,
|
||||
task 05; PLAN §6 "crash isolation") — the task's `UNDERCOOKED_TEST_HOOK`
|
||||
seam in action.
|
||||
|
||||
Two live rooms, both created through the supervisor and both driven by
|
||||
manual `tick_once` (a `FakeClock`; no test sleeps). An exception injected
|
||||
into room A's sim through the test-only `Room._test_step_override` seam
|
||||
(set only here — a `Callable` with `step`'s signature the `PLAYING` tick
|
||||
calls in the kernel's place; never read from the network):
|
||||
|
||||
* room A becomes `CLOSED` — the sim marked `ABORTED` through
|
||||
`sim.round_state.abort`, `abort_reason` names the exception type, and
|
||||
the log line carries the room's code and round **never a player
|
||||
token**;
|
||||
* the `err` event is delivered — one `tick_crash` in the buffer, the
|
||||
round number in the payload, drained exactly once;
|
||||
* room B keeps ticking correctly — its sim advances one substep per
|
||||
0.05 s frame, still `PLAYING`, untouched by A's fault;
|
||||
* the supervisor's `gc_pass()` reaps room A (dropped from the registry —
|
||||
the code no longer resolves, its token purged) and leaves room B
|
||||
registered;
|
||||
* a room that is `CLOSED` ticks nothing;
|
||||
* the server keeps serving `/healthz` (200, the live room count is one —
|
||||
room B).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from undercooked.config import Settings
|
||||
from undercooked.server.app import create_app
|
||||
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
|
||||
from undercooked.sim.level import Level, LevelDef
|
||||
from undercooked.sim.model import (
|
||||
Event,
|
||||
EventKind,
|
||||
ItemForm,
|
||||
ItemKind,
|
||||
KitchenState,
|
||||
RoundResult,
|
||||
StationKind,
|
||||
)
|
||||
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
|
||||
from undercooked.sim.step import PlayerInput
|
||||
from undercooked.sim.step import step as real_step
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
#: The test-isolated content dir (a recipe book, no levels) — the app
|
||||
#: boots on it; the supervisor's bundle carries the two test levels.
|
||||
FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels"
|
||||
|
||||
#: One 20 Hz tick, in wall seconds (the sim's `STEP_DT`).
|
||||
PERIOD = 0.05
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ content
|
||||
|
||||
|
||||
def make_book() -> RecipeBook:
|
||||
return RecipeBook(
|
||||
recipes={
|
||||
"soup": Recipe(
|
||||
id="soup",
|
||||
name="Soup",
|
||||
base_score=40,
|
||||
ingredients=[
|
||||
Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW, count=1)
|
||||
],
|
||||
steps=[
|
||||
Step(kind="pick", station=StationKind.SPAWN_SHELF),
|
||||
Step(kind="serve", station=StationKind.SERVE),
|
||||
],
|
||||
)
|
||||
},
|
||||
ingredients=[ItemKind.TOMATO],
|
||||
)
|
||||
|
||||
|
||||
def make_level(level_id: str, *, time_limit: float, target: int) -> Level:
|
||||
"""A 7x4 kitchen (one shelf, one serve window) — the rooms only need
|
||||
to reach `PLAYING`; room A never runs its round (the injected fault
|
||||
fires on the first playing tick) and room B never needs a verdict
|
||||
(the 60 s limit outlives the test)."""
|
||||
data: dict[str, object] = {
|
||||
"id": level_id,
|
||||
"name": f"Isolation kitchen {level_id}",
|
||||
"order": 1,
|
||||
"time_limit": time_limit,
|
||||
"target_score": target,
|
||||
"grid": [
|
||||
"#######",
|
||||
"#S...V#",
|
||||
"#.....#",
|
||||
"#######",
|
||||
],
|
||||
"tiles": {
|
||||
".": {"kind": "floor"},
|
||||
"#": {"kind": "wall"},
|
||||
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
|
||||
"V": {"kind": "floor", "station": "serve"},
|
||||
},
|
||||
"spawns": [[2, 2]],
|
||||
"orders": {
|
||||
"pool": ["soup"],
|
||||
"concurrent": 1,
|
||||
"spawn_interval": [4, 6],
|
||||
"deadline": 30,
|
||||
},
|
||||
}
|
||||
return Level.build(LevelDef.model_validate(data))
|
||||
|
||||
|
||||
def make_bundle() -> ContentBundle:
|
||||
"""The two levels: `08_bad` (room A — the injected fault) and
|
||||
`08_good` (room B — the one that must keep ticking)."""
|
||||
return ContentBundle(
|
||||
recipe_book=make_book(),
|
||||
levels=(
|
||||
make_level("08_bad", time_limit=60.0, target=1000),
|
||||
make_level("08_good", time_limit=60.0, target=1000),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- app
|
||||
|
||||
|
||||
class IsolationApp:
|
||||
"""An app whose supervisor runs on a `FakeClock` (the
|
||||
`FakeClockSupervisorApp` pattern `test_rooms_api.py` pins) with the
|
||||
test's two-level bundle. The `TestClient` is used without the
|
||||
context manager: the rooms' tick tasks are never started — every
|
||||
tick is the test's own `tick_once`, and `gc_pass` runs in a fresh
|
||||
`asyncio.run` with nothing to cancel."""
|
||||
|
||||
def __init__(self, **settings_overrides: Any) -> None:
|
||||
self.settings = Settings(levels_dir=FIXTURE_LEVELS_DIR, **settings_overrides)
|
||||
self.app = create_app(self.settings)
|
||||
self.clock = FakeClock()
|
||||
self.app.state.supervisor = RoomSupervisor(
|
||||
self.settings, self.clock, make_bundle()
|
||||
)
|
||||
|
||||
@property
|
||||
def supervisor(self) -> RoomSupervisor:
|
||||
return self.app.state.supervisor
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the test
|
||||
|
||||
|
||||
def bring_both_to_playing(
|
||||
room_a: Room, room_b: Room, clock: FakeClock
|
||||
) -> None:
|
||||
"""Both rooms through the 3 s countdown (0.05 s wall frames); both
|
||||
end `PLAYING` with a built, un-decided round."""
|
||||
for _ in range(90):
|
||||
clock.advance(PERIOD)
|
||||
room_a.tick_once()
|
||||
room_b.tick_once()
|
||||
if room_a.state is RoomState.PLAYING and room_b.state is RoomState.PLAYING:
|
||||
break
|
||||
assert room_a.state is RoomState.PLAYING
|
||||
assert room_b.state is RoomState.PLAYING
|
||||
assert room_a.sim_state is not None and room_b.sim_state is not None
|
||||
assert room_a.sim_state.result is None
|
||||
assert room_b.sim_state.result is None
|
||||
|
||||
|
||||
def inject_the_fault(room_a: Room) -> None:
|
||||
"""The UNDERCOOKED_TEST_HOOK: room A's sim raises on its first
|
||||
playing tick; room B's level is left to the real kernel."""
|
||||
|
||||
def boom(
|
||||
state: KitchenState,
|
||||
dt: float,
|
||||
inputs: Sequence[PlayerInput],
|
||||
book: RecipeBook | None,
|
||||
) -> tuple[KitchenState, tuple[Event, ...]]:
|
||||
if state.level_id == "08_bad":
|
||||
msg = "sim exploded in room A"
|
||||
raise RuntimeError(msg)
|
||||
return real_step(state, dt, inputs, book)
|
||||
|
||||
room_a._test_step_override = boom
|
||||
|
||||
|
||||
def check_room_a_is_aborted(
|
||||
room_a: Room,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
token_a: str,
|
||||
token_b: str,
|
||||
) -> None:
|
||||
"""Room A: aborted and closed; the err event delivered; the log line
|
||||
carries the code and the round, never a player token."""
|
||||
assert room_a.state is RoomState.CLOSED
|
||||
assert room_a.sim_state is not None
|
||||
assert room_a.sim_state.result is RoundResult.ABORTED
|
||||
assert room_a.abort_reason is not None
|
||||
assert "RuntimeError" in room_a.abort_reason
|
||||
events = room_a.take_events()
|
||||
crash = [
|
||||
e
|
||||
for e in events
|
||||
if e.kind is EventKind.ERROR and e.payload.get("reason") == "tick_crash"
|
||||
]
|
||||
assert len(crash) == 1
|
||||
assert crash[0].payload["round"] == room_a.round_no
|
||||
assert room_a.take_events() == ()
|
||||
assert room_a.code in caplog.text
|
||||
assert "round 1" in caplog.text
|
||||
assert token_a not in caplog.text
|
||||
assert token_b not in caplog.text
|
||||
|
||||
|
||||
def check_room_b_keeps_ticking(room_b: Room, clock: FakeClock) -> None:
|
||||
"""Room B: untouched — still playing, still ticking correctly (one
|
||||
substep per 0.05 s frame: no slip); and a closed room ticks nothing."""
|
||||
sim = room_b.sim_state
|
||||
assert sim is not None
|
||||
tick_before = sim.tick
|
||||
for _ in range(20): # 1.0 s of B's round
|
||||
clock.advance(PERIOD)
|
||||
room_b.tick_once()
|
||||
assert room_b.state is RoomState.PLAYING
|
||||
assert room_b.sim_state is not None
|
||||
assert room_b.sim_state.tick == tick_before + 20
|
||||
assert room_b.sim_state.result is None
|
||||
|
||||
|
||||
async def check_gc_reaps_a_leaves_b(
|
||||
sup: RoomSupervisor,
|
||||
room_a: Room,
|
||||
room_b: Room,
|
||||
token_a: str,
|
||||
token_b: str,
|
||||
) -> None:
|
||||
"""The GC reaps A (already `CLOSED`: the pass drops it — the close
|
||||
itself happened in the crash isolation, not here) and leaves B
|
||||
registered."""
|
||||
assert await sup.gc_pass() == 0
|
||||
assert sup.get(room_a.code) is None # A is gone: the code no longer resolves
|
||||
assert sup.get(room_b.code) is room_b
|
||||
assert sup.by_token(token_a) is None # A's token is purged
|
||||
assert sup.by_token(token_b) is room_b
|
||||
assert sup.stats()["rooms"] == 1
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ the test
|
||||
|
||||
|
||||
def test_one_room_fails_without_affecting_the_other(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
caplog.set_level(logging.ERROR, logger="undercooked.server.room")
|
||||
fixture = IsolationApp()
|
||||
sup = fixture.supervisor
|
||||
clock = fixture.clock
|
||||
|
||||
async def scenario() -> None:
|
||||
# Two live rooms, both created through the supervisor (the
|
||||
# creator is seat 0, host; the rooms get the store as their
|
||||
# `store`).
|
||||
ada = new_session("Ada", clock)
|
||||
bob = new_session("Bob", clock)
|
||||
room_a = sup.create_room(ada, "Ada")
|
||||
room_b = sup.create_room(bob, "Bob")
|
||||
room_b.select_level("08_good") # A plays the campaign's first
|
||||
assert room_a.level_id == "08_bad"
|
||||
|
||||
# Both are solo hosts: the start is legal as-is.
|
||||
room_a.start(ada.token)
|
||||
room_b.start(bob.token)
|
||||
bring_both_to_playing(room_a, room_b, clock)
|
||||
|
||||
inject_the_fault(room_a)
|
||||
# The crash tick — isolated: it must not propagate. The wall
|
||||
# time is not advanced: A takes this tick at the last wall time
|
||||
# (dt 0), so B's cadence below stays exactly one 0.05 s frame
|
||||
# per substep.
|
||||
room_a.tick_once()
|
||||
|
||||
check_room_a_is_aborted(room_a, caplog, ada.token, bob.token)
|
||||
check_room_b_keeps_ticking(room_b, clock)
|
||||
|
||||
# A closed room ticks nothing.
|
||||
clock.advance(PERIOD)
|
||||
room_a.tick_once()
|
||||
assert room_a.state is RoomState.CLOSED
|
||||
|
||||
await check_gc_reaps_a_leaves_b(sup, room_a, room_b, ada.token, bob.token)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
# The server keeps serving /healthz — room B is the live room.
|
||||
client = TestClient(fixture.app)
|
||||
body = client.get("/healthz").json()
|
||||
assert body["status"] == "ok"
|
||||
assert body["rooms"] == 1
|
||||
Reference in New Issue
Block a user