Files
undercooked/README.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

226 lines
13 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.
# Undercooked
A cooperative, **in-browser**, Overcooked-style kitchen game: 1–4 players share one
kitchen over WebSockets, cook orders against the clock, and climb a campaign of
levels that get progressively harder. Lobbies are **anonymous** — no accounts, no
email, no password: you create a room, share the 5-character code, play.
> Working title. "Overcooked" is a trademark of Ghost Town Games/Studio MDG; this
> project is an independent homage with its own art, code, and content.
## Status
Phases **01** (runnable service scaffold) and **02** (level content, schema and
validation) are **complete**; the next pending phase is
`03_simulation_movement_and_interaction`. What runs today:
- **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
single typed, validated `Settings` object (`DEBUGPY` support included), and
`uv run undercooked` starts a FastAPI server that serves the client shell at `/`,
the browser assets from `app/static/`, and `GET /healthz` — which answers
`{"status":"ok","version":"0.1.0","rooms":0}` (`rooms` stays `0` until phase 05 owns
a supervisor) — with the security headers (CSP `default-src 'self'`) on every
response.
- **The content.** The recipe book and the first two campaign levels live in
`levels/`; `sim/level.py` parses them into typed models, `sim/validation.py` runs
PLAN §8's named rules, and `create_app` loads + validates that bundle at startup and
**refuses to boot** broken content — the named-rule report goes to stderr and
`undercooked` exits `2` instead of serving a half-working kitchen. `GET /api/levels`,
`GET /api/levels/{id}` and `GET /api/config` answer from it, and
`undercooked-levels` gives authors the same gate from the command line
(`docs/LEVELS.md` is the schema reference).
- **The rules are tests, not promises.** The no-external-asset rule is
`tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is
`tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by
`tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
phase pipeline runs after every task.
What does not exist yet: the simulation (phases 03-04), rooms and the WebSocket
protocol (05-06 — the lobby buttons answer "Not implemented until phase 05.") and the
playable Canvas renderer (07). Execute phases with the `phased-execution` skill
(`auto-phase.sh`); it always takes the lowest-numbered directory still in
`.agents/phases/todo/`.
## How to play
The intended loop, once the room flow ships: open the site, **Create lobby**, read out
the 5-character code, everyone else **Joins** with it, the host picks a level, the room
readies up, and the kitchen starts. Each cook has one movement control and **one action
button** that picks up, uses and puts down whatever is in reach — one carried item at a
time. Orders appear in the HUD with a shrinking deadline; assemble the dish and serve it
at the window before the timer runs out. Levels escalate: more recipes, fewer stations,
hostile floors. Nothing is saved between rounds and nothing identifies you: the browser
keeps only a random per-tab token and the last code you typed, both in `sessionStorage`.
What actually works today is the shell of that: the page loads, normalises a typed code to
five uppercase alphanumerics (codes themselves are Crockford base32, and only get issued
once phase 05 runs the room supervisor), and answers "Not implemented until phase 05."
Movement keys and touch controls are defined by the phase-07/08 client and documented in
`docs/CLIENT.md` once that phase writes it.
## Quickstart (development)
Requires [`uv`](https://docs.astral.sh/uv/), Python ≥ 3.12, and `podman`/`docker`
for container builds. **There is no database** — all live game state is in memory.
```bash
uv sync --extra dev
uv run undercooked # http://127.0.0.1:8000 (shell page + /healthz)
uv run undercooked --host 0.0.0.0 --port 8000 # e.g. to reach it from a phone
uv run undercooked --reload # dev reload
uv run undercooked --levels-dir levels # point at another content dir
curl -fsS http://127.0.0.1:8000/api/levels # the campaign cards the lobby will show
curl -fsS http://127.0.0.1:8000/api/config # client-safe settings + protocol version
```
`python -m undercooked` is the same command (the container uses it). A flag
always beats the environment, which beats the built-in default. There is no
`/docs` or `/openapi.json`: FastAPI renders those pages from a public CDN, which
this project forbids. The validation the server runs at startup (every level
file loads, and no rule of PLAN §8 is broken) is a command too —
`undercooked-levels`, see **Authoring content** below.
Debug the server with a real debugger attached (dev only - the listener binds
`0.0.0.0`, so never enable it on a public host):
```bash
DEBUGPY=1 uv run undercooked # opens a debugpy listener on :5678, then serves as usual
```
Authoring content:
```bash
uv run undercooked-levels list # the campaign, in campaign order
uv run undercooked-levels show 01_first_kitchen --grid # resolved level + reachability overlay
uv run undercooked-levels validate # the content gate; --json for scripts
uv run undercooked-levels new 03_riverboat # scaffold a level from the recipe book
uv run undercooked-levels axes # escalation-axis table (phase 11's curve)
```
`undercooked-levels` is a thin shell over the loader and the PLAN §8 rules
`create_app` runs at startup, so a green `validate` is a server that boots. It
finds content through the settings (`--dir` > `UNDERCOOKED_LEVELS_DIR` >
`levels`, plus `--recipes` > `UNDERCOOKED_RECIPES_PATH` > `<dir>/recipes.yaml`)
and never from the current directory, so it works from any path. Exit codes:
`0` ok, `1` a level breaks a rule, `2` nothing usable to work with (load
failure, unknown id, refused overwrite, usage error). The schema, every field,
the overlay trick and a "which rule did I just hit" table live in
[docs/LEVELS.md](docs/LEVELS.md); `tests/unit/test_content_not_hardcoded.py`
is what keeps levels and recipes out of the Python.
Run the checks:
```bash
uv run ruff check .
uv run pyright
uv run pytest # everything, coverage floor included
uv run pytest -m "not e2e" # the quick loop: no browser launches
uv run playwright install chromium # once, before the first e2e run
uv run pytest -m e2e # browser suites only
bash .agents/validate.sh # the pipeline gate: full suite + ruff + pyright
```
`pytest` with no `-m` runs the browser suites too, so chromium has to be
installed for the pipeline gate. Those suites start the app themselves on a free
loopback port (`live_server` in `tests/conftest.py`) — nothing needs to be
running first, and no test ever listens on a routable interface.
`bash .agents/validate.sh` is the gate a phase task must pass before it moves to
`.agents/phases/complete/`. It stops at the first failure, in this order:
`ruff check .`, `pyright`, `undercooked-levels validate` (unconditional: the same
loader and PLAN §8 rules `create_app` runs at startup, so a campaign that cannot
validate cannot commit), the **full** `pytest` suite with the 90 % coverage floor,
then repo-hygiene checks (nothing tracked that `.gitignore` excludes, no log drops or
key material committed). Run it from the repo root or from `.agents/`.
## Container
```bash
podman build -t undercooked -f Containerfile .
podman run --rm -p 8000:8000 undercooked
# or
podman compose up -d
```
## Configuration
All settings come from the environment, parsed once into
`undercooked.config.Settings` — the only module that reads the environment, so
nothing else in the codebase touches `os.environ`. Precedence: `undercooked` CLI
flags, then exported environment variables, then a local `.env` (found from the
current directory upwards; never commit it), then the built-in defaults. A blank value counts as
unset. Invalid values (`UNDERCOOKED_TICK_HZ=99`) raise `ConfigError` at startup
instead of half-configuring the server, and unusable content is refused the same
way — an unreadable `UNDERCOOKED_LEVELS_DIR` or a level that breaks a PLAN §8
rule prints the named-rule report and exits `2` (under `--reload` the reload
child reports and restarts instead; docs/LEVELS.md). `.env.example` and the table below list
every variable with its default; tests keep both in sync with `config.py`.
| Variable | Default | Meaning |
|---|---|---|
| `UNDERCOOKED_HOST` | `127.0.0.1` | Bind address |
| `UNDERCOOKED_PORT` | `8000` | Bind port |
| `UNDERCOOKED_TICK_HZ` | `20` | Server simulation rate (5-60) |
| `UNDERCOOKED_SNAPSHOT_HZ` | `20` | Snapshot broadcast rate to players (<= tick rate) |
| `UNDERCOOKED_MAX_PLAYERS` | `4` | Players per room (1-4) |
| `UNDERCOOKED_MAX_ROOMS` | `200` | Concurrent rooms before new rooms are refused |
| `UNDERCOOKED_LEVELS_DIR` | `levels` | Directory of level YAML files (`~` is expanded) |
| `UNDERCOOKED_RECIPES_PATH` | *(derived)* | Recipe book file; empty = `recipes.yaml` inside `UNDERCOOKED_LEVELS_DIR` (here: `levels/recipes.yaml`) |
| `UNDERCOOKED_STATIC_DIR` | *(derived)* | Directory served at `/static`; empty = `app/static/` beside the source (containers set it) |
| `UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S` | `900` | Close empty/idle lobbies |
| `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` | `45` | Drop a disconnected player's seat after this |
| `UNDERCOOKED_MAX_MESSAGE_BYTES` | `4096` | Hard cap on inbound WS frame size |
| `UNDERCOOKED_MAX_INPUTS_PER_S` | `40` | Per-connection inbound message rate limit |
| `UNDERCOOKED_SIM_LATENCY_MS` | `0` | Injected server-side downlink latency (netcode tests) |
| `UNDERCOOKED_ALLOWED_ORIGINS` | *(empty)* | Comma-separated `Origin`s accepted on the WebSocket handshake; empty = same-host only |
| `DEBUGPY` | `0` | `1` = open a debugpy listener on `DEBUGPY_PORT` at startup |
| `DEBUGPY_PORT` | `5678` | debugpy listen port |
## Layout
```
src/undercooked/ server: FastAPI app, rooms, WebSocket protocol, simulation
src/undercooked/__main__.py the `undercooked` command (CLI > env > default)
src/undercooked/config.py typed env settings (the only `os.environ` reader)
src/undercooked/debug.py DEBUGPY-gated debugger bootstrap
src/undercooked/sim/ pure simulation kernel: entities (`model`), grid geometry
(`grid`), content schema + loader (`recipe`, `level`,
`content` — the YAML contract is docs/LEVELS.md), the
level validator (`validation` — PLAN §8 named rules;
rule table in docs/LEVELS.md), and the
A5 purity guard (`purity`, test-enforced). There is no
`step()` yet: the tick arrives with phase 03
src/undercooked/tools/levels.py the `undercooked-levels` author CLI — validate, list,
show [--grid], new, axes; same loader, same rules, no
hard-coded content list (docs/LEVELS.md "Authoring workflow")
src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`, mounts the content API
src/undercooked/server/routes_content.py `/api/levels`, `/api/levels/{id}`, `/api/config`,
plus the startup gate: broken content refuses startup
src/undercooked/server/headers.py the security-header middleware
.env.example every configuration variable, with its default
levels/ data-driven content (YAML): the recipe book (A8) and the
first two campaign levels (01_first_kitchen, 02_double_shift)
app/static/ browser client: the ES-module shell today; Canvas 2D renderer
and this repo's SVG art arrive with phase 07
tests/ unit/, integration/, e2e/ (one Playwright suite per story) and
sim/ (the pure kernel: grid, entities, purity guard, validation);
fixtures/levels/ is the content dir tests see (a recipe book and no
levels; `_bad/` holds the deliberately-broken campaign the `_`
discovery rule ignores); conftest.py
docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract),
TESTING.md (how this project is verified), DEPLOYMENT.md;
PROTOCOL / SIMULATION / CLIENT land with their own phases
(the index is PLAN.md §13)
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
```
**No external CDNs.** Every byte of JS, CSS, font, and image is served by this app
from `app/static/`. `tests/unit/test_no_external_assets.py` enforces it, and the
e2e suite fails if loading the page ever issues a request off-origin.
## Contributing
Read [AGENTS.md](AGENTS.md) first: it points at `.agents/PLAN.md` (LOCKED decisions)
and the phase protocol. Code changes land by executing phase task files, not by
editing ad hoc.