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
@@ -0,0 +1,570 @@
"""Integration: the whole lobby/room lifecycle over HTTP (phase 05, task 05;
US-01/US-02/US-03) — every path the task file names, driven by `TestClient`
plus manual `tick_once` (a `FakeClock`; no test sleeps in real time).
* **the happy path** — `POST /api/rooms` → three `POST .../join` → ready-up
→ the host's `start` → the 3 s countdown in 0.05 s wall frames → a
scripted full-pipeline round to `WON` → the `ROUND_END` payload is
`result_payload` (the exact key set, the per-seat `players`) →
`next_level` → a second (losing) round → `to_lobby`: back in `LOBBY`
with the same seats, tokens and colours, the ready flags reset,
* **the solo path** — create, `start` with no other players, a scripted
win, and the session's progression (the task-04 wiring through the
supervisor's store: `unlocked_level` up to 2, a `best` run, the
counters, the `next_level_id` advice),
* **the full-room path** — the 5th join is a 503 `room_full` and the
room's code is retained (still 200, still four seats),
* **the bad-code path** — `GET /api/rooms/ZZZZZ` (unknown) and
`GET /api/rooms/nope!` (malformed) answer with the same status and the
same body (US-02 criterion 4: the API is not a room-existence oracle),
with no stack trace in the response,
* **the abandon path** — every player leaves a `LOBBY`; exactly at
`SEAT_GRACE_S` the room is still up, one `gc_pass()` after the grace
closes and drops it (the code no longer resolves — a 404),
* **the idle-lobby expiry** — a `FakeClock` advanced past
`lobby_idle_timeout_s` and one `gc_pass()` close an occupied idle
lobby (exactly at the timeout: not yet),
* **the capacity** — with `max_rooms=2`, the 3rd `POST /api/rooms` is a
503 `room_full` and the existing rooms are untouched.
The app's supervisor is swapped before any request (a `FakeClock` + the
test's own content bundle — the pattern `test_rooms_api.py` pins), and
the `TestClient` is used **without** the context manager: the lifespan
never starts the rooms' tick tasks, so every tick is the test's own
`tick_once` call and `gc_pass` runs in a fresh `asyncio.run` with nothing
to cancel. (There is no HTTP `start`/`ready` yet — that is the phase-06
transport; `docs/ROOMS.md` "Lifecycle walkthrough" documents the gap.)
"""
from __future__ import annotations
import asyncio
import sys
from collections.abc import Mapping
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 SEAT_GRACE_S, Room, RoomState
from undercooked.server.rooms import RoomSupervisor
from undercooked.sim.content import ContentBundle
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import (
EventKind,
ItemForm,
ItemKind,
KitchenState,
RoundResult,
StationKind,
)
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
pytestmark = pytest.mark.integration
#: The test-isolated content dir (a recipe book, no levels) — explicit, so
#: the app never reads the shipped `levels/` implicitly. The bundle the
#: supervisor gets carries the test's own two levels.
FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels"
#: The sim's scripted-cook 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]
CookController,
shelf_id_for,
)
#: One 20 Hz tick, in wall seconds (the sim's `STEP_DT`).
PERIOD = 0.05
#: The exact key set of `result_payload` (PLAN §7's `round` body) — the
#: `ROUND_END` payload the happy path checks (the phase-04 contract).
RESULT_PAYLOAD_KEYS = {
"result",
"score",
"target",
"stars",
"time_left",
"counters",
"longest_streak",
"players",
"level_id",
"seed",
"ticks",
}
# ------------------------------------------------------------------ content
def make_book() -> RecipeBook:
"""A one-recipe book: the full-pipeline `soup` (pick → chop → cook →
plate → serve), so the scripted cook runs the whole station chain —
the same shape the phase-04 full rounds prove."""
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="chop", station=StationKind.CHOP_BOARD, seconds=0.9),
Step(kind="cook", station=StationKind.STOVE, seconds=4.0),
Step(kind="plate", station=StationKind.PREP, seconds=0.4),
Step(kind="serve", station=StationKind.SERVE),
],
)
},
ingredients=[ItemKind.TOMATO],
)
def make_level(
level_id: str, *, order: int, time_limit: float, target: int
) -> Level:
"""An 8x5 kitchen with the full station chain: one spawn shelf
(`tomato`), a chop board, a stove, a prep and a serve window — every
fixture has a walkable stand cell (the scripted cook's BFS). The
four spawn points seat the four cooks in four distinct cells, the
host on the working row and the three idle cooks on the back row:
an idle cook that shares the one-tile corridor parks where the
Goto's detour has no exit cell (the body-cell avoid set covers every
neighbour), and a single shared spawn cell overlaps the bodies at
start (the soft-push drift is what parks an idle cook there). The
back-row bench keeps the corridor free for the scripted cook."""
data: dict[str, object] = {
"id": level_id,
"name": f"Lifecycle kitchen {level_id}",
"order": order,
"time_limit": time_limit,
"target_score": target,
"grid": [
"########",
"#S.C.VP#",
"#......#",
"#T.....#",
"########",
],
"tiles": {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"C": {"kind": "floor", "station": "chop_board"},
"T": {"kind": "floor", "station": "stove"},
"V": {"kind": "floor", "station": "serve"},
"P": {"kind": "floor", "station": "prep"},
},
"spawns": [[2, 2], [3, 3], [4, 3], [5, 3]],
"orders": {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [4, 6],
"deadline": 30,
},
}
return Level.build(LevelDef.model_validate(data))
def make_bundle() -> ContentBundle:
"""The test's two-level campaign: A (order 1, 60 s, a target a single
scripted serve clears) and B (order 2, 5 s, a target nothing clears) —
the happy path's win and its quick losing follow-up."""
return ContentBundle(
recipe_book=make_book(),
levels=(
make_level("07_win", order=1, time_limit=60.0, target=10),
make_level("07_lose", order=2, time_limit=5.0, target=1000),
),
)
# --------------------------------------------------------------------- app
class LifecycleApp:
"""An app whose supervisor runs on a `FakeClock` (the
`FakeClockSupervisorApp` pattern `test_rooms_api.py` pins), with the
test's own content bundle (the fixture dir is a zero-level campaign;
the rooms need a level to run a round). The `TestClient` is used
without the context manager, so the rooms' tick tasks are never
started — every tick is the test's own `tick_once`.
The `settings` are the app's (the supervisor reuses them, so the
task's `max_rooms=2` / `lobby_idle_timeout_s` overrides apply).
"""
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
# ---------------------------------------------------------------- helpers
def create_room(client: TestClient, name: str | None = None) -> dict[str, Any]:
body: dict[str, Any] = {}
if name is not None:
body["name"] = name
response = client.post("/api/rooms", json=body)
assert response.status_code == 201, response.text
return response.json()
def join_room(
client: TestClient, code: str, token: str, name: str | None = None
) -> tuple[int, dict[str, Any]]:
body: dict[str, Any] = {"token": token}
if name is not None:
body["name"] = name
response = client.post(f"/api/rooms/{code}/join", json=body)
return response.status_code, response.json()
def drive_countdown_to_playing(room: Room, clock: FakeClock) -> None:
"""The 3.0 s countdown in 0.05 s wall frames (the manual `tick_once`
cadence); leaves the room in `PLAYING` with the round built."""
for _ in range(90): # 60 frames are the 3.0 s; the rest is slack
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 drive_round_to_end(
room: Room, clock: FakeClock, max_ticks: int, active: str | None = None
) -> None:
"""0.05 s wall frames until the room is out of `PLAYING` (verdict +
the 1.0 s settle → `ROUND_END`). When `active` is a seat's token,
the phase-04 scripted cook drives that one cook (the controller is
built before the loop — the round is already built); every other
seat stays idle (no input). The cook stops feeding once the verdict
lands (the frozen settle ignores inputs anyway)."""
controller: CookController | None = None
if active is not None:
state = room.sim_state
assert state is not None # the countdown has built the round
controller = CookController(0, shelf_id_for(state, ItemKind.TOMATO), "any")
for _ in range(max_ticks):
if room.state is not RoomState.PLAYING:
break
clock.advance(PERIOD)
state = room.sim_state
assert state is not None
if controller is not None and state.result is None:
frame = controller.next_frame(state, state.tick)
assert frame.player_id == active # seat 0 is the host's sim player
room.queue_input(frame.player_id, frame)
room.tick_once()
assert room.state is RoomState.ROUND_END
def check_won_payload(
room: Room, state: KitchenState, level_id: str, n_players: int
) -> Mapping[str, object]:
"""The `ROUND_END` payload is the `result_payload` of the verdict:
the exact key set, the win, the star grade, the per-seat `players`."""
payload = room.last_result
assert payload is not None
assert set(payload) == RESULT_PAYLOAD_KEYS
assert payload["result"] == "won"
assert payload["score"] == state.score
assert payload["target"] == state.target_score
assert payload["stars"] in (1, 2, 3) # a win grades at least one star
assert payload["level_id"] == level_id
assert payload["seed"] == room.round_seed
players = payload["players"]
assert isinstance(players, list)
assert len(players) == n_players # one per seat
return payload
def check_round_end_events(room: Room) -> None:
"""The verdict's events (the `ROUND_END` and the served dishes) landed
in the buffer and drain 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() == ()
def check_second_losing_round(
room: Room, clock: FakeClock, level_id: str
) -> None:
"""`next_level`: the campaign's second level, round 2 — an idle round
loses on the 5 s buzzer (plus the settle)."""
room.next_level(level_id)
assert room.state is RoomState.COUNTDOWN
assert room.level_id == level_id
assert room.round_no == 2
drive_countdown_to_playing(room, clock)
assert room.sim_state is not None
assert room.sim_state.level_id == level_id
drive_round_to_end(room, clock, max_ticks=300)
assert room.sim_state is not None
assert room.sim_state.result is RoundResult.LOST
assert room.last_result is not None and room.last_result["result"] == "lost"
def check_lobby_return(
client: TestClient,
room: Room,
code: str,
host_token: str,
colours: dict[str, str],
) -> None:
"""Back to the `LOBBY`: the same seats and colours, the ready flags
reset, the crown untouched — and over HTTP the room answers with all
four seats."""
room.to_lobby()
assert room.state is RoomState.LOBBY
seated = sorted(room.seats, key=lambda seat: seat.seat_index)
assert [seat.token for seat in seated] == [
host_token,
"tok-bob",
"tok-cy",
"tok-di",
]
assert {seat.token: seat.color for seat in seated} == colours
assert all(not seat.ready for seat in seated) # ready up again
assert seated[0].host is True # the crown was never in play
get = client.get(f"/api/rooms/{code}")
assert get.status_code == 200
body = get.json()
assert body["state"] == "lobby" and len(body["seats"]) == 4
# --------------------------------------------------------------- the paths
def test_happy_path_from_create_to_won_to_next_level_to_lobby() -> None:
"""The task's happy path, end to end (HTTP for the lobby shell, manual
`tick_once` for the round — no sleeps)."""
fixture = LifecycleApp()
client = TestClient(fixture.app)
sup = fixture.supervisor
level_a, level_b = sup.content.levels
# create + three joins
body = create_room(client, "Ada")
code = body["room_code"]
host_token = body["you"]["token"]
assert body["you"]["seat"] == 0 and body["you"]["host"] is True
room = sup.get(code)
assert room is not None
assert room.level_id == level_a.id # the campaign's first by default
for name, token in (("Bob", "tok-bob"), ("Cy", "tok-cy"), ("Di", "tok-di")):
status, joined = join_room(client, code, token, name)
assert status == 200, joined
assert joined["you"]["token"] == token
assert len(room.seats) == 4
# ready-up (two of the three; one ready non-host is the precondition)
assert room.set_ready("tok-bob", True)
assert room.set_ready("tok-cy", True)
assert room.can_start
# the host's start (the phase-06 `start` message is its transport copy)
room.start(host_token)
assert room.state is RoomState.COUNTDOWN
assert room.round_no == 1
# the countdown completes in 0.05 s wall frames
drive_countdown_to_playing(room, fixture.clock)
assert room.sim_state is not None
assert room.sim_state.level_id == level_a.id
# the colours are handed out first-free in seat order — captured before
# the round so the return to the lobby can check them
colours = {seat.token: seat.color for seat in room.seats}
assert len(set(colours.values())) == 4 # the whole palette is used
# the round: a scripted full-pipeline round to WON
drive_round_to_end(room, fixture.clock, max_ticks=1500, active=host_token)
state = room.sim_state
assert state is not None
assert state.result is RoundResult.WON
assert state.score >= state.target_score
# the ROUND_END payload is the result_payload of the verdict
check_won_payload(room, state, level_a.id, n_players=4)
check_round_end_events(room)
# `next_level` and the idle second round
check_second_losing_round(room, fixture.clock, level_b.id)
# back to the LOBBY: the same seats and colours, the ready flags reset
check_lobby_return(client, room, code, host_token, colours)
def test_solo_path_wins_and_the_unlock_advances() -> None:
"""Solo (no other players): the host's `start` is legal as-is, the
scripted round wins, and the task-04 wiring records the result into
the creator's session through the supervisor's store."""
fixture = LifecycleApp()
client = TestClient(fixture.app)
sup = fixture.supervisor
level_a = sup.content.levels[0]
level_b = sup.content.levels[1]
body = create_room(client, "Solo")
code = body["room_code"]
token = body["you"]["token"]
room = sup.get(code)
assert room is not None and len(room.seats) == 1
room.start(token) # the solo host: no ready flag needed
assert room.state is RoomState.COUNTDOWN
drive_countdown_to_playing(room, fixture.clock)
drive_round_to_end(room, fixture.clock, max_ticks=1500, active=token)
state = room.sim_state
assert state is not None and state.result is RoundResult.WON
payload = room.last_result
assert payload is not None and payload["result"] == "won"
players = payload["players"]
assert isinstance(players, list) and len(players) == 1
# the progression: the store's rule, through the room's `round_over`
record = sup.sessions.get(token)
assert record is not None
assert record.unlocked_level == 2 # the campaign's floor raised by the win
assert record.best[level_a.id] is not None
assert record.best[level_a.id].score == payload["score"]
assert record.best[level_a.id].stars in (1, 2, 3)
assert record.rounds_played == 1
assert record.dishes_served >= 1
# and the `next_level` advice the campaign hands back
assert sup.sessions.next_level_id(token, sup.content) == level_b.id
def test_full_room_refuses_the_fifth_join_and_retains_the_code() -> None:
"""With four seats taken, the 5th join is a 503 `room_full` — and the
room's code is retained (still resolvable, still four seats)."""
fixture = LifecycleApp()
client = TestClient(fixture.app)
code = create_room(client, "Ada")["room_code"]
for token in ("tok-bob", "tok-cy", "tok-di"):
status, _ = join_room(client, code, token)
assert status == 200
status, body = join_room(client, code, "tok-eve", "Eve")
assert status == 503
assert body == {"code": "room_full"}
get = client.get(f"/api/rooms/{code}")
assert get.status_code == 200
body = get.json()
assert len(body["seats"]) == 4 # the room is untouched
assert body["state"] == "lobby"
def test_bad_codes_are_indistinguishable_and_stack_trace_free() -> None:
"""US-02 criterion 4: an unknown code (`ZZZZZ`) and a malformed one
(`nope!`) must produce the *same* response — the API is not a
room-existence oracle — and the refusal is rate-limit friendly (no
stack trace in the body)."""
fixture = LifecycleApp()
client = TestClient(fixture.app)
unknown = client.get("/api/rooms/ZZZZZ")
malformed = client.get("/api/rooms/nope!")
assert unknown.status_code == 404
assert malformed.status_code == 404
assert unknown.json() == malformed.json() == {"code": "room_not_found"}
for response in (unknown, malformed):
assert "Traceback" not in response.text
assert "Exception" not in response.text
def test_all_players_leave_and_the_gc_closes_after_the_grace() -> None:
"""The abandon path: every player leaves a `LOBBY`; exactly at
`SEAT_GRACE_S` the room is still up, and one `gc_pass()` past the
grace closes and drops it — the code no longer resolves (a 404)."""
fixture = LifecycleApp()
client = TestClient(fixture.app)
sup = fixture.supervisor
body = create_room(client, "Ada")
code = body["room_code"]
host_token = body["you"]["token"]
status, _ = join_room(client, code, "tok-bob", "Bob")
assert status == 200
sup.leave(code, "tok-bob")
sup.leave(code, host_token)
room = sup.get(code)
assert room is not None and room.is_empty
assert room.empty_since == fixture.clock.now()
fixture.clock.advance(SEAT_GRACE_S) # exactly 30 s is not *past* the grace
assert asyncio.run(sup.gc_pass()) == 0
assert sup.get(code) is not None # still in the registry
fixture.clock.advance(1.0)
assert asyncio.run(sup.gc_pass()) == 1
assert sup.get(code) is None # dropped: the code no longer resolves
assert client.get(f"/api/rooms/{code}").status_code == 404
def test_idle_lobby_expires_past_the_lobby_timeout() -> None:
"""The idle-lobby rule (an occupied room, so not the grace): a
`FakeClock` advanced past `lobby_idle_timeout_s` and one `gc_pass()`
close it; exactly at the timeout it is still up."""
fixture = LifecycleApp(lobby_idle_timeout_s=10.0)
client = TestClient(fixture.app)
sup = fixture.supervisor
code = create_room(client, "Idle")["room_code"]
assert sup.get(code) is not None
fixture.clock.advance(10.0) # exactly the timeout is not past it
assert asyncio.run(sup.gc_pass()) == 0
assert sup.get(code) is not None
fixture.clock.advance(0.5)
assert asyncio.run(sup.gc_pass()) == 1
assert sup.get(code) is None
assert client.get(f"/api/rooms/{code}").status_code == 404
def test_process_capacity_refuses_the_third_room_and_leaves_existing_ones() -> None:
"""With `max_rooms=2`, the 3rd `POST /api/rooms` is a 503 `room_full`
(US-01 criterion 7) and the two existing rooms are untouched."""
fixture = LifecycleApp(max_rooms=2)
client = TestClient(fixture.app)
code_1 = create_room(client, "One")["room_code"]
code_2 = create_room(client, "Two")["room_code"]
response = client.post("/api/rooms", json={})
assert response.status_code == 503
assert response.json() == {"code": "room_full"}
for code in (code_1, code_2): # the existing rooms are untouched
get = client.get(f"/api/rooms/{code}")
assert get.status_code == 200
body = get.json()
assert len(body["seats"]) == 1
assert body["state"] == "lobby"
assert client.get("/healthz").json()["rooms"] == 2