Files
undercooked/AGENTS.md
T
ducoterra 95ca3fd1fa phase: 07_canvas_client_and_hud
**Final report — phase 07 final verification pass**

- **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found.
- **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase).
- **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔.
- **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry.
- **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`.
- **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate.
- **Next pending phase:** `08_client_prediction_and_touch`.
2026-10-10 18:03:52 -04:00

228 lines
29 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; its message tables are kept equal to
`server/protocol.py`'s models by `tests/unit/test_protocol_doc_sync.py`) 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/identity.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, the ghost park (a disconnected cook's stale input does not move it; a stun that runs out on a disconnected cook revives it `DISCONNECTED`, not `IDLE`), 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 ghost: what a dropped cook's state and hands do (`sim/ghost.py` — the sim's half, pure; the wall-clock grace is the room's): `mark_ghost` (→ `connected=False`, `DISCONNECTED`; idempotent), `restore_player` (→ `connected=True`, `IDLE`; keeps a still-carried item; idempotent), `release_ghost_carry` (the park's release — once, idempotent: onto the station on the cook's tile via `place_item`/`DROPPED`, or into `counters["lost_items"]` + `GHOST_PARK`; an item is never deleted silently), `station_at`, `PARK_AFTER_S = 3.0` (guards: `tests/unit/test_ghost_parking.py`; the room's half in `tests/integration/test_ws_disconnect.py`) | `src/undercooked/sim/ghost.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` — a room created while the GC loop is running already gets its tick task), `room.py` (the guarded FSM: the `RoomState`/`ROOM_TRANSITIONS` table, the round data, the per-tick input queue (capacity `INPUT_QUEUE_PER_SEAT x len(seats)`, overflow drops the sender's oldest frame, a zero-substep tick keeps the queue so a frame is never dropped without being applied), `stats()` (the lobby/round counters plus `inputs_received`/`inputs_dropped`/`stale_seqs`/`clamped_moves`/`large_seq_gaps`), the one owned deadline-scheduled tick task and its crash isolation, `tick_once`, the ghost and seat-grace verbs (`on_disconnect` — mark + one `GHOST_ON` + the sim switch, a no-op if already a ghost; `on_reconnect` — mark + one `GHOST_OFF` only when it was a ghost + the sim restore; `_park_ghosts` — the per-`PLAYING`-tick park off `seat.last_seen`, one release per token per round; `_abandon_expired`/`_abandon_seat` — the `SeatAbandoned` at `guest_idle_timeout_s` in every state: forced release + `GHOST_OFF` + the seat out; the `on_close` hook awaited by `close()`) — 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` (the message models, `parse_client_message` — never raises, the `ErrorCode`/`CloseCode` constants, `MESSAGE_WHITELIST`), `docs/PROTOCOL.md` (the contract in prose; `tests/unit/test_protocol_doc_sync.py` keeps the two equal) |
| The snapshot encoder (task 04): `encode_snapshot` (the PLAN §4 shape, quantized, explicit field maps per type — no `pickle`/`asdict`; no `token`/`display_name`/`seed`/`rng_state`/station-internal-id ever reach the wire), the delta mode (diff vs the previous frame, `gone` tombstones), `encode_events` (the stable `k` names + the `EVENT_PAYLOAD_SHORT` map, `MAX_EVENTS_PER_FRAME = 24`), `snapshot_size` (the UTF-8 budget hook; a 4p/4o/3h frame ≤ 1.5 KB, a 64×64 level ≤ 4 KB), the `SnapshotFanout` (encode-once/fan-out-many, the per-audience `prev` + the `FULL_SNAPSHOT_EVERY_TICKS = 20` cadence, the `needs_full` resync, the non-destructive event cursor, the `events_dropped` count) in `server/ws.py`, the room's pacing seam (`_snapshot_pacing_tick`, the seq-tagged `event_buffer`, the `countdown`/`round`/`ev` broadcasts) in `server/room.py` (contract: `docs/PROTOCOL.md` "The `snap` frame"; guards: `tests/unit/test_encoding_no_leak.py`, `tests/integration/test_encoding.py`) | `src/undercooked/server/encoding.py` |
| The WebSocket transport: the pre-accept origin check, the `hello` handshake (timeout → `4400`), the `p` gate, the supervisor seat/spectate (`4401`/`4402`; a `CLOSED` room is an `err room_gone` + close `4401` before any seat), the `welcome`'s `rejoined` flag (judged before the `join`: a token that holds a *disconnected* seat is a rejoin), the `ping`/`pong` keep-alive (any inbound frame refreshes `last_seen`) and the heartbeat watchdog (`HEARTBEAT_DEAD_S = 12` s of silence → the server closes `1000` and the standard teardown runs — the room sees the same drop as a hard close), the `input` ingestion (the `seq` discipline: duplicates dropped, stale → `err` + a `resync` event, gaps ≤ 3 tolerated, larger gaps accepted and counted; `move` clamped to unit length and quantized, `face` derived, `act` coerced, `received_at_tick` stamped server-side; a `DISCONNECTED` seat's frames are silently ignored, spectators get `not_seated`), the per-connection guardrails (size cap → `too_big`, the token bucket → `rate_limited`, the shape check, the per-state whitelist → `wrong_state`; 3 consecutive rate/oversize or 5 shape violations → `4404`; a *binary* frame is a frame the cap and the parser apply to — a `too_big` / a `bad_json`, never a stack exception), one-socket-per-token eviction (`4403`, the evicted socket never marks the seat disconnected), the capped send queue (the oldest snapshot drops first; `lobby`/`round`/`err` never; a `rate_limited` coalesces to one) and the once-only `on_disconnect` teardown | `src/undercooked/server/ws.py` (the `Connection`/`ConnectionRegistry`/`TokenBucket`/`AbuseTracker`, the `seq_verdict`/`to_player_input` input path, the `pump_out`/`read_in`/`heartbeat_watchdog` tasks, the registry's census (`stats()`: `sockets`, the `send_queue_max` high-water mark, `memory_mb` — the phase-06 abuse/budget numbers); contract: `docs/PROTOCOL.md` incl. the manual probe, "Heartbeats, drops, and seat grace" and "Testing the protocol" (the test seams + the budget table with the measured values); `tests/integration/test_ws_handshake.py` drives every guardrail over live sockets, `tests/integration/test_ws_input.py` the input rules over live sockets, `tests/integration/test_ws_disconnect.py` the heartbeat/disconnect/grace/rejoin end to end, `tests/integration/test_ws_round_two_clients.py` the complete two-client round (the `live_server`/`ws_client` fixtures, the `levels_round` campaign), `tests/integration/test_ws_abuse.py` the guardrails at scale (2000 malformed frames, the 500-burst, the 64 KB / 4 KB size cap, the host-check flood, 200 handshakes), `tests/integration/test_ws_budgets.py` the `slow` budgets (tick cost over 400 real ticks, `snap` size, downlink, echo), `tests/unit/test_send_queue_policy.py` the queue policy + bucket arithmetic, `tests/unit/test_ws_input_path.py` the ingestion unit) |
| HTTP app (`/`, `/healthz`, `/static`), lifespan, startup content gate, the `room_close` hook (a closing room closes its own sockets with `err room_gone` + close `4401`) and the test-only seam `POST /api/test/kill_socket/{code}/{token}` (registered only when `UNDERCOOKED_TEST_MODE=1`) | `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` |
| In-repo SVG art: `assets_src/` (source) served from `app/static/assets/` (copies kept in sync by the generator's check) with `index.json`; the palette (`assets/palette.css` + `js/render/palette.js` — `tests/unit/test_assets.py` keeps the two files' hex values identical in both scopes and pins the SVG `var()` fallbacks to palette entries); the atlas loader (`js/render/atlas.js` — decodes each symbol through a `data:` URL because the page CSP blocks the art's `style` hooks and browsers do not render `#symbol` fragments; throws on unknown id); the index generator (a small stdlib script — `uv run python -m undercooked.tools.gen_art_index` — whose expected ids derive from the sim's enums + the `sim/stations.py` pipeline tables, and which fails naming a duplicated or missing id) | `assets_src/`, `app/static/assets/`, `app/static/js/render/`, `src/undercooked/tools/gen_art_index.py` (guards: `tests/unit/test_assets.py`, `tests/e2e/test_atlas_loads.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` |
| The client app: the composition root (`app/static/js/main.js` — the page's single entry module: the URL params, the snapshot buffer + event queue + the single render loop, the wire routing, the `window.__uc` census seam; `main-flag.js` sets the auto-boot guard before `app.js`), the screen state machine (`app/static/js/app.js` — `show()` clears `#screen-root` and mounts exactly one screen, the message→screen routing, the test seam `window.__undercooked`, the lobby-state re-broadcasts drive the lobby screen), the snapshot stream's client half (`app/static/js/net/` — `decode.js` the `applyDelta` mirror of `server/encoding.py`, `snapshots.js` the keyed buffer + `lerpState` + the gap/resync/freeze rules (injected clock), `events.js` the per-kind TTL + dedup + tick-reset queue), the enum parity (`app/static/js/shared/enums.js` — the wire's enum sets, set-equal to the Python enums both directions, `tests/unit/test_client_enum_parity.py`), the seven screens (`app/static/js/screens/` — landing / connecting / lobby / countdown / play / round-end / error, each `mount`/`unmount`; the `dom.js` in that directory is the shared no-innerHTML builder, not a screen), the WebSocket layer (`app/static/js/net/connection.js` — the reconnect loop, the stale-close guard), the identity (`identity.js`), the keyboard input + help overlay (task 05: `app/static/js/input/keyboard.js` — WASD/arrows → the 8-way intent, `Space`/`Enter` the action's rising edge, `1`–`4` the emotes, the key-hold pump (~48 ms ≈ 20.8 Hz, above the 20 Hz tick — a tick with no frame brakes the body) + the 10 Hz zero-intent keep-alive (the ≥ 5 Hz protocol rule), the ignore rules (input focus, `document.hidden`, modifiers), the blur/hidden/pagehide forced zero, `?controls=off` binds nothing (the scripted-test mode — the e2e suites drive the wire through the seam) and `getKeyMap()` (the canonical key map the help overlay and the README's `### Controls` render, `tests/e2e/test_us04_move_and_cook.py` keeps them identical), `app/static/js/ui/help.js` — the `?`/`Esc` help dialog, the key-map rows, no external docs mid-game), the single render loop (`app/static/js/render/loop.js` — pauses on `document.hidden`, idle off-`PLAYING`, the self-measured `frameMs` census), the Canvas renderer + HUD (task 04: `app/static/js/render/camera.js` the fit-to-viewport / DPR / world⇄screen, `app/static/js/render/canvas.js` the layer-order `drawFrame` + the sprite mapping + `renderStats` + the target highlight, `app/static/js/render/fx.js` the event→effect particle pool + the reduced-motion fallbacks, `app/static/js/ui/hud.js` the DOM HUD — the order rail, the score/target, the non-colour-cue countdown, the streak, the dirty counter, the connection, the spectator; one diff-write per frame, no rAF of its own) — contract: `docs/CLIENT.md` (the module map, the machine, the message table, the buffer/loop section, the keyboard-input section, the renderer/HUD section; guards: `tests/e2e/test_us01_landing_surface.py`, `test_us01_create_lobby.py`, `test_us02_join_by_code.py`, `test_us03_ready_and_start.py`, `test_atlas_loads.py`, `test_us04_render_smoke.py`, `test_us06_interp_math.py`, `test_us06_smoothness.py`, `tests/unit/test_client_sprite_map.py`) | `app/static/js/`, `app/static/assets/`, `app/static/index.html` |
| 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` — plus the non-story asset guard `test_atlas_loads.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.