Files
undercooked/AGENTS.md
T
ducoterra 3426264ffb phase: 02_level_content_and_validation
Phase `02_level_content_and_validation` — final verification **green**.

- **Gate:** `bash .agents/validate.sh` → ruff ✅ · pyright 0 errors ✅ · `undercooked-levels validate` OK (2 levels) ✅ · **668 passed** (incl. 10 Playwright e2e) ✅ · coverage **100%** ✅ · repo hygiene ✅
- **Criteria:** `validate` exit 0, one line per level; `list` = `01_first_kitchen`, `02_double_shift` in campaign order; live `:8000` `/healthz` 200, `/api/levels` (+detail) carries both levels with numeric `target_score`/`time_limit` and no grid/station leakage, `/api/config` correct, unknown id → 404, startup log `content loaded: 2 level(s), 4 recipe(s)`; `validate --dir tests/fixtures/levels/_bad/` → exit 1 with named `RECIPE_STATION_MISSING` / `SERVE_REACHABLE` / `TARGET_IMPLAUSIBLE`; A8 lock test proves a YAML-only level reaches loader+API+CLI.
- **Defect fixed (pre-existing path, phase-02-triggered):** `undercooked` with unusable content printed the report then a raw traceback and exited `1`. `src/undercooked/__main__.py` now catches `ContentError` → one closing line, exit `2` (like a bad setting); 2 new tests in `tests/unit/test_main.py` (incl. non-content failures still propagate).
- **Docs pass (full, project-wide):** README (content gate + exit `2`, config section), AGENTS.md §3, `docs/LEVELS.md` (startup refusal + exact CLI exit codes), `docs/DEPLOYMENT.md` (crash-loop diagnosis bullet); DESIGN/TESTING re-read, consistent.
- **Container re-verified (no phase-01 regression):** `podman build -f Containerfile` succeeds; the image ships `levels/` and serves `/healthz` 200, `/api/levels` (2 levels), `/` 200 as non-root.
- **Deviation to note:** the phase overview names `tests/fixtures/levels/bad/`; the actual broken-fixture dir is `tests/fixtures/levels/_bad/` (the `_` prefix is what keeps it out of normal discovery). Overview files are read-only to me — README/LEVELS.md document the real path.
- No git index/history commands run; all changes left unstaged for the harness. ntfy notification sent.
2026-10-06 11:03:28 -04:00

12 KiB

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 → 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.
  • 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 (phase 03) must produce identical state given the same seed and input script — the replay tests in tests/sim/ are the guard once the tick exists; until then tests/sim/test_purity.py holds the A5 boundary that determinism depends on.
  • 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/
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
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 — headless determinism and replays join it with the tick in phase 03), 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.