Files
undercooked/AGENTS.md
T
ducoterra 4febdfe780 phase: 03_simulation_movement_and_interaction
**Final verification pass — phase 03 `simulation_movement_and_interaction` (all 5 task files already in `complete/`)**

- Verified all phase-03 deliverables exist and work: `sim/` kernel (`step`, `state_build`, `rng`, `movement`, `collision`, `interact`, `carry`, `replay`), replay fixtures + 3 committed goldens, `docs/SIMULATION.md`.
- `bash .agents/validate.sh` → **passed**: ruff `All checks passed!`, pyright `0 errors`, `undercooked-levels validate` green, full `pytest` **1033 passed** (coverage **99.71%** ≥ 90% floor), slow sim stage green, repo-hygiene clean.
- Perf smoke (4 players, 2 000 ticks): mean **0.049 ms/tick, 20 423 ticks/s** — ≥ 10 000 ticks/s criterion met (test lives in `test_perf_smoke.py`, which the gate's slow stage runs; the overview names it `test_replay.py`).
- Completion criteria: import `undercooked.sim.step.step` ✅ · `tests/sim` green incl. determinism + goldens ✅ · perf ✅ · constant-change-fails-by-design ✅ (committed-hash equality + mutation test naming first bad tick/field; goldens never rewritten by tests) · `docs/SIMULATION.md` ✅ (constants, 8-step order, purity/determinism, re-bless) · full suite + coverage ✅ · app launches: `/healthz` 200, `/` 200, `/api/levels` serves content ✅ · replay CLI `check walk_solo.json` exit 0 ✅ · no regression in phases 01/02 (their suites green) ✅.
- Documentation: README.md — fixed one stale inconsistency (Status opening listed only 01/02 complete with 03 pending; now lists 01–03 complete, next pending `04_kitchen_pipeline_and_scoring`); all other README claims (CLI flags, config table, shell text, replay CLI exit codes) verified against code. AGENTS.md — verified consistent with the project as it stands, no changes needed.
- No deviations. Next pending phase: **`04_kitchen_pipeline_and_scoring`**.
2026-10-07 02:46:10 -04:00

211 lines
16 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.
- **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 smoke (`tests/sim/test_perf_smoke.py`,
`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`, `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` |
| 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`, `room.py` |
| 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 smoke), `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.