**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`.
228 lines
29 KiB
Markdown
228 lines
29 KiB
Markdown
# 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.
|