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`.
18 KiB
AGENTS.md — operating rules for this repository
1. Read this order, every time
.agents/PLAN.md— master design. Its Architectural Anchors table holds the LOCKED DECISIONS, which are binding on every task and phase.- This file.
.agents/phases/todo/<phase>/00_phase.md, then the task file you are executing.- The user story referenced by the phase (
.agents/user_stories/US-XX_*.md). .agents/phases/complete/*/00_phase.mdfor what already exists (do not re-build it).
2. The phased protocol
- Phase state lives in files, not chat. Pending work:
.agents/phases/todo/NN_name/(00_phase.md+NN_task.md). Finished work:.agents/phases/complete/. - Execution is owned by the
phased-executionskill (run-task.sh,run-phase.sh,auto-phase.sh). Each task runs in a fresh subprocess and must pass.agents/validate.shbefore it moves tocomplete/. That gate fails fast in this order:ruff check .→pyright→undercooked-levels validate(unconditional: the same loader + PLAN §8 rulescreate_appruns at startup, over$UNDERCOOKED_LEVELS_DIR) → the fullpytestsuite with the coverage floor → the slow sim stage (pytest -m slow tests/sim: the replay goldens and the perf smoke, named on their own) → repo-hygiene checks. It runs from the repo root or from.agents/. - New work is authored by the
phase-authoringskill as a phase directory — not implemented straight from chat. - Never modify
.agents/PLAN.mdor anything under.agents/phases/complete/. - Ask the user before editing an existing file in
.agents/phases/todo/. - Do not renumber or reuse phase/task numbers.
3. LOCKED DECISIONS (summary — the table in PLAN.md is authoritative)
- Python 3.12+ / uv / FastAPI / native WebSockets (
uvicorn[standard]). No Socket.IO, no SSE, no WebRTC, no REST for live game traffic. - Server-authoritative fixed-tick simulation (20 Hz) with client interpolation.
The browser never decides anything true.
src/undercooked/sim/is pure: no FastAPI, no asyncio, no I/O, no wall-clock, norandomwithout the round RNG. - No database, no ORM, no Alembic. Live state lives in the room supervisor; results/scoreboards live in memory and die with the process. User-approved deviation from the default FastAPI stack.
- Data-driven content. Levels/recipes/hazards come from YAML in
levels/loaded through validated Pydantic models. No hard-coded level or recipe lists in Python.create_app()loads the bundle once (routes_content.load_validated_content) and refuses to start when content fails to load or violates a PLAN §8 rule — the named-rule report goes to stderr,ContentErrorpropagates and theundercookedcommand exits2like it does for an impossible setting. The content API projects throughextra="forbid"response models: grids, station ids and spawn coordinates never leave the server.undercooked-levels(tools/levels.py, its author CLI) obeys the same rule: it prints whatever the loaded directory holds.tests/unit/test_content_not_hardcoded.pyis the lock — it adds a level YAML to a copy oflevels/and expects the loader,/api/levelsand the CLI to carry it with no Python edited, and fails if anysrc/undercooked/**.pyor browser asset names a shipped level or recipe id as a literal. - Client: Canvas 2D + vanilla ES modules + this repo's own SVG art. No bundler, no framework, no CDN, no analytics, no external fonts.
- Tests: pytest + Playwright. One Playwright suite per user story; multi-player stories use two browser contexts against one room.
- Quality gate: ruff + pyright + full pytest, >90% coverage on new/modified code, entire suite 100% passing before a phase may complete or commit.
4. Definition of done (every task)
- Task's own work exists at the named paths and is importable/runnable.
- Tests written for the task's logic (unit for pure logic, integration for rooms/protocol, e2e for user-visible flows) and passing.
- Full project suite passes (
uv run pytest), coverage gate met — including failures the task did not cause: fix them, do not skip, xfail, or delete tests. uv run ruff check .anduv run pyrightreport zero errors.uv run undercooked-levels validatepasses —.agents/validate.shruns it for every task, whether or not any content file changed.README.mdandAGENTS.mdupdated when the change is user-visible (setup, commands, config, protocol, features, conventions). Every phase's final pass re-reviews both in full, project-wide and fixes inconsistencies, including pre-existing ones.- App still launches:
uv run undercookedserveshttp://127.0.0.1:8000/healthzwith200and the client at/. - No behavior regression in any completed phase.
5. Hard rules
- Configuration is read once, in
config.py.src/undercooked/config.pyis the only module that touchesos.environ. To add a knob: add aSettingsfield +ENV_KEYSentry + a line in.env.example+ a row in the README table (a test fails when those disagree), and read it fromget_settings(). Values are validated at startup: an impossible one raisesConfigErrorrather than half-configuring the server (undercookedprints one line and exits2). Precedence is CLI flag > environment > built-in default:__main__.pyresolves it purely, then hands the flags toconfig.apply_cli_overrides(), which is the only writer ofos.environ(so a uvicorn--reloadchild re-reads the same values).DEBUGPY=1binds a debugger listener on0.0.0.0— dev-only, never on a public host. - Time flows through an injected
Clock. Every time-dependent object inserver/(supervisor, rooms, the session store) receives aClock(server/clock.py:SystemClock,FakeClock) and no module outsideclock.pymay importtime;tests/unit/test_server_clock_discipline.pygreps for thetime.monotonic()/time.time()call literals and for straytimeimports, and the GC loop's sleep is injectable — tests drive the whole lifecycle with aFakeClockand never sleep. - No external CDNs or remote assets.
tests/unit/test_no_external_assets.pyfails the build onhttp:///https://///hostreferences,@import url(,srcset, an absolute-URLfetch()/import, an absolute-URLWebSocket, orEventSourceanywhere inapp/static/**(html/css/js/svg), and requires every<script src>/<link href>/<img src>inindex.htmlto be a same-origin/static/...path. It scans the sameSTATIC_DIRserver/app.pymounts, so it cannot pass by scanning the wrong folder; the exemptions are XMLxmlnsURIs, which are identifiers the browser never fetches, and adata:URL in anindex.htmlelement attribute where the CSP already allows it (img-src 'self' data:) — the app's own bytes either way. The US-01 e2e suite additionally fails if loading the page ever issues an off-origin request. CSP isdefault-src 'self'(plusws:/wss:same-origin) and is applied byserver/headers.pyto every response, including errors. FastAPI's/docs,/redocand/openapi.jsonstay disabled because their bundled HTML pulls Swagger assets fromcdn.jsdelivr.net; the wire contract lives indocs/PROTOCOL.md(written by phase 06) instead. - Docs are guarded like code.
tests/unit/test_docs_commands.pyrequires every command line in abashfence ofREADME.md/docs/*.mdto start with a known-safe prefix (uv,podman,curl,cd,bash .agents/validate.sh,#, or a documentedVAR=valuelead) and to launch only a program declared inpyproject.toml, and keeps theUNDERCOOKED_*/DEBUGPY*names in the docs and the ones inconfig.py/.env.exampleequal in both directions. So: never document a command that does not run yet — describe it in prose until the phase that ships it.tests/unit/test_docs_index.pykeeps PLAN §13's documentation index and thedocs/tree identical (no missing doc for a shipped phase, no doc nothing promises), anddocs/TESTING.mdis the reference for test layout, markers and artifact policy. - No new technology (dependency, framework, service) without explicit written
user permission recorded in the phase's
00_phase.mdand signed off into PLAN.md. - The simulation is the single source of truth. Client-side values are guesses.
Never trust inbound coordinates, scores, timers, or level ids — validate every
inbound field against the room's state (
server/protocol.py). - Anonymous by design. No accounts, no emails, no cookies with personal data.
Identity = 24 random bytes per browser tab (
secrets.token_urlsafe(24)when the server mints one,crypto.getRandomValues+ base64url inapp/static/js/app.js, which is the same entropy and the only option in a browser) held insessionStorage. Names are sanitized, length-capped, non-authoritative. - Movement is swept, never corrected. Stage 3 (
sim/movement.py) integrates one axis at a time and clamps each axis withsim/collision.sweep_axis()to the greatest legal delta, so a body is stopped before it intrudes — a cook never ends a tick inside a blocked tile, at anydtthe kernel can hand it, and sliding needs no special case. Two rules belong tocollision.pyalone and must not be duplicated in a caller: which tiles and fixtures block (PASS_THROUGH_STATIONS= serve/sink/trash; shelf, chop board, stove and prep block), and which cells a body covers (body_cells(),_edge_cells()) — the footprint and the sweep have to answer with the same arithmetic or a cook ends a tick 1e-16 inside a wall, which is invisible and fatal for a replay golden. Cooks push cooks positionally only (no velocity is ever injected by a push), and only a genuine terrain deadlock drops an input — the later-received one, the same arbitration asresolve_inputs.PlayerInput.moveis untrusted:intent_direction()clamps its length, so a client sending(50, 0)walks atMAX_SPEEDlike everyone else. - Frame and rate limits are enforced, not suggested:
max_message_bytes,max_inputs_per_s, room caps, and message-type whitelists per room state. - Protocol changes need a version bump: editing a wire message means editing
src/undercooked/server/protocol.py,docs/PROTOCOL.md, and the client in the same task, and bumpingPROTOCOL_VERSION(PLAN §11). - Determinism is a contract.
sim/step.pyadvances the round in wholeSTEP_DT(0.05 s) substeps withdtclamped to[0, MAX_DT], a fixed subsystem order, and no randomness outsidesim/rng.py(splitmix64, seeded byKitchenState.rng_state) — so the same seed plus the same input script produces identical state. The guard istests/sim/test_rng.py(published golden vectors) +tests/sim/test_step_kernel.py(clamping, accumulation, purity, cross-run hash equality); the committed replay goldens (tests/goldens/*.golden.jsonagainsttests/sim/test_replay.py, the input-script format and thepython -m undercooked.sim.replay record|checkCLI insrc/undercooked/sim/replay.py) and the perf budgets (tests/sim/test_perf_smoke.pyandtests/sim/test_perf_full_pipeline.py, bothslow) close it out.tests/sim/test_purity.pyholds the A5 boundary the whole thing rests on. The contract — constants, the 8-stage order, what each hook still owes — isdocs/SIMULATION.md; change the tick, the RNG or the step order and that file and the goldens move in the same commit. - ntfy is outbound-only. Never use it to ask questions or wait for approval. If a task is ambiguous, make the best judgment consistent with PLAN.md, or stop and let the user rewrite the task.
- Commits are made by the pipeline harness (
PHASE_COMMIT=1), one atomic commit per completed phase,--no-gpg-sign, subjectphase: <NN_name>. Child executors never rungit add/commit/mv/rm/stash. When a human asks for a commit, use thegit-commitskill (--no-gpg-sign,LLM-Generated: truetrailer). - Do not commit
.env, logs, coverage output,.agents/phase-sessions/,.agents/pipeline.log, or.agents/reports/(all gitignored).
6. Where things go
| Change | Location |
|---|---|
Configuration (env vars → typed Settings), debugpy bootstrap |
src/undercooked/config.py, .env.example, src/undercooked/debug.py |
| Pure game rules (movement, stations, orders, scoring, hazards) | src/undercooked/sim/ |
The fixed-step tick: step/build_state, the seeded RNG, the subsystem hooks (contract: docs/SIMULATION.md) |
src/undercooked/sim/step.py, state_build.py, rng.py, movement.py, interact.py, carry.py, stations.py, freshness.py, orders.py, hazards.py, round_state.py |
| Movement/collision: velocity integration, facing, stun, soft push, and the pure geometry under it (what blocks, the swept stop, the body footprint) | src/undercooked/sim/movement.py, src/undercooked/sim/collision.py (rules in docs/SIMULATION.md §"Movement and collision") |
The action button: reach-zone targeting, the verb ladder, named DENIED reasons, the one-press-one-event gate, contested arbitration and the long-press trash |
src/undercooked/sim/interact.py (rules in docs/SIMULATION.md §"The action button") |
The hands: one-item-per-hand carry, the marked set-down and the counter hand-off, the always-available long-press trash (contract: docs/SIMULATION.md §"The hand") |
src/undercooked/sim/carry.py |
The station pipeline: the acceptance matrix, the transition timers, the worker hold and release, dirty props and the plate/pot stock (contract: docs/SIMULATION.md §"The station pipeline") |
src/undercooked/sim/stations.py |
Freshness (stage 4b): the burn countdown (tick-exact, 8.0 s), the board's linear decay (20 s), the half-rate carry, the burnt WASTED push-out (a usable raw is never overwritten), and the dirty loop's stock (use dirties, a wash cleans, …_clean + …_dirty conserved; the no-dead-end guarantee) (contract: docs/SIMULATION.md §"Freshness") |
src/undercooked/sim/freshness.py |
Serve resolution and scoring: the dish's named rejections (NO_ORDERS/WRONG_RECIPE/INCOMPLETE/BURNT/NO_TARGET), FIFO order matching, round(base × quality × streak × time_bonus) with the capped streak and the deadline share, the miss penalty and streak reset, the per-player contribution (rules: docs/SIMULATION.md §"Serve scoring") |
src/undercooked/sim/scoring.py |
| Level/recipe content | levels/*.yaml |
Content schema, ASCII-grid parser, content loader, playability validator (contract: docs/LEVELS.md) |
src/undercooked/sim/recipe.py, level.py, content.py, validation.py |
| Rooms, lobby lifecycle, session records | src/undercooked/server/rooms.py (the supervisor: registry, capacity, the one-token-one-room index, the 5 s GC, start/shutdown), room.py (the guarded FSM: the RoomState/ROOM_TRANSITIONS table, the round data, the per-tick input queue, the one owned deadline-scheduled tick task and its crash isolation, tick_once — contract: docs/ROOMS.md), clock.py (injectable time — the only server/ module that imports time; a grep test keeps the wall clock out of the rest), codes.py (Crockford code minting + input normalisation), seats.py (the lobby's data: Seat/Spectator, the name boundary, the 4-colour palette, the host handoff, the emote whitelist, the named lobby refusals), routes_rooms.py (POST /api/rooms, GET /api/rooms/{code}, POST /api/rooms/{code}/join, GET /api/session — the lifecycle over HTTP before the phase-06 transport), sessions.py (SessionRecord + BestRun + mint_token + the bounded LRU SessionStore: record_result's win-only unlock bump (monotonic), best-score replacement, is_unlocked / next_level_id, to_public — in memory; a process restart clears all progression) |
| Wire messages + validation | src/undercooked/server/protocol.py, docs/PROTOCOL.md |
HTTP app (/, /healthz, /static), lifespan, startup content gate |
src/undercooked/server/app.py |
Content HTTP API (/api/levels, /api/levels/{id}, /api/config) + strict response models |
src/undercooked/server/routes_content.py |
Author CLI undercooked-levels (validate, list, show, new, axes); workflow in docs/LEVELS.md |
src/undercooked/tools/levels.py |
The replay harness: input scripts, golden hashes, run_script/compare, the record/check CLI (contract: docs/SIMULATION.md "Replaying and re-blessing goldens") |
src/undercooked/sim/replay.py, tests/fixtures/replays/, tests/goldens/, tests/sim/test_replay.py, tests/sim/test_perf_smoke.py (slow) |
Security headers (CSP default-src 'self', no-sniff, frame-ancestors) |
src/undercooked/server/headers.py |
CLI entry point (undercooked): flags → --help → serve |
src/undercooked/__main__.py |
| Renderer, HUD, input, interpolation | app/static/js/, app/static/assets/ |
| Documentation (design / testing / deployment; per-phase docs) | docs/*.md, indexed by PLAN §13 and guarded by tests/unit/test_docs_index.py |
| The pipeline gate itself | .agents/validate.sh |
| User-visible flow contract | .agents/user_stories/US-XX_*.md |
Tests mirror that: tests/unit/ (pure, no-I/O logic incl. HTTP-route unit tests),
tests/integration/ (in-process app boot, live uvicorn + WebSocket clients), tests/sim/
(the pure kernel without a server: grid geometry, entity model, the A5 purity guard,
the level validator, the seeded RNG's golden vectors and the tick's determinism —
plus the replay harness: committed goldens in tests/goldens/, scripts in
tests/fixtures/replays/, and the slow perf budgets — the random-walk
smoke and the full-pipeline run), tests/e2e/ (Playwright, named test_usNN_*.py, marked e2e). tests/ is
not a package; shared fixtures live in tests/conftest.py, and the autouse fixture there
points UNDERCOOKED_LEVELS_DIR at tests/fixtures/levels/ so no test silently reads the
shipped levels/.
- The browser suites use the
playwrightsync API directly with fixtures written intests/e2e/conftest.py—pytest-playwrightis deliberately not a dependency, so adding it needs the usual permission. Thosebrowser/context/pagefixtures are function-scoped on purpose: a long-lived sync driver keeps an asyncio loop flagged as running in the main thread and breaks every laterasync deftest. live_server(session-scoped,tests/conftest.py) is the only fixture that binds a socket: an ephemeral127.0.0.1port, never a routable interface.