Files
undercooked/AGENTS.md
T
ducoterra f51ba919af 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`.
2026-10-08 05:17:21 -04:00

222 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md — operating rules for this repository
## 1. Read this order, every time
1. `.agents/PLAN.md` — master design. Its **Architectural Anchors** table holds the
**LOCKED DECISIONS**, which are binding on every task and phase.
2. This file.
3. `.agents/phases/todo/<phase>/00_phase.md`, then the task file you are executing.
4. The user story referenced by the phase (`.agents/user_stories/US-XX_*.md`).
5. `.agents/phases/complete/*/00_phase.md` for 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-execution` skill (`run-task.sh`, `run-phase.sh`,
`auto-phase.sh`). Each task runs in a fresh subprocess and must pass
`.agents/validate.sh` before it moves to `complete/`. That gate fails fast in this
order: `ruff check .` → `pyright` → `undercooked-levels validate` (unconditional:
the same loader + PLAN §8 rules `create_app` runs at startup, over
`$UNDERCOOKED_LEVELS_DIR`) → the **full** `pytest` suite 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-authoring` skill as a phase directory — not
implemented straight from chat.
- **Never** modify `.agents/PLAN.md` or 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, no `random` without 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, `ContentError` propagates and the `undercooked`
command exits `2` like it does for an impossible setting. The content API
projects through `extra="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.py` is the lock — it adds a level YAML to a
copy of `levels/` and expects the loader, `/api/levels` and the CLI to carry it with
no Python edited, and fails if any `src/undercooked/**.py` or 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 .` and `uv run pyright` report zero errors.
- [ ] `uv run undercooked-levels validate` passes — `.agents/validate.sh` runs it for
every task, whether or not any content file changed.
- [ ] `README.md` and `AGENTS.md` updated 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 undercooked` serves `http://127.0.0.1:8000/healthz`
with `200` and the client at `/`.
- [ ] No behavior regression in any completed phase.
## 5. Hard rules
- **Configuration is read once, in `config.py`.** `src/undercooked/config.py` is
the only module that touches `os.environ`. To add a knob: add a `Settings`
field + `ENV_KEYS` entry + a line in `.env.example` + a row in the README table
(a test fails when those disagree), and read it from `get_settings()`. Values
are validated at startup: an impossible one raises `ConfigError` rather than
half-configuring the server (`undercooked` prints one line and exits `2`).
Precedence is **CLI flag > environment > built-in default**: `__main__.py`
resolves it purely, then hands the flags to `config.apply_cli_overrides()`,
which is the only writer of `os.environ` (so a uvicorn `--reload` child
re-reads the same values). `DEBUGPY=1` binds a debugger listener on `0.0.0.0`
— dev-only, never on a public host.
- **Time flows through an injected `Clock`.** Every time-dependent object in
`server/` (supervisor, rooms, the session store) receives a
`Clock` (`server/clock.py`: `SystemClock`, `FakeClock`) and no module
outside `clock.py` may import `time`; `tests/unit/test_server_clock_discipline.py`
greps for the `time.monotonic()`/`time.time()` call literals and for stray
`time` imports, and the GC loop's sleep is injectable — tests drive the
whole lifecycle with a `FakeClock` and never sleep.
- **No external CDNs or remote assets.** `tests/unit/test_no_external_assets.py`
fails the build on `http://`/`https://`/`//host` references, `@import url(`,
`srcset`, an absolute-URL `fetch()`/`import`, an absolute-URL `WebSocket`, or
`EventSource` anywhere in `app/static/**` (html/css/js/svg), and requires every
`<script src>`/`<link href>`/`<img src>` in `index.html` to be a same-origin
`/static/...` path. It scans the same `STATIC_DIR` `server/app.py` mounts, so it
cannot pass by scanning the wrong folder; the exemptions are XML `xmlns` URIs, which
are identifiers the browser never fetches, and a `data:` URL in an `index.html`
element 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 is
`default-src 'self'` (plus `ws:`/`wss:` same-origin) and is applied by
`server/headers.py` to **every** response, including errors. FastAPI's `/docs`,
`/redoc` and `/openapi.json` stay disabled because their bundled HTML pulls Swagger
assets from `cdn.jsdelivr.net`; the wire contract lives in `docs/PROTOCOL.md`
(written by phase 06) instead.
- **Docs are guarded like code.** `tests/unit/test_docs_commands.py` requires every
command line in a `bash` fence of `README.md`/`docs/*.md` to start with a known-safe
prefix (`uv `, `podman `, `curl `, `cd `, `bash .agents/validate.sh`, `#`, or a
documented `VAR=value` lead) and to launch only a program declared in
`pyproject.toml`, and keeps the `UNDERCOOKED_*`/`DEBUGPY*` names in the docs and the
ones in `config.py`/`.env.example` equal 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.py` keeps PLAN §13's documentation index and the `docs/`
tree identical (no missing doc for a shipped phase, no doc nothing promises), and
`docs/TESTING.md` is 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.md` and 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 in `app/static/js/app.js`,
which is the same entropy and the only option in a browser) held in
`sessionStorage`. 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 with `sim/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 any `dt` the kernel can hand it, and sliding needs no special case. Two
rules belong to `collision.py` alone 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 as
`resolve_inputs`. `PlayerInput.move` is untrusted: `intent_direction()` clamps its length,
so a client sending `(50, 0)` walks at `MAX_SPEED` like 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 bumping `PROTOCOL_VERSION` (PLAN §11).
- **Determinism is a contract.** `sim/step.py` advances the round in whole `STEP_DT`
(0.05 s) substeps with `dt` clamped to `[0, MAX_DT]`, a fixed subsystem order, and no
randomness outside `sim/rng.py` (splitmix64, seeded by `KitchenState.rng_state`) — so the
same seed plus the same input script produces identical state. The guard is
`tests/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.json` against `tests/sim/test_replay.py`, the
input-script format and the `python -m undercooked.sim.replay record|check` CLI
in `src/undercooked/sim/replay.py`) and the perf budgets (`tests/sim/test_perf_smoke.py`
and `tests/sim/test_perf_full_pipeline.py`, both `slow`) close it out. `tests/sim/test_purity.py` holds the A5 boundary the
whole thing rests on. The contract — constants, the 8-stage order, what each hook still
owes — is `docs/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`, subject `phase: <NN_name>`. Child executors
never run `git add`/`commit`/`mv`/`rm`/`stash`. When a human asks for a commit, use
the `git-commit` skill (`--no-gpg-sign`, `LLM-Generated: true` trailer).
- 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 **`playwright` sync API directly with fixtures written in
`tests/e2e/conftest.py`** — `pytest-playwright` is deliberately not a dependency, so
adding it needs the usual permission. Those `browser`/`context`/`page` fixtures are
**function-scoped on purpose**: a long-lived sync driver keeps an asyncio loop flagged
as running in the main thread and breaks every later `async def` test.
- `live_server` (session-scoped, `tests/conftest.py`) is the only fixture that binds a
socket: an ephemeral `127.0.0.1` port, never a routable interface.