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:
@@ -1,52 +0,0 @@
|
||||
# Phase 05 — Anonymous lobbies and room lifecycle
|
||||
|
||||
**Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`, `US-03_ready_up_and_start`
|
||||
**Context:** `.agents/PLAN.md` §6 (room FSM, supervisor, sessions), A6, A15, A16
|
||||
|
||||
## Objective
|
||||
Build the multiplayer shell without any sockets yet: a `RoomSupervisor` that mints and
|
||||
reclaims anonymous room codes, a `Room` whose finite state machine walks
|
||||
lobby → countdown → playing → round-end → closed, and an in-memory session store that
|
||||
remembers unlocks and best scores. Everything is exercised through direct Python calls
|
||||
plus thin HTTP endpoints, so the transport (phase 06) is a thin layer over a tested core.
|
||||
|
||||
## Dependencies
|
||||
- `04_kitchen_pipeline_and_scoring` (complete) — `build_state`, `step`,
|
||||
`result_payload`, round results
|
||||
|
||||
## Tasks
|
||||
1. `01_room_supervisor_and_codes.md` — code minting, capacity, registry, GC task
|
||||
2. `02_room_state_machine.md` — `Room` FSM with guarded transitions and tick ownership
|
||||
3. `03_lobby_state_and_presence.md` — seats, ready, host election/handoff, level pick, spectators
|
||||
4. `04_session_store_and_progression.md` — in-memory LRU session records, unlocks, best scores
|
||||
5. `05_lifecycle_integration_suite.md` — HTTP probes + full lifecycle tests, GC/idle behaviour, crash isolation
|
||||
|
||||
## Testing & Quality
|
||||
- Unit: code alphabet and collision retry, capacity rejection, FSM transition legality
|
||||
(every illegal pair rejected), host handoff ordering, GC decisions, LRU eviction,
|
||||
unlock monotonicity.
|
||||
- Integration: `create → join(×3) → ready → start → tick to result → round_end → next
|
||||
level → lobby`; solo start; join-during-play becomes spectator; host leaves mid-round;
|
||||
all-seats-empty closes the room; `max_rooms` refusal; a room whose tick raises is aborted
|
||||
and isolated.
|
||||
- Coverage: **>90%** on new/modified code; `server/room.py`, `server/rooms.py`,
|
||||
`server/sessions.py` ≥ 95%.
|
||||
- The phase is "Complete" only when the **entire** project test suite passes 100%.
|
||||
|
||||
## Documentation & Consistency
|
||||
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
|
||||
stands after this phase — fixing every inconsistency found, **including pre-existing
|
||||
ones not caused by this phase**.
|
||||
- Tasks add `docs/ROOMS.md` (FSM diagram, supervisor rules, session semantics, what is
|
||||
lost on restart) and update `AGENTS.md` §6 "where things go" if a new module appears.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run python -c "from undercooked.server.rooms import RoomSupervisor"` succeeds
|
||||
- [ ] `curl -fsS -X POST http://127.0.0.1:8000/api/rooms` returns a 5-char Crockford code; `curl -fsS http://127.0.0.1:8000/api/rooms/{code}` shows the seat list
|
||||
- [ ] lifecycle integration test drives a 4-seat room to `WON` and back to `LOBBY` without sockets
|
||||
- [ ] an exception injected into one room's tick aborts that room only; other rooms keep ticking (test)
|
||||
- [ ] idle lobby GC closes a room within 2 GC cycles (test with a fake clock)
|
||||
- [ ] `docs/ROOMS.md` exists with the FSM diagram and restart-semantics note
|
||||
- [ ] full test suite **100% passing**, coverage >90%
|
||||
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
|
||||
- [ ] no behavior change in completed phases
|
||||
-64
@@ -1,64 +0,0 @@
|
||||
# Task 01 — Room supervisor, code minting, capacity, GC
|
||||
|
||||
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`
|
||||
|
||||
## Objective
|
||||
Own the registry of live rooms: mint unguessable-but-shoutable codes, enforce capacity,
|
||||
reclaim dead rooms, and do it all behind an injectable clock so tests are deterministic.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/clock.py` — `Clock` protocol (`now() -> float`) with
|
||||
`SystemClock` and `FakeClock(start=0.0)` (`advance(seconds)`); every time-dependent
|
||||
server object receives a `clock` — **no bare `time.monotonic()` calls** in `server/`
|
||||
(add a grep-style test in task 05).
|
||||
2. `src/undercooked/server/codes.py`:
|
||||
- `CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"` (5 chars → excludes I, L, O, U);
|
||||
`CODE_LEN = 5`.
|
||||
- `new_code(taken: Container[str], rng: random.Random | None = None) -> str` using
|
||||
`secrets.choice`; retries up to 100 times on collision, then raises `CodeSpaceFull`.
|
||||
- `normalize_code(raw: str) -> str | None` — uppercase, strip spaces/dashes, map
|
||||
confusables (`I`/`l`→`1`, `O`→`0`), reject wrong length or invalid chars → `None`
|
||||
(never a partial match, and the caller must not be able to distinguish "bad format"
|
||||
from "no such room" — see US-02 criterion 4).
|
||||
3. `src/undercooked/server/rooms.py`:
|
||||
- `RoomSupervisor(settings, clock, content)` holding `dict[str, Room]`,
|
||||
`dict[str, str]` token→code index, `stats()`.
|
||||
- `create_room(session: SessionRecord, display_name: str) -> Room` → raises
|
||||
`RoomCapacityError` when `len(rooms) >= max_rooms`; the creator takes seat 0 as host.
|
||||
- `join(code, token, display_name) -> Room` → `RoomNotFound`, `RoomFull`,
|
||||
`RoomClosed`; a known token re-joining its own room reclaims its seat even if the
|
||||
seat was marked disconnected (rejoin path, phase 12 refines it).
|
||||
- `leave(code, token)`, `get(code)`, `by_token(token)`, `all_rooms()`.
|
||||
- `async def gc_pass()` — close rooms whose last seat left more than
|
||||
`SEAT_GRACE_S` ago, close `LOBBY` rooms idle past `lobby_idle_timeout_s`, drop stale
|
||||
token index entries; `async def gc_loop()` every 5 s, cancellable.
|
||||
- `async def start()` / `async def shutdown()` — cancel all room tasks and await them.
|
||||
- Registers itself in `app.state.supervisor` from `create_app()` (feature-flagged on
|
||||
`Settings.levels_dir` content being loaded), and exposes
|
||||
`POST /api/rooms`, `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join` in
|
||||
`src/undercooked/server/routes_rooms.py` so the lifecycle is testable over HTTP
|
||||
before WebSockets exist. Responses carry `room_code`, `state`, `seats`, `you` (token,
|
||||
seat, host flag) — tokens are generated server-side with
|
||||
`secrets.token_urlsafe(24)` and returned once.
|
||||
4. `tests/unit/test_codes.py` — alphabet excludes I/L/O/U, length, 20 000 codes without
|
||||
duplicates, collision retry via a stubbed `taken` set, `CodeSpaceFull`, all confusable
|
||||
normalizations, rejection cases.
|
||||
5. `tests/integration/test_supervisor.py` — create/join/leave matrix, `RoomFull` at
|
||||
`max_players`, `RoomCapacityError` at `max_rooms` (with `max_rooms=3`), rejoin by token,
|
||||
unknown code indistinguishable from malformed code at the API boundary, `stats()`
|
||||
accuracy, shutdown cancels everything, GC with `FakeClock` closes exactly what it should.
|
||||
6. `tests/integration/test_rooms_api.py` — the three HTTP endpoints: status codes, body
|
||||
shapes, no token leakage for *other* players, `POST /api/rooms` at capacity → 503 with
|
||||
`{code:"room_full"}`.
|
||||
|
||||
## Testing & Quality
|
||||
- Unit + integration as listed; no test sleeps — all time via `FakeClock`.
|
||||
- Coverage: **>90%** on `rooms.py`, `codes.py`, `clock.py`, `routes_rooms.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/unit/test_codes.py tests/integration/test_supervisor.py tests/integration/test_rooms_api.py -q` → green
|
||||
- [ ] `curl -fsS -X POST http://127.0.0.1:8000/api/rooms` returns a 5-char code from the Crockford alphabet
|
||||
- [ ] no `time.monotonic()`/`time.time()` literal in `src/undercooked/server/` (grep-asserted test)
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work
|
||||
@@ -1,58 +0,0 @@
|
||||
# Task 02 — Room state machine and tick ownership
|
||||
|
||||
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-03_ready_up_and_start`
|
||||
|
||||
## Objective
|
||||
Implement `Room` as an explicit, guarded FSM that owns exactly one 20 Hz tick task and
|
||||
turns a level plus seats into a running simulation and a result.
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/room.py`:
|
||||
- `RoomState` enum: `LOBBY, COUNTDOWN, PLAYING, ROUND_END, CLOSED`.
|
||||
- `RoomTransition` table as data (`{(from, event): to}`); `Room._fire(event)` raises
|
||||
`IllegalTransition` for anything not in the table — no ad-hoc state writes.
|
||||
- Events: `host_start`, `countdown_done`, `round_over`, `to_lobby`, `retry`,
|
||||
`next_level`, `close`, `all_seats_empty`, `idle_expired`.
|
||||
- State data: `code`, `seats: list[Seat]` (`Seat(player_id, token, display_name,
|
||||
color, ready, host, connected, last_seen, seat_index)`), `level: Level`,
|
||||
`state`, `state_since`, `countdown_left`, `sim_state`, `last_result`,
|
||||
`round_no`, `events_since_snapshot` buffer, `snapshot_seq`.
|
||||
- `async def tick_loop()` — `while` loop targeting `1/tick_hz` using
|
||||
`clock.now()`-based scheduling (no `asyncio.sleep(1/hz)` drift: compute the
|
||||
next deadline), calling `self.tick_once(now)`.
|
||||
- `def tick_once(now: float | None = None)` — synchronous core so tests can drive it
|
||||
manually: `PLAYING` → `sim.step(sim_state, dt, queued_inputs)`, drain events into the
|
||||
buffer, on `result is not None` fire `round_over`; `COUNTDOWN` → decrement, fire
|
||||
`countdown_done`; `LOBBY`/`ROUND_END` → idle-expiry checks.
|
||||
- `queue_input(player_id, input)` — only legal in `PLAYING`, unknown player →
|
||||
`err` event; input queue is per-tick and cleared after each step.
|
||||
- Crash isolation: `tick_once` wrapped so any exception logs (code + round, never
|
||||
player tokens), calls `sim.round_state.abort(...)`, fires `close`, and never
|
||||
propagates out of the loop (one room must not kill the server).
|
||||
- `start()`/`close()` manage the task; `close()` is idempotent and frees seats.
|
||||
2. `docs/ROOMS.md` — the FSM diagram (ASCII), the transition table, and what each state
|
||||
accepts from clients (forward reference to phase 06's whitelist).
|
||||
3. `tests/integration/test_room_fsm.py` — every legal transition, every illegal pair
|
||||
raises, `host_start` requires ≥ 1 ready non-host or a solo host, countdown counts 3
|
||||
frames of 1 s, `round_over` stores `last_result` and payload, `retry` reuses the level
|
||||
with a new seed, `next_level` switches to an explicitly supplied level id (task 04 wires
|
||||
that value from the session store), `close` idempotent,
|
||||
`all_seats_empty` closes after grace, queued inputs dropped outside `PLAYING`.
|
||||
4. `tests/integration/test_room_tick.py` — `tick_once` drives the sim forward
|
||||
(player positions change under a scripted input), tick cadence has no drift over 100
|
||||
manual calls, event buffer drains exactly once, injected exception aborts the round and
|
||||
leaves the room `CLOSED` without raising out of `tick_loop`.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: full transition table, tick cadence with `FakeClock`, crash isolation.
|
||||
- Unit: seat helpers, input legality, seed freshness on retry.
|
||||
- Coverage: **>90%** on `room.py` (target 95%).
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_room_fsm.py tests/integration/test_room_tick.py -q` → green
|
||||
- [ ] the illegal-transition test covers the full `states × events` matrix except legal pairs
|
||||
- [ ] a `Room` driven by `tick_once` reaches `WON` on level 1 with a scripted input
|
||||
- [ ] injected sim exception closes only that room (asserted via a second live room)
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
# Task 03 — Seats, ready-up, host election, spectators
|
||||
|
||||
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-02_join_by_code`, `US-03_ready_up_and_start`
|
||||
|
||||
## Objective
|
||||
Make the lobby mean something: named seats with colors, ready toggles, host authority and
|
||||
handoff, level selection restricted to unlocks, and spectators for people who arrive
|
||||
mid-round.
|
||||
|
||||
## Work
|
||||
1. Extend `src/undercooked/server/room.py` (or split into `server/seats.py` if it exceeds
|
||||
~400 lines):
|
||||
- `Seat` behavior: `toggle_ready()`, `mark_connected()/mark_disconnected(now)`,
|
||||
`is_host`, `display_name` sanitized at the boundary.
|
||||
- `sanitize_name(raw: str | None, seat_index: int) -> str` — strip control chars and
|
||||
`<>"'`, collapse whitespace, trim, cap at 16 chars, fall back to `Cook #{seat+1}`;
|
||||
duplicate names allowed; the result is rendered as text by the client (never HTML).
|
||||
- Color assignment: first free color from the 4-color palette in seat order; reconnect
|
||||
keeps the same color.
|
||||
- Host rules: seat 0 of the creator is host; `transfer_host()` picks the lowest
|
||||
occupied, connected seat when the host leaves **in `LOBBY` or `ROUND_END`**; in
|
||||
`PLAYING`/`COUNTDOWN` the round continues and the host slot is empty (no mid-round
|
||||
election) — documented, tested. If the host returns, they do not automatically regain
|
||||
the crown (explicit, avoids flip-flopping).
|
||||
- `set_level(token, level_id)` — host only, level must exist and be unlocked for the
|
||||
session; rejects with `NotHost`/`LevelLocked`/`UnknownLevel`; only in `LOBBY`.
|
||||
- `start(token)` — host only; legal when `len(occupants) == 1` (solo) or
|
||||
`any(seat.ready for seat in occupants if not seat.host)`; raises `NothingToStart`.
|
||||
- Spectators: `join()` during `COUNTDOWN`/`PLAYING`/`ROUND_END` adds a spectator entry
|
||||
(no seat, `role="spectator"`); `leave()` removes it; spectators receive state/snapshot
|
||||
but their inputs are ignored (`err` `not_seated`).
|
||||
- `lobby_payload()` — the `lobby` message body: `code`, `state`, `level`, `host_seat`,
|
||||
`can_start`, `seats[{index, name, color, ready, host, connected}]`,
|
||||
`spectators[{name, color}]`, `unlocked` (for the requesting session), `round_no`.
|
||||
- Emotes: `emote(token, id)` validated against an `EMOTES` frozenset constant, throttled
|
||||
to 2/s per connection, re-broadcast as an event (no chat in v1).
|
||||
2. `docs/ROOMS.md` — host rules, spectator semantics, name rules, emote list.
|
||||
3. `tests/integration/test_lobby.py` — ready toggles visible in `lobby_payload`,
|
||||
solo-start allowed, 2-player start requires a non-host ready, non-host `start`/
|
||||
`set_level` rejected, host handoff in lobby and *not* in play, spectator join during
|
||||
play + ignored inputs, spectator removal on leave, color stability across reconnect,
|
||||
emote whitelist + throttle, name sanitization table (control chars, 40 chars, emoji,
|
||||
`"`, `<script>`, whitespace-only → fallback name).
|
||||
4. `tests/unit/test_sanitize_name.py` — property-ish table incl. unicode, RTL marks, zero
|
||||
width chars, and a length-1000 input; output length ≤ 16 and never contains `<`, `>`, `"`.
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: lobby behaviors above.
|
||||
- Unit: sanitizer hardening.
|
||||
- Coverage: **>90%** on the lobby code paths.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_lobby.py tests/unit/test_sanitize_name.py -q` → green
|
||||
- [ ] `<script>alert(1)</script>` as a display name comes back sanitized and ≤ 16 chars (asserted)
|
||||
- [ ] no mid-round host election occurs (asserted explicitly, not by omission)
|
||||
- [ ] a spectator's `input` is rejected with `not_seated` (asserted)
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
# Task 04 — In-memory session store and campaign progression
|
||||
|
||||
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-07_campaign_progression`
|
||||
|
||||
## Objective
|
||||
Remember, per browser session and purely in memory, which levels are unlocked and the
|
||||
best score per level — the smallest thing that makes a campaign possible (A6).
|
||||
|
||||
## Work
|
||||
1. `src/undercooked/server/sessions.py`:
|
||||
- `SessionRecord` dataclass: `token`, `display_name`, `unlocked_level: int`
|
||||
(campaign *order*, starts at 1), `best: dict[level_id, BestRun]`, `rounds_played`,
|
||||
`dishes_served`, `created_at`, `last_seen`, `endless_best: int | None`.
|
||||
- `BestRun`: `score`, `stars`, `completed_at` (monotonic-relative, not wall-clock PII —
|
||||
store `time.time()` rounded to seconds is fine and documented; no timezone, no IP).
|
||||
- `SessionStore(max_entries=10_000)`:
|
||||
- `get_or_create(token) -> SessionRecord`, `record_result(token, level, result_payload)`
|
||||
→ updates `best` when the score beats the stored one, bumps `unlocked_level` to
|
||||
`level.order + 1` **only on `WON`** and never decreases it, increments counters;
|
||||
- `is_unlocked(token, level_id) -> bool`, `next_level_id(token, content) -> str | None`,
|
||||
- LRU eviction on insert (oldest `last_seen` first), `stats()` for `/api/rooms`
|
||||
diagnostics, `to_public(token)` returning only what the client may see.
|
||||
- Optional bounded persistence: **none** (A6). A comment at the top of the module
|
||||
states "in-memory only; a process restart clears all progression" and the README
|
||||
repeats it (US-07 criterion 8).
|
||||
- No personal data: the module must never store IP, user-agent, or raw headers
|
||||
(enforced by test).
|
||||
2. Wire it: `RoomSupervisor` owns a `SessionStore`; `POST /api/rooms` creates/uses a
|
||||
session from the `X-Undercooked-Token` header (or a `token` body field) and returns the
|
||||
token in the body **once**; `GET /api/session` returns `to_public(...)`; the `Room`
|
||||
asks `session_store.is_unlocked(...)` in `set_level` (task 03) and
|
||||
`next_level_id(...)` for `next_level` (task 02); `round_over` calls
|
||||
`record_result` for every seated player.
|
||||
3. `tests/integration/test_sessions.py` — unlock only on win, unlock monotonic (win L1 →
|
||||
L2 unlocked; lose L2 → still unlocked), best score replaces only when higher, stars
|
||||
recorded, counters accumulate across rooms, `to_public` leaks nothing beyond the public
|
||||
shape, unknown token auto-creates at `unlocked_level=1`, LRU eviction evicts the stalest
|
||||
and never the current session, 10 001 sessions stay bounded in size.
|
||||
4. `tests/unit/test_session_privacy.py` — assert the `SessionRecord` field set and a
|
||||
`repr()` scan for `ip`, `user_agent`, `authorization`, `cookie`; assert
|
||||
`SessionStore` has no attribute holding raw headers.
|
||||
5. `docs/ROOMS.md` — "Progression and what survives a restart".
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: unlock/best/LRU/privacy behaviors.
|
||||
- Unit: privacy surface.
|
||||
- Coverage: **>90%** on `sessions.py`.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_sessions.py tests/unit/test_session_privacy.py -q` → green
|
||||
- [ ] winning level 1 then losing level 2 leaves level 2 unlocked (asserted)
|
||||
- [ ] a lower score never overwrites a stored best (asserted)
|
||||
- [ ] `GET /api/session` with a fresh token returns `unlocked: 1` and empty `best`
|
||||
- [ ] full test suite **100% passing**
|
||||
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
|
||||
- [ ] no behavior change in completed work
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
# Task 05 — Lifecycle integration, GC behaviour, crash isolation
|
||||
|
||||
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`, `US-03_ready_up_and_start`
|
||||
|
||||
## Objective
|
||||
Prove the whole lobby/room lifecycle end to end over HTTP (still no WebSockets),
|
||||
including the unglamorous parts: idle expiry, seat grace, capacity refusal, and one room
|
||||
failing without taking the server down.
|
||||
|
||||
## Work
|
||||
1. `tests/integration/test_lifecycle_end_to_end.py` — using `TestClient` + direct
|
||||
`tick_once` driving (no sleeps):
|
||||
- full happy path: create → 3 joins → readies → host start → countdown completes →
|
||||
tick to `WON` → `ROUND_END` payload correct → `next_level` → back to `LOBBY` with the
|
||||
same seats and colors;
|
||||
- solo path: create → start (no other players) → win → unlock advanced;
|
||||
- full-room path: 5th join refused `room_full`, code retained;
|
||||
- bad-code path: `GET /api/rooms/ZZZZZ` and `GET /api/rooms/nope!` return the *same*
|
||||
status and body shape (US-02 criterion 4 — no oracle for room existence beyond
|
||||
"not here"), and the response is rate-limit friendly (no stack trace);
|
||||
- abandon path: all players leave a `LOBBY` → after `SEAT_GRACE_S` + one `gc_pass()`
|
||||
the room is `CLOSED` and its code no longer resolvable;
|
||||
- idle lobby expiry with `FakeClock` advancing past `lobby_idle_timeout_s`;
|
||||
- capacity: with `max_rooms=2`, the third `POST /api/rooms` → 503 `room_full`, and the
|
||||
existing rooms are untouched.
|
||||
2. `tests/integration/test_room_isolation.py`:
|
||||
- two live rooms; inject an exception into room A's sim via a test-only
|
||||
`UNDERCOOKED_TEST_HOOK` seam (a `Callable` on `Room` named `_test_step_override`,
|
||||
set only in tests — never read from the network);
|
||||
- assert: room A becomes `CLOSED`/aborted with an error event delivered, room B keeps
|
||||
ticking correctly, the supervisor's `gc_pass()` reaps room A, and the server keeps
|
||||
serving `/healthz`.
|
||||
3. `tests/integration/test_no_wall_clock.py` — grep test: no `asyncio.sleep(` with a
|
||||
non-zero literal and no `time.time()`/`time.monotonic()` inside
|
||||
`src/undercooked/server/room*.py`, `rooms.py`, `sessions.py` (all timing through
|
||||
`Clock`), so the lifecycle stays deterministically testable.
|
||||
4. `docs/ROOMS.md` — add the "Lifecycle walkthrough" section mirroring the happy-path
|
||||
test step by step, and the "Failure and expiry" section (grace windows, GC period,
|
||||
crash policy).
|
||||
5. Docs sweep: confirm README mentions the token header/`/api/session`, `SEAT_GRACE_S`,
|
||||
`GUEST_IDLE_TIMEOUT_S` naming is consistent between README table, `config.py`, and
|
||||
docs (rename in this task if they drifted, updating all three).
|
||||
|
||||
## Testing & Quality
|
||||
- Integration: the full lifecycle, expiry, capacity, and isolation suites above.
|
||||
- Coverage: **>90%** on new/modified code; the phase must leave `server/room.py`,
|
||||
`server/rooms.py`, `server/sessions.py` each ≥ 95%.
|
||||
- Full suite green; `bash .agents/validate.sh` exits 0.
|
||||
|
||||
## Completion Criteria
|
||||
- [ ] `uv run pytest tests/integration/test_lifecycle_end_to_end.py -q` → green (all five paths)
|
||||
- [ ] `uv run pytest tests/integration/test_room_isolation.py -q` → one room's failure does not affect the other
|
||||
- [ ] `uv run pytest tests/integration/test_no_wall_clock.py -q` → no direct clock calls in the lifecycle modules
|
||||
- [ ] `curl` walk-through of create → join → ready → (HTTP has no start yet: document the gap in `docs/ROOMS.md` as closed by phase 06) works as written
|
||||
- [ ] full test suite **100% passing**, coverage >90%
|
||||
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
|
||||
- [ ] no behavior change in completed phases
|
||||
Reference in New Issue
Block a user