Scaffold project and author 14-phase roadmap

Browser co-op kitchen game: FastAPI + native WebSockets, server-authoritative
20 Hz simulation, in-memory anonymous lobbies, data-driven YAML levels, Canvas 2D
vanilla ES-module client with locally authored SVG art and no external CDNs.

.agents/PLAN.md records the LOCKED decisions; .agents/user_stories/ holds the 12
flow contracts; .agents/phases/todo/ breaks them into 14 sequential, test-gated
phases (71 task files), each leaving the app launchable and the suite green.

LLM-Generated: true
This commit is contained in:
2026-10-05 17:05:56 -04:00
commit f84c6ebaef
105 changed files with 5752 additions and 0 deletions
@@ -0,0 +1,48 @@
# Phase 01 — Runnable service scaffold
**Story:** `US-01_create_anonymous_lobby` (partial — landing surface only)
**Context:** `.agents/PLAN.md` §4 (app/static/HTTP boundary), A1, A2, A12, A17, A18, A20
## Objective
Turn the empty repository into a launchable FastAPI service that serves the client
shell, exposes `/healthz`, enforces the no-external-asset rule, and has its quality
toolchain wired end to end. Nothing about gameplay exists yet; the app must still boot,
serve, and pass every check.
## Dependencies
- none (first phase; `pyproject.toml`, `Containerfile`, `compose.yaml`, `README.md`,
`AGENTS.md`, `.agents/PLAN.md` already exist in the repo)
## Tasks
1. `01_lock_toolchain_and_packages.md` — deps resolve, `uv sync` clean, test tree + markers, first passing test
2. `02_config_and_debugpy.md` — typed env config, `.env.example`, debugpy bootstrap
3. `03_fastapi_app_and_static_shell.md` — app factory, CLI entry point, static shell page, security headers
4. `04_no_cdn_guard_and_docs.md` — no-external-asset test, header tests, README/AGENTS verified
## Testing & Quality
- Unit: config parsing/defaults/overrides; static asset scan finds zero external URLs;
security headers present; `/healthz` returns 200 with a JSON body.
- Integration: app boots through `create_app()` and serves `/` and `/static/*`.
- Coverage: **>90%** on new/modified code (`config.py` and `server/app.py` fully covered).
- The phase is "Complete" only when the **entire** project test suite passes 100%
(every test in the project, not just this phase's).
- Gate commands, all green: `uv run ruff check .`, `uv run pyright`, `uv run pytest`.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
will stand after this phase — fixing every inconsistency found, **including
pre-existing ones not caused by this phase** (e.g. commands or variable names that
drifted from `config.py`).
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are
user-visible (setup, commands, configuration, features, conventions).
## Completion Criteria
- [ ] `uv sync --extra dev` succeeds and `uv.lock` is committed
- [ ] `uv run undercooked --host 127.0.0.1 --port 8000 &` then
`curl -fsS http://127.0.0.1:8000/healthz` → `{"status":"ok"}`; `curl -fsS http://127.0.0.1:8000/` → the shell HTML
- [ ] `DEBUGPY=1 uv run undercooked` opens port 5678 and still serves `/healthz`
- [ ] `uv run pytest -q` → 100% passing, coverage ≥ 90%
- [ ] `uv run ruff check .` and `uv run pyright` → zero errors
- [ ] no file under `app/static/` references an external URL (test-enforced)
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases (n/a — first phase)
@@ -0,0 +1,50 @@
# Task 01 — Lock toolchain and packages
**Phase:** `01_runnable_service_scaffold` · **Story:** `n/a`
## Objective
Make `uv` resolve the locked dependency set and establish the test tree, markers, and
first green test run, so every later phase starts from a working harness.
## Work
1. `pyproject.toml` — verify it matches A1/A13/A20: runtime deps (`fastapi`,
`uvicorn[standard]`, `pydantic`, `python-dotenv`, `pyyaml`, `websockets`), dev extras
(`debugpy`, `ruff`, `pyright`, `pytest`, `pytest-asyncio`, `pytest-cov`, `httpx`,
`playwright`). Add nothing else; if something is missing, add it and note it in the
report. Confirm `[project.scripts]` entries point at modules created in tasks 02–03
(`undercooked.__main__:main`, `undercooked.tools.levels:main`) — the second one is
created in phase 02, so temporarily drop it **only if** it breaks `uv sync`; otherwise
leave it and let the console script fail lazily.
2. Run `uv sync --extra dev`; commit `uv.lock` (it is tracked — verify `.gitignore` does
not exclude it).
3. Create the package skeleton: `src/undercooked/__init__.py` (`__version__ = "0.1.0"`,
empty `__all__`), `src/undercooked/server/__init__.py`, `src/undercooked/sim/__init__.py`,
`src/undercooked/tools/__init__.py`.
4. Create the test tree with `tests/__init__.py`? **No** — keep tests a non-package tree;
add `tests/conftest.py` with: a `settings` fixture factory (`make_settings(**overrides)`),
an `app`/`client` fixture pair (`fastapi.testclient.TestClient`), and a
`random_seed` fixture. Add empty `__init__.py`-free dirs `tests/unit/`,
`tests/integration/`, `tests/sim/`, `tests/e2e/` each with a `README.md` one-liner
describing what belongs there.
5. `tests/unit/test_harness.py` — asserts `undercooked.__version__`, that the four test
markers are registered (`--strict-markers` passes), and that
`pytest -m e2e --collect-only` collects zero items without error (no e2e tests yet).
6. `tests/conftest.py` — add an autouse fixture that sets `UNDERCOOKED_LEVELS_DIR` to a
test fixture dir (`tests/fixtures/levels/`, created empty with a `.gitkeep`) so no
test can accidentally read the real content before phase 02 exists.
7. Install the Playwright browser once and record the command in `README.md`
(`uv run playwright install chromium`); do not add a conftest hook that downloads
browsers during unit runs.
## Testing & Quality
- Unit: `tests/unit/test_harness.py` passes; `uv run pytest -q` green.
- Coverage: **>90%** on this task's new/modified code (trivial modules may hit 100%).
## Completion Criteria
- [ ] `uv sync --extra dev` exits 0; `uv run python -c "import fastapi, yaml, websockets"` exits 0
- [ ] `uv run pytest -q` → all collected tests pass, coverage gate met
- [ ] `uv run ruff check .` and `uv run pyright` → zero errors
- [ ] `git status --porcelain uv.lock` shows the lock file tracked (staged by the owner later)
- [ ] full test suite **100% passing** (every test in the project, not just this task's)
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,49 @@
# Task 02 — Typed config and debugpy bootstrap
**Phase:** `01_runnable_service_scaffold` · **Story:** `n/a`
## Objective
Give the project one authoritative, env-driven `Settings` object (A17) plus a
`DEBUGPY`-gated debugger hook (A18), so no later phase ever reads `os.environ` inline.
## Work
1. `src/undercooked/config.py`:
- `@dataclass(frozen=True, slots=True) class Settings` with fields matching the
README table: `host`, `port`, `tick_hz`, `snapshot_hz`, `max_players`, `max_rooms`,
`levels_dir: Path`, `lobby_idle_timeout_s`, `guest_idle_timeout_s`,
`max_message_bytes`, `max_inputs_per_s`, `sim_latency_ms`, `debugpy: bool`,
`debugpy_port`, `allowed_origins: tuple[str, ...]`.
- `load_settings(env: Mapping[str, str] | None = None) -> Settings` reading
`UNDERCOOKED_*` / `DEBUGPY*` keys, calling `dotenv.load_dotenv()` once when
`env is None`, with the README's defaults, `Path` expansion for `levels_dir`,
comma-splitting for `ALLOWED_ORIGINS`, and validation that raises
`ConfigError` (defined here) on: non-integer values, `tick_hz` outside 5–60,
`snapshot_hz` > `tick_hz`, `max_players` outside 1–4, `max_rooms` < 1, negative
timeouts, `max_message_bytes` < 256.
- `get_settings()` cached accessor (`functools.lru_cache` on a private `_load`) plus
`reset_settings_cache()` for tests. No module-level import-time env reads that
break test isolation.
2. `.env.example` — every variable, defaulted, commented (one line each). No secrets, no
real hostnames.
3. `src/undercooked/debug.py` — `maybe_start_debugpy(settings) -> bool`: returns `False`
and does nothing when `debugpy` is false; otherwise imports debugpy inside the
function, `listen(("0.0.0.0", settings.debugpy_port))`, `wait_for_client=False`, logs
one line, and never raises (a debug failure must not stop the server).
4. `tests/unit/test_config.py` — defaults, per-var overrides, `Path` handling,
comma-separated origins, every `ConfigError` branch, cache/reset behavior,
`.env` file loading via `monkeypatch` + `tmp_path`.
5. `tests/unit/test_debug.py` — `maybe_start_debugpy` is a no-op when disabled; when
enabled it calls `listen` exactly once (patch `debugpy` via `sys.modules` so the real
debugger never binds a port in CI) and swallows import/listen errors.
## Testing & Quality
- Unit: all `ConfigError` branches, default/override matrix, debugpy on/off/error paths.
- Coverage: **>90%** on `config.py` and `debug.py` (aim 100%).
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_config.py tests/unit/test_debug.py -q` passes
- [ ] `UNDERCOOKED_TICK_HZ=99 uv run python -c "from undercooked.config import load_settings; load_settings()"` fails with a clear `ConfigError` message
- [ ] `uv run python -c "from undercooked.config import load_settings; print(load_settings().tick_hz)"` prints `20` with no `.env` present
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,68 @@
# Task 03 — FastAPI app factory, CLI entry point, static shell
**Phase:** `01_runnable_service_scaffold` · **Story:** `US-01_create_anonymous_lobby` (landing surface)
## Objective
Create the process that later phases attach rooms and sockets to: `create_app()`,
`python -m undercooked`, security headers, and the client shell page served from
`app/static/`.
## Work
1. `src/undercooked/server/app.py`:
- `create_app(settings: Settings | None = None) -> FastAPI` — builds the app, stores
`app.state.settings`, installs middleware (see 3), registers `GET /healthz`
returning `{"status":"ok","version":__version__,"rooms":0}` (`rooms` is a placeholder
literal until phase 05 wires the supervisor), mounts
`app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")`, and adds
`GET /` returning `FileResponse(STATIC_DIR / "index.html")`.
- `STATIC_DIR` resolved from the package location (`Path(__file__).resolve().parents[3] / "app" / "static"`)
with an env override `UNDERCOOKED_STATIC_DIR` for containers; add it to `Settings`
(extend `.env.example` and the README table in this task).
- Startup hook: log the resolved settings (never log `.env` contents), and
`maybe_start_debugpy(settings)`.
2. `src/undercooked/__main__.py` — `main()` with argparse: `--host`, `--port`, `--reload`,
`--levels-dir`, precedence CLI > env > default; calls
`uvicorn.run("undercooked.server.app:app", ...)` (reload-capable string form) and
module-attribute `app = create_app()` guarded so import-time work stays cheap.
Exit code 0 on clean shutdown, 2 on `ConfigError` (usage/config error) with a one-line
message on stderr.
3. `src/undercooked/server/headers.py` — middleware adding:
`Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self' ws: wss:; font-src 'self'; base-uri 'none'; frame-ancestors 'none'`,
`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`,
`X-Frame-Options: DENY`, `Permissions-Policy: camera=(), microphone=(), geolocation=()`.
No `Report-To`/remote endpoints.
4. Client shell (vanilla ES modules, zero external references):
- `app/static/index.html` — `<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">`,
a `#screen-lobby` section with heading, *Create lobby* button, *Join* form
(`inputmode="text"`, `autocapitalize="characters"`, `maxlength="5"`, label,
visible focus styles), a `#status` `aria-live="polite"` region, and
`<script type="module" src="/static/js/app.js">`. Buttons wired to
`console.log`-free placeholders that render "not implemented until phase 05" into
`#status`.
- `app/static/css/base.css` — CSS custom properties for the palette, system font
stack, `prefers-reduced-motion` block, 44 px minimum hit targets, ≥ 4.5:1 text
contrast, 360 px → 4K fluid layout, `#kcanvas` sized by JS later.
- `app/static/js/app.js` — imports nothing external; small `mount()` that reads the
form, and a `getToken()` helper using `sessionStorage`
(`secrets`-style random via `crypto.getRandomValues`, base64url) storing
`uc.token`; keeps `uc.lastCode` in `sessionStorage`.
5. `tests/integration/test_app.py` — `create_app()` boots; `/healthz` 200 + JSON shape;
`/` serves HTML containing `/static/js/app.js`; `/static/css/base.css` 200 with
`text/css`; unknown path 404; `/static/../pyproject.toml` cannot escape (404/400);
every response carries the five security headers; `create_app(Settings(...))` honors
an overridden `max_players`.
## Testing & Quality
- Integration: routes, static mount, traversal attempt, header presence on 3 route types.
- Unit: `__main__._resolve()` precedence logic (CLI > env > default) extracted as a pure
function so it is testable without spawning uvicorn.
- Coverage: **>90%** on `app.py`, `headers.py`, `__main__.py`.
## Completion Criteria
- [ ] `uv run undercooked --port 8137 &` then `curl -fsSD- http://127.0.0.1:8137/healthz` → 200 with the security headers visible
- [ ] `curl -fsS http://127.0.0.1:8137/ | grep -c '/static/js/app.js'` → ≥ 1
- [ ] `curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8137/static/../pyproject.toml` → 404 or 400
- [ ] `uv run pytest -q` → all green, coverage ≥ 90%
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,61 @@
# Task 04 — No-CDN guard, gate wiring, docs verification
**Phase:** `01_runnable_service_scaffold` · **Story:** `n/a`
## Objective
Make the no-external-asset rule (A12) mechanically enforced, adapt the pipeline gate to
this project's real checks, and prove the README/AGENTS instructions are true as written.
## Work
1. `tests/unit/test_no_external_assets.py`:
- walk `app/static/**` (html, css, js, svg) and fail on `http://`, `https://`,
`//cdn.`, `@import url(`, `<link rel="stylesheet" href="//`, `srcset`, `fetch(` with
an absolute URL, `import "` with an absolute URL, `WebSocket("ws`, and
`new EventSource`.
- parse `index.html` for `<script src>`/`<link href>`/`<img src>` and assert each is
a same-origin `/static/...` path.
- fail if any `.html`/`.js` file contains `googleapis`, `gstatic`, `jsdelivr`,
`unpkg`, `cdnjs`, or `tailwindcss`.
- resolve the static root the same way `app.py` does (import `STATIC_DIR`), so the
test cannot pass by scanning the wrong directory.
2. `tests/unit/test_docs_commands.py` — extract fenced `bash` blocks from `README.md`,
and assert every command line starts with a known-safe prefix
(`uv `, `podman `, `bash .agents/validate.sh`, `curl `, `cd `, `#`), i.e. the docs do
not drift into unrunnable pseudo-commands; assert every `UNDERCOOKED_*` variable named
in README also appears in `config.py` or `.env.example` (and vice versa).
3. `.agents/validate.sh` — if the phased-execution harness has not installed it yet, copy
its template; then adapt it to this project, in this order, failing fast with clear
output: `uv run ruff check .`, `uv run pyright`, `uv run undercooked-levels validate`
(skip with a notice only if `src/undercooked/tools/levels.py` does not exist yet —
phase 02 makes it real), `uv run pytest -q` (full suite, coverage gate on), plus the
template's repo-hygiene checks (tracked files matching `.gitignore`, log-drop/key
patterns). Keep `set -euo pipefail` and make it runnable from the repo root or the
`.agents/` directory.
4. Run `bash .agents/validate.sh` and fix everything it reports.
5. Create the doc stubs the PLAN §13 documentation index promises, each with a one-paragraph
purpose and a "filled in by phase NN" marker so no later phase references a missing file:
`docs/DESIGN.md`, `docs/TESTING.md`, `docs/DEPLOYMENT.md`. `docs/TESTING.md` documents the
`tests/{unit,sim,server,integration,e2e}` layout, the marker set (`slow`, `e2e`), and the
artifact policy (`test-results/` is never committed).
6. Add a `tests/unit/test_docs_index.py` guard: every `docs/*.md` listed in PLAN §13's
documentation index exists, and no `docs/` file exists that the index does not list — so
the doc set stays exactly the promised one.
7. Docs pass for this phase: confirm README's Quickstart block, config table (add
`UNDERCOOKED_STATIC_DIR`, `UNDERCOOKED_ALLOWED_ORIGINS`), and Layout section match
the code as it now stands; confirm AGENTS.md §5's test path
`tests/unit/test_no_external_assets.py` matches the file created here.
## Testing & Quality
- Unit: the guard itself (temp fixture dirs with a planted external URL must fail —
`tmp_path` + monkeypatched root; assert the failure message names the offending file).
- Coverage: **>90%** on new test-support code; content tests are self-verifying.
- Full suite green; `bash .agents/validate.sh` exits 0.
## Completion Criteria
- [ ] `bash .agents/validate.sh` exits 0 from both the repo root and `.agents/`
- [ ] planting `https://cdn.example.com/x.js` in a temp static file makes `uv run pytest tests/unit/test_no_external_assets.py -q` fail (then removed)
- [ ] `uv run pytest -q` → 100% passing, coverage ≥ 90%
- [ ] every README command block runs as written on a clean `uv sync --extra dev` checkout
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] full test suite **100% passing** (every test in the project, not just this phase's)
- [ ] no behavior change in completed phases
@@ -0,0 +1,50 @@
# Phase 02 — Level content, schema, and validation
**Story:** `US-07_campaign_progression` (content foundation)
**Context:** `.agents/PLAN.md` §5 (entity model), §8 (content + escalation), A5, A8
## Objective
Define the pure simulation's data model and the data-driven content pipeline: YAML →
validated Pydantic level/recipe definitions → in-memory `LevelDef`, plus a CLI that
proves every shipped level loads, is reachable, and is solvable. No networking, no
movement yet — but the app can already list levels over HTTP.
## Dependencies
- `01_runnable_service_scaffold` (complete) — `create_app()`, `Settings.levels_dir`,
validate gate, test tree
## Tasks
1. `01_sim_core_data_model.md` — enums, `Vec2`, `Item`, `Station`, `Player`, `Order`, `Hazard`, `KitchenState`, `Grid`
2. `02_recipe_and_level_schema.md` — Pydantic models for `recipes.yaml` + level YAML, ASCII grid parser
3. `03_level_validation_rules.md` — reachability, solvability, target plausibility, structured `LevelError` report
4. `04_seed_recipes_and_two_levels.md` — `levels/recipes.yaml`, `levels/01_…yaml`, `levels/02_…yaml`, `/api/levels`
5. `05_levels_cli_and_manifest_test.md` — `undercooked-levels validate|list|show`, content regression tests
## Testing & Quality
- Unit: grid geometry and walkability; ASCII parser (ragged grids, unknown legend chars,
spawn off-floor); validator rules (each rule needs at least one failing fixture and one
passing fixture); loader ordering (campaign order by `order` field, ties broken by id);
duplicate id detection; recipe referencing an unknown ingredient fails.
- Integration: `GET /api/levels` returns both seeded levels with id/name/order/target/
time_limit/difficulty pips and nothing else (no leak of internal grid text).
- Coverage: **>90%** on new/modified code (`sim/level.py`, `sim/validation.py`, `sim/grid.py`
at ≥ 95% — they carry the content contract).
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are
user-visible: this phase adds `docs/LEVELS.md` (schema reference) and must keep the
README layout/commands table aligned with it.
## Completion Criteria
- [ ] `uv run undercooked-levels validate` exits 0 and prints one line per level
- [ ] `uv run undercooked-levels list` prints both levels in campaign order
- [ ] `curl -fsS http://127.0.0.1:8000/api/levels | python -m json.tool` lists both levels with numeric target/time
- [ ] `uv run undercooked-levels validate --dir tests/fixtures/levels/bad/` reports the expected named rule failures (non-zero exit)
- [ ] adding a fixture YAML file to `tests/fixtures/levels/` makes it appear in the loader output with no Python change (test-enforced)
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,73 @@
# Task 01 — Core simulation data model
**Phase:** `02_level_content_and_validation` · **Story:** `US-04_move_and_cook`, `US-07_campaign_progression`
## Objective
Write the vocabulary every later phase shares: pure dataclasses/enums for the kitchen,
and the grid type with its geometry and walkability rules.
## Work
1. `src/undercooked/sim/model.py` (pure: no imports outside stdlib + this package; add a
module docstring stating the A5 purity contract):
- `Facing` (`str, Enum`: `UP/RIGHT/DOWN/LEFT`) with `delta()` and `OPPOSITE`.
- `Vec2` frozen dataclass `x, y` with `+`, `-`, `scale`, `length`, `normalized`,
`quantized(digits=2)` (used by the wire encoder later), `KEY = (int(round(x*64)), int(round(y*64)))`
for hashing.
- `TileKind` (`FLOOR, COUNTER, WALL, VOID`), `WALKABLE: frozenset[TileKind] = {FLOOR}`.
- `StationKind` (`SPAWN_SHELF, CHOP_BOARD, STOVE, PREP, SERVE, SINK, TRASH`),
`StationState` (`IDLE, WORKING, READY, BUSY, DIRTY, BLOCKED`).
- `ItemKind` (`TOMATO, ONION, LETTUCE, DOUGH, PATTY, RICE, FISH, PLATE`) and
`ItemForm` (`RAW, CHOPPED, COOKING, COOKED, BURNT, PLATED, DIRTY`).
- `Item` dataclass: `kind`, `form`, `quality: float = 1.0`, `ttl: float = 0.0`,
`dish_id: int | None = None`; `is_trash()`, `with_form()` helpers (returns new
instance — keep value semantics).
- `Station` dataclass: `id: str`, `kind`, `x`, `y`, `contents: tuple[Item, ...]`,
`state`, `progress: float`, `duration: float`, `cooldown: float`,
`worker_id: str | None`, `dirty: bool`.
- `PlayerState` (`IDLE, MOVING, USING, STUNNED, DISCONNECTED`), `Player` dataclass:
`id`, `pos: Vec2`, `vel: Vec2`, `facing`, `carrying: Item | None`, `state`,
`stun: float`, `score: int`, `dishes_served: int`, `connected: bool`, `seat: int`.
- `Order` dataclass: `id: int`, `recipe_id: str`, `time_left: float`, `deadline: float`,
`value: int`.
- `HazardKind` (`FIRE, SPILL, MOVING_OBSTACLE, RUSH`), `Hazard` dataclass: `id`, `kind`,
`pos`, `life`, `path: tuple[Vec2, ...]`, `period_s`, `station_id`, `chance_per_s`.
- `RoundResult` (`WON, LOST, ABORTED`), `KitchenState` dataclass: `level_id`, `seed`,
`tick`, `grid`, `stations: tuple[Station, ...]`, `players: tuple[Player, ...]`,
`orders: tuple[Order, ...]`, `hazards`, `score`, `target_score`, `time_left`,
`streak`, `result: RoundResult | None`, `counters: Mapping[str, int]`,
`rng_state: int`. Provide `state_hash() -> str` (sha256 over a canonical
repr of the fields that matter — used by replay tests) and `with_*` helpers so
mutation stays centralized.
- `Event` dataclass: `kind: str`, `x`, `y`, `payload: Mapping`, plus `EventKind`
string constants (`CHOPPED`, `SERVED`, `BURNT`, `ORDER_MISSED`, `FIRE_STARTED`,
`DENIED`, `SCORE`, `STUNNED`, `WASHED`, `TRASHED`, `TICK_SLOW`).
2. `src/undercooked/sim/grid.py`:
- `Grid` frozen dataclass `width`, `height`, `tiles: tuple[TileKind, ...]`
(row-major), `station_at: Mapping[int, str]`.
- `in_bounds`, `tile_at(x, y)`, `is_walkable(x, y)`, `walkable_cells()`,
`blocking_mask()`, `neighbours(cell, four_way=True)`, `bfs_reachable(start,
extra_blocked=frozenset()) -> frozenset[int]`, `manhattan(a, b)`.
- `Grid.from_rows(rows: Sequence[str], legend: Mapping[str, TileKind]) -> Grid` —
raises `GridError` on ragged rows, empty grid, unknown legend char, or size 0/out of
range (`MAX_GRID = 64`).
3. `src/undercooked/sim/purity.py` — `assert_pure_imports()` helper that imports every
`sim` module and asserts none pulled in `fastapi`, `uvicorn`, `asyncio`, `socket`, or
`time` (allow `time.perf_counter` nowhere in sim; check module-level, keep it a
20-line guard used by a test).
4. `tests/sim/test_model.py`, `tests/sim/test_grid.py`, `tests/sim/test_purity.py` —
geometry, walkability, BFS around walls, ragged-grid errors, `state_hash` stability
across field insertion order for mappings, helpers return new instances.
## Testing & Quality
- Unit: every helper listed above; `Vec2` arithmetic incl. zero-length normalization;
`state_hash` changes when a scored field changes and ignores cosmetic ones.
- Coverage: **>90%** on `model.py`/`grid.py`/`purity.py` (target 95%+).
## Completion Criteria
- [ ] `uv run python -c "from undercooked.sim.grid import Grid"` succeeds
- [ ] `uv run pytest tests/sim -q` → all green
- [ ] `uv run pytest tests/sim/test_purity.py -q` proves no forbidden import in `sim/`
- [ ] `uv run ruff check src/undercooked/sim && uv run pyright` → zero errors
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,69 @@
# Task 02 — Recipe and level schema (YAML → typed)
**Phase:** `02_level_content_and_validation` · **Story:** `US-07_campaign_progression`
## Objective
Make content files the only source of levels and recipes: strict Pydantic models, an
ASCII-grid parser wired to a tile legend, and a loader that returns campaign-ordered
`LevelDef`s.
## Work
1. `src/undercooked/sim/recipe.py` — Pydantic models:
- `Ingredient(kind: ItemKind, form: ItemForm, count: int = 1)`
- `Step(kind: Literal["pick","chop","cook","plate","serve","wash"], station:
StationKind, seconds: float = 0.0)`
- `Recipe(id: str, name: str, base_score: int, ingredients: list[Ingredient],
steps: list[Step], requires_plating: bool = True, tags: list[str] = [])`
- `RecipeBook(recipes: dict[str, Recipe])` with `get(id)`, `ids()`,
`required_station_kinds(recipe_id) -> frozenset[StationKind]`,
`required_item_kinds(recipe_id) -> frozenset[ItemKind]`,
`min_seconds_to_complete(recipe_id) -> float` (sum of step seconds + handling
constant `HANDLING_S = 0.6`) — the solver bound used by validation.
- `load_recipe_book(path: Path) -> RecipeBook` (YAML, `extra="forbid"`,
`validate_assignment=True`).
2. `src/undercooked/sim/level.py` — Pydantic models mirroring PLAN §8:
- `TileSpec(kind: TileKind, station: StationKind | None = None, item: ItemKind | None = None,
id_prefix: str | None = None)`
- `OrdersSpec(pool: list[str], concurrent: int, spawn_interval: tuple[float, float],
deadline: float, penalty: int = 10)`
- `HazardSpec(type: HazardKind, x: int | None, y: int | None, station: str | None,
chance_per_s: float | None, respawn_s: float | None, path: list[tuple[int,int]] | None,
period_s: float | None, intensity: float = 1.0)` — model_validator: `station` XOR
`x/y`; `MOVING_OBSTACLE` requires `path` ≥ 2 points + `period_s`; `FIRE` requires
`station` or coordinates; `RUSH` requires `intensity`.
- `StationLimits(plates: int = 99, pots: int = 99)`
- `LevelDef(id: str, name: str, order: int, time_limit: float, target_score: int,
grid: list[str], tiles: dict[str, TileSpec], spawns: list[tuple[int,int]],
orders: OrdersSpec, hazards: list[HazardSpec] = [], station_limits: StationLimits = …,
difficulty: int = 1, blurb: str = "", escalation_notes: str = "")`
with `model_validator`s: rectangular grid, spawns inside bounds, ≥ 1 spawn,
`difficulty` 1–10, exactly one `SERVE` legend char per level, every legend char used.
- `build_grid(level: LevelDef) -> Grid` (legend → `TileKind`, station ids assigned
deterministically as `f"{kind.value}_{n}"` in reading order) and
- `Level` dataclass pairing `LevelDef` with its `Grid` + station list + spawn list.
3. `src/undercooked/sim/content.py` — `ContentBundle(recipe_book, levels: tuple[Level, …],
by_id: Mapping[str, Level])`, `load_content(levels_dir: Path, recipes_path: Path)
-> ContentBundle`; sorts by `(order, id)`; raises `ContentError` listing **all**
file-level errors at once (not the first only); tolerates subdirectories and
non-matching `*.yml`/`*.yaml`; ignores files starting with `_` (fixtures/overrides).
4. `tests/unit/test_recipe_schema.py`, `tests/unit/test_level_schema.py`,
`tests/unit/test_content_loader.py` — happy paths plus one failing YAML per rule
(ragged grid, unused legend char, two serve legends, unknown recipe in pool, hazard
with neither station nor coords, `min` > `max` spawn interval, non-rectangular grid,
out-of-bounds spawn, `difficulty=11`, duplicate level ids, unknown extra key).
5. `docs/LEVELS.md` — schema reference: every field, type, default, meaning, plus the
legend conventions and a complete annotated example. README Layout + Contributing get
a pointer to it.
## Testing & Quality
- Unit: every validator branch; loader aggregation ordering; `min_seconds_to_complete`
and `required_station_kinds` correctness for a 2-step and a 4-step recipe.
- Coverage: **>90%** on `recipe.py`, `level.py`, `content.py`.
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_level_schema.py tests/unit/test_content_loader.py tests/unit/test_recipe_schema.py -q` → green
- [ ] `uv run python -c "from undercooked.sim.content import load_content; print(len(load_content('levels','levels/recipes.yaml').levels))"` works once task 04 adds content (skip-note allowed before that; must work at phase end)
- [ ] `docs/LEVELS.md` documents every field present in the models (a test compares the model field sets to the doc headings)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,62 @@
# Task 03 — Level validation rules
**Phase:** `02_level_content_and_validation` · **Story:** `US-07_campaign_progression`
## Objective
Prove a level is playable before anyone plays it: reachability, recipe solvability,
target plausibility, and hazard references, reported as a structured, named-rule error
list.
## Work
1. `src/undercooked/sim/validation.py`:
- `RuleId` string constants: `GRID_SHAPE`, `SPAWN_ON_FLOOR`, `SPAWN_COUNT`,
`STATION_REACHABLE`, `SERVE_REACHABLE`, `SPAWNS_MUTUALLY_REACHABLE`,
`RECIPE_STATION_MISSING`, `RECIPE_INGREDIENT_UNAVAILABLE`, `ORDER_POOL_UNKNOWN_RECIPE`,
`TARGET_IMPLAUSIBLE`, `TIME_LIMIT_INVALID`, `HAZARD_REF_UNKNOWN`,
`HAZARD_ON_UNREACHABLE_TILE`, `STATION_LIMIT_ZERO`, `DUPLICATE_ID`,
`DIFFICULTY_ORDER`, `TRAVEL_TOO_SHORT`.
- `LevelViolation(rule: RuleId, level_id: str, message: str, detail: Mapping = {})`
and `ValidationReport(violations, warnings)` with `.ok`, `.errors_for(level_id)`,
`.render() -> str` (grouped, stable ordering, no ANSI).
- `validate_level(level: Level, book: RecipeBook, prev: Level | None = None) -> ValidationReport`
implementing PLAN §8:
- BFS from each spawn over walkable tiles → one reachable set; every station cell,
serve window, and spawn must be in it (movers ignored here, they are dynamic).
- For each recipe in the order pool: every required station kind exists and is
reachable; every `RAW` ingredient kind is produced by a reachable spawn shelf
(or a station that yields it).
- `theoretical_max = floor(time_limit / mean_complete)` where
`mean_complete = mean(book.min_seconds_to_complete(r) for r in pool) + travel_bound`
and reject when `target_score > 0.6 * theoretical_max * mean_order_value`
(so a target is never mathematically unreachable). Emit a warning when
`target_score > 0.45 * …` (tight-but-fair).
- Hazards reference existing station ids / in-bounds reachable tiles.
- `time_limit ≥ 30`, `station_limits` positive, ids unique across the bundle
(bundle-level check in `validate_bundle`).
- `DIFFICULTY_ORDER` when `prev` is given: at least two escalation axes must move
vs. the predecessor (axes computed by `escalation_axes(level)` → dict of
`recipe_complexity`, `order_pressure`, `time_budget`, `station_scarcity`,
`layout_hostility`, `hazard_density`, `travel_distance`).
- `TRAVEL_TOO_SHORT`: mean spawn→serve path length ≥ 3 tiles for `difficulty ≥ 3`.
- `validate_bundle(bundle: ContentBundle) -> ValidationReport` — per level plus
duplicate ids and campaign monotonicity for `difficulty`.
- Warnings never fail; every error must name the rule id and a human message with
coordinates.
2. `tests/sim/test_validation.py` — one test per rule id with a minimal fixture level
built by a `make_level(**overrides)` factory (unreachable station behind a wall,
missing sink, ingredient with no shelf, `target_score` 10× too high, hazard pointing at
a nonexistent station id, level 2 with fewer axes moved than level 1, …), and a test
asserting a clean level reports zero errors/warnings.
3. `tests/sim/test_validation_render.py` — `render()` is deterministic (two runs equal),
mentions rule ids, and stays ≤ 40 lines for a 10-violation level.
## Testing & Quality
- Unit: every rule id has a failing and a passing case; report ordering/rendering stable.
- Coverage: **>90%** on `validation.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_validation.py -q` → green, one test per rule id (assert the count matches `len(RuleId.ALL)`)
- [ ] planting an unreachable station in a temp level produces exactly the `STATION_REACHABLE` violation (test asserts this)
- [ ] `uv run pytest -q` → full suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,60 @@
# Task 04 — Seed recipes and the first two levels, expose `/api/levels`
**Phase:** `02_level_content_and_validation` · **Story:** `US-07_campaign_progression`
## Objective
Ship real content: an ingredient/recipe book and the first two campaign levels, loaded at
startup, validated, and readable by the client over HTTP.
## Work
1. `levels/recipes.yaml` — ingredients (`tomato, onion, lettuce, dough, patty, rice,
fish, plate`) and recipes:
- `soup` (tomato + onion → chop → pot/stove → plate) base 40
- `salad` (lettuce + tomato → chop → plate) base 30
- `fried_rice` (rice + onion → stove → plate) base 50
- `burger` (patty + dough + lettuce → pan/stove + chop → plate) base 70 (defined now,
used by later levels)
Keep `steps` consistent with the station pipelines in PLAN §5.
2. `levels/01_first_kitchen.yaml` — order 1, difficulty 1, 12×9 open kitchen, single
spawn shelf per ingredient used by `soup`, one chop board, one stove, one prep, one
serve, one sink, `time_limit: 150`, `target_score: 45`, `orders.pool: [soup]`,
`concurrent: 1`, `spawn_interval: [16, 20]`, `deadline: 60`, no hazards, 2 spawns,
`blurb` written for a first-timer.
3. `levels/02_double_shift.yaml` — order 2, difficulty 2, counters that create a mild
pinch, `pool: [soup, salad]`, `concurrent: 2`, `spawn_interval: [12, 16]`,
`deadline: 52`, `time_limit: 130`, `target_score: 80`, 2 spawns. Must move ≥ 2
escalation axes versus level 1 (order pressure + time budget + recipe complexity).
4. Startup wiring: `create_app()` loads the content bundle once
(`app.state.content`), running `validate_bundle`; on errors, the app **refuses to
start** with the rendered report on stderr (content bugs must not surface as a
half-working lobby). `Settings.levels_dir` drives the directory; `levels/recipes.yaml`
resolved beside it (overridable with `UNDERCOOKED_RECIPES_PATH`, added to `Settings`,
`.env.example`, and the README table).
5. `GET /api/levels` in `src/undercooked/server/app.py` (new
`src/undercooked/server/routes_content.py` router) returning
`[{id, name, order, difficulty, blurb, target_score, time_limit, recipes: [names]}]`
in campaign order — no grids, no internal station ids.
`GET /api/levels/{id}` returns the same plus `hazards: [{type}]` only (never layout).
`GET /api/config` returns client-safe values (`max_players`, `tick_hz`, `snapshot_hz`,
`protocol_version`) and nothing else.
6. `tests/integration/test_content_api.py` — lists exactly the shipped levels in order,
shape-validated by an assertable response model, no `grid` key anywhere in the body;
`/api/levels/01_first_kitchen` 200, unknown id 404; `/api/config` exposes no setting
name containing `secret`/`password`/`origins`.
7. `tests/unit/test_shipped_content.py` — for **every** file in `levels/`: validates
clean, has a `blurb` (≥ 20 chars), `order` unique and contiguous from 1, difficulty in
range, and the recipes in its pool exist. This is the content regression test.
## Testing & Quality
- Integration: content API shape/ordering/leaks.
- Unit: shipped content validates; startup refuses on a broken content dir
(`tmp_path` copy of a bad level + assert `ConfigError`/`ContentError` and stderr report).
- Coverage: **>90%** on `routes_content.py`.
## Completion Criteria
- [ ] `uv run undercooked --port 8139 &` → `curl -fsS http://127.0.0.1:8139/api/levels` lists both levels, no grid data
- [ ] `uv run pytest tests/unit/test_shipped_content.py tests/integration/test_content_api.py -q` → green
- [ ] removing `serve` from a copied level makes startup fail with `SERVE_*`/`STATION_REACHABLE` named in stderr
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,56 @@
# Task 05 — `undercooked-levels` CLI and manifest tests
**Phase:** `02_level_content_and_validation` · **Story:** `US-07_campaign_progression`
## Objective
Give authors a fast, scriptable feedback loop over content and lock in the "no
hard-coded content lists" rule with a test that adds a level file and expects it to show
up.
## Work
1. `src/undercooked/tools/levels.py` — argparse CLI (stdlib only; A10/A18 style, exit
codes 0/1/2):
- `validate [--dir PATH] [--recipes PATH] [--json]` → runs `validate_bundle`, prints
`report.render()` (or JSON), exit 1 on any error, 2 on a load failure.
- `list [--dir PATH]` → one line per level: `order id difficulty target time recipes`.
- `show ID [--grid]` → resolved level summary; `--grid` prints the ASCII map plus a
reachability overlay (`.` reachable, `?` unreachable) to debug a failing level.
- `new ID [--order N]` → writes a starter YAML from a built-in template into the
levels dir, then runs validation on it (author onboarding).
- `axes [--dir PATH]` → escalation-axis table for all levels (feeds the curve work in
phase 11).
Module is importable: `main(argv: Sequence[str] | None = None) -> int`.
2. Make the console script real: confirm `undercooked-levels` in
`[project.scripts]` points at `undercooked.tools.levels:main`; verify
`uv run undercooked-levels validate` works from the repo root **and** from another cwd
(path resolution via `Settings.levels_dir`, never `cwd`).
3. Wire the CLI into `.agents/validate.sh` unconditionally (remove the phase-01 skip
notice).
4. `tests/unit/test_levels_cli.py` — each subcommand's exit code and stdout, including:
`validate` green on shipped content; `validate --dir <bad fixture>` exit 1 naming the
rule; `show 01_first_kitchen --grid` overlay marks an unreachable station; `axes`
table ordering; `--json` output parses.
5. `tests/unit/test_content_not_hardcoded.py` — the rule lock:
- copy the shipped levels to a `tmp_path`, add `90_zerocool.yaml` (valid, order 99),
and assert `load_content`, `/api/levels` (via `TestClient` with
`UNDERCOOKED_LEVELS_DIR` pointed there), and `undercooked-levels list` **all**
include it, with no Python edited.
- assert no Python source under `src/undercooked/` contains a literal level id
(`grep`-style scan for `"01_first_kitchen"` etc.) — content lives in YAML only.
6. `docs/LEVELS.md` — append the "Authoring workflow" section: `new` → edit → `validate`
→ `axes`, with the overlay debugging trick and the rule-id table.
## Testing & Quality
- Unit: all subcommands, exit codes, cwd-independence, hardcoding scan.
- Coverage: **>90%** on `tools/levels.py`.
## Completion Criteria
- [ ] `uv run undercooked-levels validate` exits 0 from `/tmp` (not just the repo root)
- [ ] `uv run undercooked-levels list` prints both shipped levels in campaign order
- [ ] `uv run undercooked-levels show 01_first_kitchen --grid` prints a reachability overlay
- [ ] `uv run undercooked-levels validate --json | python -m json.tool` parses, `errors: []`
- [ ] `uv run pytest tests/unit/test_content_not_hardcoded.py -q` → green
- [ ] `bash .agents/validate.sh` runs the content gate and exits 0
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,52 @@
# Phase 03 — Simulation: fixed-step kernel, movement, interaction
**Story:** `US-04_move_and_cook` (server-side half)
**Context:** `.agents/PLAN.md` §5 (movement, interaction model), A4, A5, A21
## Objective
Stand up the deterministic simulation kernel: a pure `step(state, dt)` driven at 20 Hz
with seeded randomness, tile+body collision, reach-based targeting, and pick/carry/drop
semantics — all reproducible from a seed plus an input script, proven by golden hashes.
## Dependencies
- `02_level_content_and_validation` (complete) — `Level`, `Grid`, `RecipeBook`,
`KitchenState`, `Vec2`, `Event`
## Tasks
1. `01_fixed_step_kernel.md` — `build_state`, `step(state, dt)`, subsystem order, seeded pure RNG
2. `02_movement_and_collision.md` — velocity integration, tile AABB + player soft-push resolution, facing, stun
3. `03_interaction_targeting.md` — front-tile + forgiveness-radius target resolution, verb selection, `DENIED` reasons
4. `04_pickup_carry_drop.md` — one-item carry rules, handoff via counters, trash, deterministic contested resolution
5. `05_replay_harness_and_goldens.md` — input-script format, golden hash fixtures, perf smoke
## Testing & Quality
- Unit: integration/clamping math, collision against every tile kind and another player,
facing rules, target priority ordering, every `DeniedReason`, RNG reproducibility and
independence of two states with the same seed.
- Determinism (`tests/sim/test_determinism.py`): same seed + same script → identical
`state_hash()`; **different** seed → different hash; running 2×50 ticks equals running
1×100 ticks at the same total time within the documented tolerance, and exactly equals
it when using the same substep count (documented, asserted).
- Replay (`tests/sim/test_replay.py`): scripted 600-tick walk + pickup + drop matches a
committed golden hash; the golden regenerates only when the sim contract intentionally
changes, and the test prints the diff hint when it breaks.
- Perf smoke (`slow`): 4 players, 2000 ticks ≤ 400 ms total (≥ 20 Hz headroom).
- Coverage: **>90%** on new/modified code; `sim/movement.py` and `sim/interact.py` ≥ 95%.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks update `docs/SIMULATION.md` (new) as they land: constants table, step order,
determinism contract, denied-reason list.
## Completion Criteria
- [ ] `uv run python -c "from undercooked.sim.step import step"` succeeds
- [ ] `uv run pytest tests/sim -q` → green, including determinism + golden replay
- [ ] `uv run pytest -m slow tests/sim/test_replay.py -q` shows ticks/s ≥ 10 000 for a 4-player kitchen
- [ ] changing a sim constant without regenerating the golden fails the suite (demonstrated by the test design, not by hand)
- [ ] `docs/SIMULATION.md` documents the step order, constants, and the determinism contract
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,66 @@
# Task 01 — Fixed-step kernel and seeded RNG
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
## Objective
Create the loop that everything else plugs into: `build_state(level, players, seed)` and
`step(state, dt) -> (state, events)`, with a deterministic RNG and a fixed subsystem
order.
## Work
1. `src/undercooked/sim/rng.py` — a pure, seedable PRNG (splitmix64 or xoshiro128+; no
stdlib `random`, which is not portable across versions):
`Rng(state: int)` with `next_u64()`, `next_float() -> float` in [0,1),
`range_int(lo, hi)`, `choice(seq)`, `fork(salt: int) -> Rng`; returns a new `Rng`
(immutable state threading). Golden vector test with fixed expected values.
2. `src/undercooked/sim/state_build.py`:
- `build_state(level: Level, seats: Sequence[SeatSpec], seed: int, book: RecipeBook) -> KitchenState`
placing stations from `level`, spawning players at `level.spawns` (seat index →
spawn, wrapping for > spawns), `time_left = level.time_limit`,
`target_score`, `orders=()`, `hazards=()` (populated in phase 10), `rng_state = seed`.
- `SeatSpec(player_id: str, seat: int, display_name: str, color: str)` with a fixed
4-color palette constant.
- Spawn assignment wraps when there are more seats than spawns (`seat % len(spawns)`)
and offsets stacked players by 0.25 tile so nobody starts overlapping; raise
`StateBuildError` only when the level defines zero spawns.
3. `src/undercooked/sim/step.py`:
- `STEP_DT` constant (0.05) and `step(state, dt, inputs: Sequence[PlayerInput] = ()) -> tuple[KitchenState, tuple[Event, ...]]`
with a documented, fixed order:
1. clamp `dt` to `[0, MAX_DT = 0.1]` (never spiral), accumulate fractional ticks via
`state.accumulator` so the sim advances in exact `STEP_DT` substeps,
2. apply inputs (movement intent + action requests) — earliest-received wins,
tie-break by seat,
3. movement + collision (`movement.step_positions`),
4. station progress (`stations.step` — phase 04 makes it real; now an identity hook),
5. orders (`orders.step` — phase 04 hook),
6. hazards (`hazards.step` — phase 10 hook),
7. round timer + end conditions (`round_state.step` — phase 04 hook; timer counts down now),
8. `tick += 1`.
Hooks are no-op functions in this phase, each in its own module with a docstring
naming the phase that fills it in.
- `PlayerInput(player_id, seq, move: Vec2, action: bool, face: Facing | None,
received_at_tick: int)` frozen dataclass; unknown `player_id` → `err` event, never a
crash.
- Never mutates the input state: build a new `KitchenState` per substep (dataclasses
with `replace`/explicit rebuild; a `_draft` mutable helper is allowed internally as
long as the public function is pure).
4. `docs/SIMULATION.md` — constants table (`STEP_DT`, `MAX_DT`, `PLAYER_RADIUS`,
`MAX_SPEED`, `ACCEL`, `REACH`, `HANDLING_S`), the 8-step order, purity + determinism
contract, and how to regenerate goldens.
5. `tests/sim/test_step_kernel.py` — dt clamping, substep accumulation (17 ticks over
0.85 s at 0.05), unknown player id, purity (input state object unchanged after step),
two states from the same seed stay identical over 200 steps, `tick` monotonic.
6. `tests/sim/test_rng.py` — golden vectors, `fork` determinism, distribution sanity
(10 000 draws within tolerance), immutability.
## Testing & Quality
- Unit: as listed above; RNG is golden-vector locked.
- Coverage: **>90%** on `rng.py`, `step.py`, `state_build.py`.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_rng.py tests/sim/test_step_kernel.py -q` → green
- [ ] `uv run python -c "from undercooked.sim.step import step; from undercooked.sim.state_build import build_state"` succeeds
- [ ] the purity guard test (`tests/sim/test_purity.py` from phase 02) still passes with the new modules
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,55 @@
# Task 02 — Movement, collision, facing, stun
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
## Objective
Make cooks walk: analog velocity, axis-separated tile collision, soft player push,
facing, and stun handling — deterministic and cheap.
## Work
1. `src/undercooked/sim/movement.py`:
- Constants: `MAX_SPEED = 3.5` tiles/s, `ACCEL = 30.0`, `DECEL = 40.0`,
`RADIUS = 0.35`, `DIAGONAL_NORM = True`.
- `desired_velocity(player, intent: Vec2, dt, modifiers: MoveModifiers) -> Vec2` —
accelerate toward the intent direction (normalized so diagonals are not faster),
decelerate to zero when the intent is zero, apply `MoveModifiers(speed_scale,
accel_scale, slide)` (used by spills in phase 10).
- `resolve_moves(state, dt) -> tuple[tuple[Player, ...], tuple[Event, ...]]` —
integrate X then Y separately, clamping against blocked tiles (`WALL`, `COUNTER`,
`VOID`, out-of-bounds) using AABB-vs-circle-as-square resolution; then resolve
player↔player overlap with a symmetric soft push (mass 1, positional correction
only, no velocity injection), iterating twice for chains, falling back to reverting
the later-received input on a deadlock.
- `update_facing(player, intent, action) -> Facing` — facing follows the last nonzero
intent direction; an action press with zero intent keeps facing.
- `apply_stun(players, dt)` — decrement `stun`, zero velocity while stunned, emit
`EventKind.STUNNED` once on entry.
- Guarantees to assert in docstrings and tests: a player never ends a tick inside a
blocked tile; no tunneling at 2× `MAX_SPEED`; two players never share an overlap
after resolution; a stunned player cannot move.
2. `src/undercooked/sim/collision.py` — pure helpers: `cell_blocked(grid, x, y, extra_blocked)`,
`sweep_axis(pos, delta, radius, blocked) -> float` (max allowed delta), `body_cells(pos, radius)`,
`station_body_cells(station)` (stations occupy their tile as `COUNTER`-like blockers
unless `kind in {SERVE, SINK, TRASH}` where the cook may stand adjacent-adjacent).
3. Update `step.py` to call `movement.step_movement(state, dt)` in slot 3 (replace the
phase-03-task-01 hook) and feed `intent` from `PlayerInput.move`, clamping the intent
vector length to ≤ 1 server-side (a client cannot send speed 50).
4. `tests/sim/test_movement.py` — table-driven: walk into each tile kind, corner catch,
diagonal speed parity, soft push with 2 and 4 players, stun blocks movement, no
tunneling at `dt = MAX_DT` and doubled `MAX_SPEED`, intent longer than 1 is clamped,
out-of-bounds containment, `MoveModifiers` effect.
5. `tests/sim/test_movement_stress.py` — 4 players, 2 000 ticks of pseudo-random walk
from a fixed seed: assert no player is ever inside a blocked tile and no two players
overlap by more than 2×`RADIUS` at any tick.
## Testing & Quality
- Unit: everything listed; stress test asserts invariants rather than exact positions.
- Coverage: **>90%** on `movement.py`/`collision.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_movement.py tests/sim/test_movement_stress.py -q` → green
- [ ] a test proves a doubled `MAX_SPEED` still cannot pass through a 1-tile wall
- [ ] the phase-03 golden replay hash from task 05 (once created) is unaffected by later cosmetic refactors — regenerate only on contract change
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,55 @@
# Task 03 — Interaction targeting and verb selection
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
## Objective
Decide, purely and deterministically, *what the action button means* right now: which
entity is targeted and which verb applies, with explicit denial reasons the client can
show.
## Work
1. `src/undercooked/sim/interact.py`:
- `DeniedReason` constants: `OUT_OF_REACH`, `HANDS_FULL`, `HANDS_EMPTY`,
`STATION_BUSY`, `STATION_DIRTY`, `NOTHING_HERE`, `NOT_FACING`, `STUNNED`,
`WRONG_ITEM`, `INVALID_STATE`, `CONTESTED_LOST`.
- `Target` union: `StationTarget(station)`, `ItemTarget(item, on_station_id | None)`,
`ServeTarget(station)`, `None`.
- `candidate_cells(player) -> tuple[tuple[int,int], ...]` — the tile directly in
front, then the two diagonal-neighbouring front tiles, then the tile under the
player; ordered, deduped.
- `select_target(state, player) -> Target | None` — walk `candidate_cells` in order,
preferring (a) the item on a station the player is carrying-compatible with,
(b) station, (c) none; a target is only valid within `REACH = 1.0` tile plus a
`FORGIVE = 0.6` distance check from the player's position.
- `Verb` constants: `PICK_ITEM`, `PICK_FROM_STATION`, `START_WORK`, `HOLD_WORK`,
`PLACE_ON_STATION`, `PLATE_DISH`, `SERVE`, `WASH`, `TRASH`, `RELEASE`, `NONE`.
- `select_verb(state, player, target) -> tuple[Verb, DeniedReason | None]` — the
priority ladder from PLAN §5: carrying→place/drop; else pick, work, plate, serve,
wash; long-press trash handled in task 04. Every rejection returns a reason, never a
silent `None` with no explanation.
- `resolve_action(state, player, held_for: float) -> tuple[KitchenState, tuple[Event, ...]]`
— in this phase implements only `RELEASE`/`PICK_ITEM`/`NONE`/denials; the station
verbs are stubbed with `TODO(phase 04)` docstrings that return
`INVALID_STATE`-free no-ops so tests can pin the ladder shape now.
2. Wire the action edge/hold semantics into the kernel: `step.py` slot 2 tracks
`player.action_held_ticks` and `player.action_pressed_tick` (added to `Player`),
emitting `EventKind.DENIED` events with the reason in the payload.
3. `tests/sim/test_targeting.py` — candidate-cell ordering per facing, forgiveness radius
boundary (0.99 reach ok, 1.61 rejected), priority when item + station share a cell,
diagonal-front preference, target selection when a wall separates the cook from the
station, `STUNNED` denial, out-of-reach press emits exactly one `DENIED` per press
(not per tick).
4. `tests/sim/test_verbs.py` — table-driven `(carrying, target, expected_verb,
expected_denial)` covering every `Verb` and every `DeniedReason` at least once.
## Testing & Quality
- Unit: full verb/denial matrix; targeting ordering deterministic for identical states.
- Coverage: **>90%** on `interact.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_targeting.py tests/sim/test_verbs.py -q` → green
- [ ] every `DeniedReason` appears in at least one test (assert set coverage in the test file)
- [ ] a press that does nothing emits exactly one `DENIED` event (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,57 @@
# Task 04 — Pick up, carry, drop, hand off, trash
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
## Objective
Implement the hands: one carried item per cook, deterministic arbitration when two cooks
want the same thing, counter-based handoff between players, and an always-available trash.
## Work
1. Extend `src/undercooked/sim/interact.py` (and add `src/undercooked/sim/carry.py` if
the file grows past ~350 lines):
- `PICK_ITEM` / `PICK_FROM_STATION`: moves an `Item` into `player.carrying`, removes it
from the station/cell; emits `PICKED` (add `EventKind.PICKED`, `DROPPED`,
`HANDED_OFF`, `TRASHED_ALREADY_EMPTY` as needed in `model.py`).
- `PLACE_ON_STATION` / `RELEASE`: placing onto a station that accepts the item kind
transfers it; releasing into open floor is **not** allowed in v1 (items live on
stations and in hands) → `RELEASE` onto a `FLOOR` cell becomes a no-op with
`NOTHING_HERE`.
- Handoff: two players may both act on the same `PREP`/counter tile; placing an item
there and another player picking it up is the sanctioned pass. Emit `HANDED_OFF`
with both player ids when a placed item is picked by a different player within
`HANDOFF_WINDOW_TICKS = 20`.
- `TRASH`: long-press (≥ `TRASH_HOLD_TICKS = 8`) over a `TRASH` station destroys the
carried item, `counters["trashed"] += 1`, score nudge `-2` (never below 0 overall —
clamp at 0 in `scoring` from phase 04; store the raw delta in the event payload).
- **Contested resolution** (PLAN §5): within one substep, when two players target the
same item/station, the winner is the one with the smaller `received_at_tick`, then
the smaller seat. Winner gets the verb; losers get
`EventKind.DENIED{reason: CONTESTED_LOST}`. No item ever exists in two places.
- `active_claims` bookkeeping on the state draft: `Mapping[target_key, player_id]` for
the current substep only (never persisted into the snapshot).
2. Update `docs/SIMULATION.md` — carry rules, handoff window, trash long-press, and the
contested-resolution tie-break (this is the contract phase 09's E2E asserts).
3. `tests/sim/test_carry.py`:
- one item per hand: second pick while carrying → `HANDS_FULL`.
- pick/place round trip preserves `Item` identity fields and clears the station cell.
- handoff between two players emits `HANDED_OFF` with correct ids; a third party
picking it up later does not.
- trash long-press destroys at tick 8 and not at 7; short press does nothing.
- contested: two players same target, `received_at_tick` tie broken by seat, exactly
one winner, loser event reason `CONTESTED_LOST`, item count conserved.
- 100-tick fuzz with random simultaneous presses from a fixed seed: item count
invariant (sum of items in hands + on stations + trashed == initial) holds every tick.
4. `tests/sim/test_replay_carry.py` — a scripted two-player sequence (one pass + one
contest) reaches a committed golden hash.
## Testing & Quality
- Unit: rules above plus the conservation invariant under fuzz.
- Coverage: **>90%** on carry/interaction changes.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_carry.py tests/sim/test_replay_carry.py -q` → green
- [ ] the item-conservation invariant test runs 100 ticks and cannot be satisfied by a hack (asserts per tick, not only at the end)
- [ ] `docs/SIMULATION.md` documents the contested tie-break exactly as implemented
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,53 @@
# Task 05 — Replay harness, golden hashes, perf smoke
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
## Objective
Lock determinism into CI: a reusable input-script format, committed golden state hashes,
a regeneration path for intentional contract changes, and a headless perf smoke test.
## Work
1. `src/undercooked/sim/replay.py` (library, stdlib only):
- `InputScript` = `{seed, level_id, tick_hz, frames: [{tick, player_id, move, action, face}]}`
with `load(path)`, `dump(path)`, `validate()`.
- `run_script(level: Level, book: RecipeBook, script: InputScript, max_ticks: int | None = None)
-> ReplayResult(state, hashes: tuple[tuple[int, str], ...], events: tuple[Event, ...],
wall_seconds: float)` — pure driver: builds the state, feeds frames at their tick,
records `(tick, state_hash())` every `record_every` ticks.
- `compare(result, golden: dict[int, str]) -> list[HashMismatch]` reporting the first
divergent tick plus a field-level diff (which player/station/order changed) so a
failure is diagnosable in one read.
- CLI: `uv run python -m undercooked.sim.replay record|check <path.json>` writing
`tests/goldens/<name>.golden.json` (record is manual; CI only checks).
2. Replay fixtures + goldens (committed):
- `tests/fixtures/replays/walk_solo.json` — 600 ticks of scripted walking around the
level-1 kitchen (no actions): exercises movement + collision only.
- `tests/fixtures/replays/pick_drop_pass.json` — two players, pick/place/pass/trash.
- `tests/goldens/walk_solo.golden.json`, `tests/goldens/pick_drop_pass.golden.json`.
3. `tests/sim/test_replay.py` — both scripts match their goldens; a deliberately mutated
script diverges and `compare()` names the first bad tick and the changed field; the
hash record cadence is stable; replay of the same script twice in one process is
identical (no hidden global state); goldens are never rewritten by tests.
4. `tests/sim/test_perf_smoke.py` (mark `slow`) — 4 players, 2 000 ticks on level 1:
assert mean step < 0.2 ms and total < 400 ms on the dev machine, and print the measured
numbers in the report. Mark the test `slow` so the quick loop skips it but
`.agents/validate.sh` runs it.
5. `.agents/validate.sh` — add `uv run pytest -m slow tests/sim -q` as a final stage
(after the full-suite run) so determinism and perf are gated, not advisory.
6. `docs/SIMULATION.md` — "Replaying and re-blessing goldens" section (when a re-bless is
legitimate: a deliberate rule change; never to silence a failure).
## Testing & Quality
- Unit/integration: replay determinism, mismatch diagnostics, goldens committed and used.
- Perf: budget asserted, numbers reported.
- Coverage: **>90%** on `replay.py`.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_replay.py -q` → green against committed goldens
- [ ] `uv run python -m undercooked.sim.replay check tests/fixtures/replays/walk_solo.json` exits 0
- [ ] editing one coordinate in the fixture makes `check` exit 1 and print the first divergent tick + changed field
- [ ] `uv run pytest -m slow tests/sim -q` meets the perf budget
- [ ] `bash .agents/validate.sh` includes the slow stage and exits 0
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,57 @@
# Phase 04 — Kitchen pipeline, orders, and scoring
**Story:** `US-04_move_and_cook`, `US-05_orders_score_and_result`
**Context:** `.agents/PLAN.md` §5 (station pipelines, orders/scoring/results), A5, A21
## Objective
Make the kitchen actually cook: station state machines with timers, recipe-driven order
spawning and expiry, serve resolution with quality/streak bonuses, burning and the
dirty-dish loop, and round win/lose with stars — all deterministic and simulable headless
to a completed round.
## Dependencies
- `03_simulation_movement_and_interaction` (complete) — step kernel, targeting/verbs,
carry rules, replay harness and goldens
## Tasks
1. `01_station_state_machines.md` — `sim/stations.py`: idle/working/ready/dirty transitions, hold-to-work, station acceptance matrix
2. `02_order_spawning_and_expiry.md` — `sim/orders.py`: seeded spawns from the level pool, concurrency, deadlines, miss penalty
3. `03_serve_resolution_and_scoring.md` — `sim/scoring.py`: dish match, quality, streak, time bonus, wrong-dish rejection, contribution tracking
4. `04_spoil_burn_and_dirty_loop.md` — quality decay, burnt items, dirty plates/pots and the sink, waste counters
5. `05_round_end_results_and_stars.md` — `sim/round_state.py`: target reached, timer expiry, stars, per-player summary
6. `06_headless_round_simulations.md` — scripted full rounds to win and to loss; golden refresh; hook coverage
## Testing & Quality
- Unit: acceptance matrix per station kind (every `ItemKind` × every `StationKind`
combination has an explicit accepted/rejected expectation); progress timing at exact
tick boundaries (±1 tick); seeded order-spawn cadence within the level's
`spawn_interval` bounds; deadline expiry to the tick; scoring math (quality × streak ×
time bonus) against hand-computed values; star thresholds.
- Integration (headless sim): a scripted single-player run completes one soup and scores;
a scripted full round reaches `target_score` → `WON`; an idle round expires → `LOST`;
an all-idle round never divides by zero, never spawns unbounded orders, and stays within
the item-count invariant.
- Determinism: every new mechanic is exercised by a replay fixture; the phase-03 goldens
are re-blessed **once** at the end of task 06 with a note in the commit that the sim
contract changed intentionally (document in `docs/SIMULATION.md`).
- Coverage: **>90%** on new/modified code; `sim/stations.py`, `sim/scoring.py`,
`sim/orders.py` ≥ 95%.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks update `docs/SIMULATION.md` (acceptance matrix, scoring formulas, star rule) and
`docs/LEVELS.md` (new recipe fields) as they land.
## Completion Criteria
- [ ] `uv run python -c "from undercooked.sim.stations import step_stations"` succeeds
- [ ] a headless test drives level 1 from tick 0 to `result=WON` with a scripted player
- [ ] a headless test drives an idle round to `result=LOST` with score 0
- [ ] every `ItemKind` × `StationKind` pair is covered by the acceptance matrix test
- [ ] scoring hand-check test proves `base × quality × streak × time_bonus` for 5 cases
- [ ] `uv run pytest tests/sim -q` (incl. re-blessed goldens) → green
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases (movement/carry semantics unchanged; goldens re-blessed only for pipeline additions)
@@ -0,0 +1,54 @@
# Task 01 — Station state machines and acceptance matrix
**Phase:** `04_kitchen_pipeline_and_scoring` · **Story:** `US-04_move_and_cook`
## Objective
Give every station kind its legal state graph and timers, so the verbs from phase 03 have
something real to act on.
## Work
1. `src/undercooked/sim/stations.py`:
- `ACCEPTS: Mapping[StationKind, frozenset[ItemKind]]` and
`TRANSITIONS: Mapping[tuple[StationKind, ItemForm], tuple[ItemForm, float]]`
declared as data, not `if` ladders — the matrix is the spec:
shelf→`RAW` out (infinite while `orders` need it, capped by
`shelf_stock_per_level`), chop board `RAW→CHOPPED` 0.9 s, stove pot
`CHOPPED→COOKED` 6 s (rice `RAW→COOKED` 8 s), pan `PATTY/FISH RAW→COOKED` 5 s,
prep `COOKED|CHOPPED (+PLATE)→PLATED` 0.6 s, serve consumes `PLATED`,
sink `DIRTY→None` 1.2 s, trash consumes anything.
- `step_stations(state, dt) -> tuple[tuple[Station, ...], tuple[Event, ...]]` — advance
`progress` on `WORKING` stations, flip to `READY` at `duration`, tick `cooldown`,
release `worker_id` when the worker leaves the tile (no worker within reach for
`RELEASE_TICKS = 4`), clear `BLOCKED` when its cause clears, emit `CHOPPED`,
`COOKED`, `WASHED`, `READY` events.
- `apply_station_verb(state, player, verb, target)` implementing `START_WORK`,
`HOLD_WORK` (progress only advances while `action_held_ticks` grows and the player is
in reach and `worker_id` is theirs), `PLACE_ON_STATION`, `PLATE_DISH`, `SERVE`
(delegates scoring to phase 04 task 03's `scoring.resolve_serve`), `WASH`.
- Station occupancy: `WORKING` + `worker_id` set ⇒ second player gets
`STATION_BUSY`; `DIRTY` station requires a wash before use ⇒ `STATION_DIRTY`.
- Dirty-dish stock: `state.counters["plates_clean"]` / `["pots_clean"]` decremented on
use, incremented by the sink; `PLATE_DISH` with zero clean plates → `NOTHING_HERE`
with payload `{"why":"no_plate"}`.
2. Wire into `step.py` slot 4 (replace the hook) and make phase-03's `resolve_action`
stubs call `apply_station_verb`.
3. `tests/sim/test_stations.py` — one test per transition row; timing boundaries
(progress at `duration-1 tick` is still `WORKING`, at `duration` is `READY`); hold
semantics (release early ⇒ progress freezes, resume continues, never rolls back);
occupancy lock/unlock; dirty gating; plate/pot stock counters; the full acceptance
matrix test enumerating `StationKind × ItemKind` with an explicit expected
accepted/rejected table (no gaps allowed — the test asserts the matrix is total).
4. `tests/sim/test_station_events.py` — event kinds and payloads (station id, item, tick)
for chop/cook/wash/ready.
## Testing & Quality
- Unit: transitions, boundaries, occupancy, stock, totality of the matrix.
- Coverage: **>90%** on `stations.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_stations.py tests/sim/test_station_events.py -q` → green
- [ ] the acceptance-matrix test fails if a new `ItemKind` or `StationKind` is added without an explicit expectation
- [ ] hold-to-chop freezes on release and resumes without rollback (asserted at tick granularity)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,49 @@
# Task 02 — Order spawning and expiry
**Phase:** `04_kitchen_pipeline_and_scoring` · **Story:** `US-05_orders_score_and_result`
## Objective
Fill the ticket rail: seeded, level-driven order spawning with concurrency caps,
deadlines, and miss penalties — reproducible from a seed.
## Work
1. `src/undercooked/sim/orders.py`:
- `step_orders(state, dt, book) -> tuple[tuple[Order, ...], tuple[Event, ...]]`:
- maintains `state.next_order_at` (tick); when `tick >= next_order_at` and
`len(orders) < level.orders.concurrent`, spawn an order: recipe chosen by
`Rng(state.rng_state).choice(pool)` with state threading, `deadline` from the
level, `value = recipe.base_score`, id monotonically increasing.
- schedules the next spawn at `tick + rng.range_int(*spawn_interval)` in ticks;
never schedules two spawns for the same tick; if the rail is full, retry after
`min_retry = 1 s` without consuming the interval draw (documented and tested —
otherwise a full rail silently warps the cadence).
- decrements `time_left` on every order; at 0 → remove, `counters["missed"] += 1`,
emit `ORDER_MISSED` with the recipe and id; streak reset is applied by
`scoring.py` (single owner), not here.
- first order spawns at `FIRST_ORDER_DELAY_TICKS = 1.5 s` so the countdown-to-cook
beat feels right.
- `order_summary(state) -> tuple[Mapping, ...]` — the compact dict the snapshot encoder
uses later (`id`, `r`, `l` (fraction remaining), `pts`).
- No unbounded growth: assert `len(orders) <= concurrent` every tick (test).
2. Wire into `step.py` slot 5; the level's `orders` spec must reach the state — add the
needed fields to `KitchenState` (`orders_spec` reference or copied scalars; prefer
copying scalars so the state stays self-contained for replay).
3. `tests/sim/test_orders.py` — first-order delay; concurrency cap under idle and busy
rails; spawn interval bounds over 200 simulated seconds (all gaps within
`[min, max]` ticks, and the full-rail retry rule); deadline expiry exactly at the tick
it reaches 0; `ORDER_MISSED` payload; same seed ⇒ identical order sequence and ids;
different seed ⇒ different sequence; rail never exceeds `concurrent`.
4. `tests/sim/test_orders_fuzz.py` — 20 levels-worth of randomized specs (fixed seed):
no negative times, no duplicate ids, no unbounded order count, no exceptions.
## Testing & Quality
- Unit: cadence, caps, expiry, seeded reproducibility, fuzz safety.
- Coverage: **>90%** on `orders.py`.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_orders.py tests/sim/test_orders_fuzz.py -q` → green
- [ ] a same-seed test proves the order sequence is identical across two runs
- [ ] the full-rail retry rule has its own test (not just implied)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,58 @@
# Task 03 — Serve resolution and scoring
**Phase:** `04_kitchen_pipeline_and_scoring` · **Story:** `US-05_orders_score_and_result`
## Objective
Turn a plated dish into points: match it against the live orders, apply quality, streak,
and time bonuses, reject wrong dishes without destroying them, and track per-player
contribution.
## Work
1. `src/undercooked/sim/scoring.py`:
- `ServeOutcome` = `Matched(order_id, points, quality, streak, time_bonus) |
Rejected(reason)` with `reason in {NO_ORDERS, WRONG_RECIPE, INCOMPLETE, BURNT,
NO_TARGET}`.
- `resolve_serve(state, player, dish: Item, book) -> tuple[KitchenState, tuple[Event, ...]]`:
- the dish's `recipe_id` + ingredient composition is validated against the recipe
(`INCOMPLETE` when an ingredient is missing or the wrong one is inside);
- match against the **oldest** compatible live order (FIFO); no compatible order ⇒
`WRONG_RECIPE`/`NO_ORDERS` — the dish is **not** consumed, and the client gets
`DENIED` with the reason (US-04 criterion 7);
- `points = round(base × quality × streak_mult × time_bonus)` where
`quality = dish.quality` (1.0 → 0.0, burnt ⇒ 0.0 and rejected as `BURNT`),
`streak_mult = 1 + 0.1 × min(streak, 5)` (streak counted *after* increment),
`time_bonus = 1 + 0.5 × (order.time_left / order.deadline)`;
- `state.score += points`, `state.streak += 1`, `player.score += points`,
`player.dishes_served += 1`, `counters["served"] += 1`, order removed, dirty
plate/pots returned to the sink pool (`counters["plates_dirty"] += …`),
emit `SERVED` (`{order_id, points, quality, streak}`) and `SCORE`.
- order miss handling lives in `orders.py` but the streak reset and penalty land
here: `apply_miss(state, order)` → `score = max(0, score - penalty)`,
`streak = 0`, emit `SCORE` with a negative delta.
- `MAX_STREAK_MULT = 1.5` and `MIN_SCORE_FLOOR = 0` constants; score never negative.
- Never reads the clock or anything outside `state`.
2. Wire: `stations.apply_station_verb(SERVE)` calls `resolve_serve`; `orders` calls
`apply_miss` on expiry (inject it as a callback or import one way — `orders.py` must
not import `scoring` and vice versa; put the shared bits in `model.py` or pass the
function in from `step.py`).
3. `tests/sim/test_scoring.py` — 5 hand-computed cases in the test file with the expected
integers written out (e.g. `base 40, quality 0.8, streak 3, time_left/deadline 0.5 →
50`); rejection paths (no orders, wrong recipe, incomplete dish, burnt dish) leaving
the dish and order intact; FIFO matching when two compatible orders are live; streak
cap at 1.5×; score floor at 0; per-player contribution sums to `state.score`.
4. `tests/sim/test_scoring_property.py` — property-style (fixed seed, 500 random serves):
points always ≥ 0, `served` count equals removed orders, streak never exceeds cap,
no order served twice.
## Testing & Quality
- Unit: hand-computed math, rejections, FIFO, caps, floors, contribution sum.
- Property: invariants over 500 randomized serves.
- Coverage: **>90%** on `scoring.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_scoring.py tests/sim/test_scoring_property.py -q` → green
- [ ] the 5 hand-computed cases are written as literals in the test file (no formula re-derived in the test)
- [ ] serving a wrong dish leaves both the dish and the order unchanged (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,52 @@
# Task 04 — Spoilage, burning, and the dirty-dish loop
**Phase:** `04_kitchen_pipeline_and_scoring` · **Story:** `US-04_move_and_cook`, `US-05_orders_score_and_result`
## Objective
Add the clocks that create pressure: quality decay, burnt food, rotting raw items, and
the sink loop that keeps plates and pots finite — with no dead ends.
## Work
1. `src/undercooked/sim/freshness.py`:
- `step_freshness(state, dt) -> tuple[KitchenState, tuple[Event, ...]]`:
- `READY` stove/pan contents: after `BURN_AFTER_S = 8` become `BURNT`
(`quality = 0`), station goes `DIRTY`, emit `BURNT`;
- `CHOPPED` items on a board decay `quality` linearly over `ROT_S = 20` and become
`BURNT` at 0;
- `RAW` items on a board are pushed off (returned to nothing / despawned with
`WASTED` event) when a new raw item must be placed and the old is `BURNT`
— never silently overwrite a usable item;
- carried items decay at half rate (hands are not a pantry, but not a fridge either);
- `counters["wasted"] += 1` on any destruction.
- Dirty loop: `PLATE_DISH` and pot use set `plates_dirty++` / `pots_dirty++`;
`pots_clean + pots_dirty == level.station_limits.pots` invariant asserted in tests;
sink wash converts dirty → clean and cleans the station (`DIRTY → IDLE`).
- Deadlock guard: if `plates_clean == 0 and plates_dirty == total` the game is *not*
over — the sink is always reachable (validated in phase 02), and the validator's
`STATION_LIMIT_ZERO` rule prevents zero-plate levels. Add a runtime safety:
`plates_clean` can never go below 0 and `PLATE_DISH` denies cleanly.
2. Wire into `step.py` (runs right after `step_stations`, before orders).
3. `tests/sim/test_freshness.py` — burn boundary tick-exact; quality linear decay values at
25/50/75/100 % of `ROT_S`; half-rate carry decay; burnt item rejected by serve
(`BURNT`, task 03's path); wasted-event accounting; dirty counters round-trip
through the sink; the clean+dirty conservation invariant over a 1 000-tick scripted
sequence.
4. `tests/sim/test_no_deadlock.py` — a scripted 2 000-tick "worst case" (serve everything
possible, never wash until forced) always remains able to plate again after one wash;
no state where `plates_clean == 0` persists for more than `wash_time + 5 s` while a
player is alive and can reach the sink.
5. Update `docs/SIMULATION.md` — decay table, dirty-dish flow diagram, and the "no dead
end" guarantee.
## Testing & Quality
- Unit: decay math, burn boundaries, counters conservation, waste events.
- Integration: scripted worst-case run proves no deadlock.
- Coverage: **>90%** on `freshness.py`.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_freshness.py tests/sim/test_no_deadlock.py -q` → green
- [ ] clean+dirty dish conservation holds every tick of the scripted sequence (asserted per tick)
- [ ] burnt food cannot be served and the station becomes `DIRTY` (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,47 @@
# Task 05 — Round end, results, and stars
**Phase:** `04_kitchen_pipeline_and_scoring` · **Story:** `US-05_orders_score_and_result`
## Objective
Define exactly when a round ends, whether it was won, how many stars it earned, and what
the result screen gets to show — computed by the server, never by the client.
## Work
1. `src/undercooked/sim/round_state.py`:
- `step_round(state, dt) -> tuple[KitchenState, tuple[Event, ...]]`:
- `time_left -= dt` (clamped at 0);
- `score >= target_score` ⇒ `result = WON` immediately, emit `ROUND_END`
- `time_left == 0` ⇒ `WON` if `score >= target_score` else `LOST`, emit `ROUND_END`;
- once `result` is set the sim freezes gameplay inputs (movement/stations/orders
ignored) but keeps ticking for 1.0 s of "settle" so in-flight events land —
documented, tested;
- `abort(state, reason)` ⇒ `result = ABORTED` (used by room crash isolation,
phase 12).
- `stars(state) -> int` — `0–3`: 1 for winning, 2 with `time_left ≥ 25 %` of
`time_limit`, 3 with `time_left ≥ 50 %`; 0 when lost.
- `result_payload(state) -> Mapping` — the `round` message body: `result`, `score`,
`target`, `stars`, `time_left`, `counters` (served/missed/wasted), `longest_streak`,
`players[]` (`id`, `name`, `score`, `dishes_served`, `missed_contrib` estimate —
documented as display-only), `level_id`, `seed`, `ticks`.
- `longest_streak` tracked in `counters`/state during play (single owner: `scoring.py`
updates, `round_state` reads).
2. Wire into `step.py` slot 7 (replace the timer hook).
3. `tests/sim/test_round_state.py` — early win at target, timeout win, timeout loss,
star thresholds at exactly 25 %/50 % boundaries (both sides of the tick), result
payload keys and types (assert exact key set), `ABORTED` path, input freeze after end,
settle window emits no new `SERVED`, `time_left` never negative, monotonic `tick`.
4. `tests/sim/test_result_payload.py` — payload is JSON-serializable with no `Vec2`/enum
leakage (a recursive `assert_json_safe` helper), and stable across two identical runs.
5. Update `docs/SIMULATION.md` with the win/lose/star rules table.
## Testing & Quality
- Unit: thresholds at exact boundaries, payload contract, freeze semantics, abort.
- Coverage: **>90%** on `round_state.py`.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_round_state.py tests/sim/test_result_payload.py -q` → green
- [ ] star boundary tests cover just-below and just-at 25 % and 50 % (4 assertions minimum)
- [ ] `result_payload` passes the JSON-safety check with zero leaks
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,48 @@
# Task 06 — Headless full-round simulations and golden refresh
**Phase:** `04_kitchen_pipeline_and_scoring` · **Story:** `US-04_move_and_cook`, `US-05_orders_score_and_result`
## Objective
Prove the whole pipeline works without a browser: scripted full rounds that win, lose,
and wash dishes — and re-bless the replay goldens now that the pipeline is real.
## Work
1. `tests/sim/helpers.py` (test code only, never imported by `src/`): builders and a
friendly script compiler — `make_level_1_state(seed=…, seats=…)`,
`walk_to(state, target)`, `press_at(state, cell)`, `script(*steps) -> InputScript`
that compiles a list of high-level steps into tick-indexed frames.
2. `tests/sim/test_full_rounds.py`:
- `test_win_level_1_solo` — a scripted cook: grab tomato + onion, chop both, pot, cook,
plate, serve, repeat until `result == WON`; assert `score >= target_score`, `stars ≥ 1`,
`counters["served"] ≥ 2`, and the run finishes with ≥ 1 s of time left (the level is
winnable with slack, not on the last frame).
- `test_loss_by_timer_idle` — no inputs, `time_left` reaches 0, `result == LOST`,
`score == 0`, `counters["missed"] ≥ 1`.
- `test_two_player_win` — two scripted cooks divide the work (one chops, one cooks/serves)
and win faster than solo (assert `ticks_won < ticks_solo_won`), proving co-op is
actually rewarded by the rules.
- `test_wash_cycle_required` — with `station_limits.plates: 1`, the script must wash
before the second plate; assert the denial then success, and that the round still ends.
- `test_no_order_flooding_over_full_round` — order rail never exceeds `concurrent` for
the entire round; ids strictly increasing.
3. Re-bless goldens **once** (`uv run python -m undercooked.sim.replay record …`) and
note in the task report that the sim contract changed by design; `docs/SIMULATION.md`
gains a "Golden history" line: `phase 03 → phase 04 (pipeline added)`.
4. `tests/sim/test_perf_full_pipeline.py` (mark `slow`) — 4 players, 3 000 ticks with the
full pipeline: mean step < 0.5 ms, total < 1 500 ms; report the numbers.
5. `tests/unit/test_sim_hooks_wired.py` — assert no `TODO(phase …)` marker remains for
phases ≤ 04 in `src/undercooked/sim/` (grep-style, so a half-wired hook cannot hide).
## Testing & Quality
- Integration (headless): full win/loss/co-op/wash rounds; flooding bound; perf.
- Coverage: **>90%** on any sim code touched by this task; helper code lives in tests.
- The suite must be green including the re-blessed goldens.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_full_rounds.py -q` → green (win, loss, co-op, wash)
- [ ] `uv run pytest -m slow tests/sim -q` meets the full-pipeline perf budget
- [ ] `uv run pytest tests/unit/test_sim_hooks_wired.py -q` → no stale hooks for phases ≤ 04
- [ ] goldens regenerated and the change noted in the phase report and `docs/SIMULATION.md`
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases (movement/carry/determinism unchanged; only pipeline hooks went live)
@@ -0,0 +1,52 @@
# Phase 05 — Anonymous lobbies and room lifecycle
**Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`, `US-03_ready_up_and_start`
**Context:** `.agents/PLAN.md` §6 (room FSM, supervisor, sessions), A6, A15, A16
## Objective
Build the multiplayer shell without any sockets yet: a `RoomSupervisor` that mints and
reclaims anonymous room codes, a `Room` whose finite state machine walks
lobby → countdown → playing → round-end → closed, and an in-memory session store that
remembers unlocks and best scores. Everything is exercised through direct Python calls
plus thin HTTP endpoints, so the transport (phase 06) is a thin layer over a tested core.
## Dependencies
- `04_kitchen_pipeline_and_scoring` (complete) — `build_state`, `step`,
`result_payload`, round results
## Tasks
1. `01_room_supervisor_and_codes.md` — code minting, capacity, registry, GC task
2. `02_room_state_machine.md` — `Room` FSM with guarded transitions and tick ownership
3. `03_lobby_state_and_presence.md` — seats, ready, host election/handoff, level pick, spectators
4. `04_session_store_and_progression.md` — in-memory LRU session records, unlocks, best scores
5. `05_lifecycle_integration_suite.md` — HTTP probes + full lifecycle tests, GC/idle behaviour, crash isolation
## Testing & Quality
- Unit: code alphabet and collision retry, capacity rejection, FSM transition legality
(every illegal pair rejected), host handoff ordering, GC decisions, LRU eviction,
unlock monotonicity.
- Integration: `create → join(×3) → ready → start → tick to result → round_end → next
level → lobby`; solo start; join-during-play becomes spectator; host leaves mid-round;
all-seats-empty closes the room; `max_rooms` refusal; a room whose tick raises is aborted
and isolated.
- Coverage: **>90%** on new/modified code; `server/room.py`, `server/rooms.py`,
`server/sessions.py` ≥ 95%.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks add `docs/ROOMS.md` (FSM diagram, supervisor rules, session semantics, what is
lost on restart) and update `AGENTS.md` §6 "where things go" if a new module appears.
## Completion Criteria
- [ ] `uv run python -c "from undercooked.server.rooms import RoomSupervisor"` succeeds
- [ ] `curl -fsS -X POST http://127.0.0.1:8000/api/rooms` returns a 5-char Crockford code; `curl -fsS http://127.0.0.1:8000/api/rooms/{code}` shows the seat list
- [ ] lifecycle integration test drives a 4-seat room to `WON` and back to `LOBBY` without sockets
- [ ] an exception injected into one room's tick aborts that room only; other rooms keep ticking (test)
- [ ] idle lobby GC closes a room within 2 GC cycles (test with a fake clock)
- [ ] `docs/ROOMS.md` exists with the FSM diagram and restart-semantics note
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,64 @@
# Task 01 — Room supervisor, code minting, capacity, GC
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`
## Objective
Own the registry of live rooms: mint unguessable-but-shoutable codes, enforce capacity,
reclaim dead rooms, and do it all behind an injectable clock so tests are deterministic.
## Work
1. `src/undercooked/server/clock.py` — `Clock` protocol (`now() -> float`) with
`SystemClock` and `FakeClock(start=0.0)` (`advance(seconds)`); every time-dependent
server object receives a `clock` — **no bare `time.monotonic()` calls** in `server/`
(add a grep-style test in task 05).
2. `src/undercooked/server/codes.py`:
- `CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"` (5 chars → excludes I, L, O, U);
`CODE_LEN = 5`.
- `new_code(taken: Container[str], rng: random.Random | None = None) -> str` using
`secrets.choice`; retries up to 100 times on collision, then raises `CodeSpaceFull`.
- `normalize_code(raw: str) -> str | None` — uppercase, strip spaces/dashes, map
confusables (`I`/`l`→`1`, `O`→`0`), reject wrong length or invalid chars → `None`
(never a partial match, and the caller must not be able to distinguish "bad format"
from "no such room" — see US-02 criterion 4).
3. `src/undercooked/server/rooms.py`:
- `RoomSupervisor(settings, clock, content)` holding `dict[str, Room]`,
`dict[str, str]` token→code index, `stats()`.
- `create_room(session: SessionRecord, display_name: str) -> Room` → raises
`RoomCapacityError` when `len(rooms) >= max_rooms`; the creator takes seat 0 as host.
- `join(code, token, display_name) -> Room` → `RoomNotFound`, `RoomFull`,
`RoomClosed`; a known token re-joining its own room reclaims its seat even if the
seat was marked disconnected (rejoin path, phase 12 refines it).
- `leave(code, token)`, `get(code)`, `by_token(token)`, `all_rooms()`.
- `async def gc_pass()` — close rooms whose last seat left more than
`SEAT_GRACE_S` ago, close `LOBBY` rooms idle past `lobby_idle_timeout_s`, drop stale
token index entries; `async def gc_loop()` every 5 s, cancellable.
- `async def start()` / `async def shutdown()` — cancel all room tasks and await them.
- Registers itself in `app.state.supervisor` from `create_app()` (feature-flagged on
`Settings.levels_dir` content being loaded), and exposes
`POST /api/rooms`, `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join` in
`src/undercooked/server/routes_rooms.py` so the lifecycle is testable over HTTP
before WebSockets exist. Responses carry `room_code`, `state`, `seats`, `you` (token,
seat, host flag) — tokens are generated server-side with
`secrets.token_urlsafe(24)` and returned once.
4. `tests/unit/test_codes.py` — alphabet excludes I/L/O/U, length, 20 000 codes without
duplicates, collision retry via a stubbed `taken` set, `CodeSpaceFull`, all confusable
normalizations, rejection cases.
5. `tests/integration/test_supervisor.py` — create/join/leave matrix, `RoomFull` at
`max_players`, `RoomCapacityError` at `max_rooms` (with `max_rooms=3`), rejoin by token,
unknown code indistinguishable from malformed code at the API boundary, `stats()`
accuracy, shutdown cancels everything, GC with `FakeClock` closes exactly what it should.
6. `tests/integration/test_rooms_api.py` — the three HTTP endpoints: status codes, body
shapes, no token leakage for *other* players, `POST /api/rooms` at capacity → 503 with
`{code:"room_full"}`.
## Testing & Quality
- Unit + integration as listed; no test sleeps — all time via `FakeClock`.
- Coverage: **>90%** on `rooms.py`, `codes.py`, `clock.py`, `routes_rooms.py`.
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_codes.py tests/integration/test_supervisor.py tests/integration/test_rooms_api.py -q` → green
- [ ] `curl -fsS -X POST http://127.0.0.1:8000/api/rooms` returns a 5-char code from the Crockford alphabet
- [ ] no `time.monotonic()`/`time.time()` literal in `src/undercooked/server/` (grep-asserted test)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,58 @@
# Task 02 — Room state machine and tick ownership
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-03_ready_up_and_start`
## Objective
Implement `Room` as an explicit, guarded FSM that owns exactly one 20 Hz tick task and
turns a level plus seats into a running simulation and a result.
## Work
1. `src/undercooked/server/room.py`:
- `RoomState` enum: `LOBBY, COUNTDOWN, PLAYING, ROUND_END, CLOSED`.
- `RoomTransition` table as data (`{(from, event): to}`); `Room._fire(event)` raises
`IllegalTransition` for anything not in the table — no ad-hoc state writes.
- Events: `host_start`, `countdown_done`, `round_over`, `to_lobby`, `retry`,
`next_level`, `close`, `all_seats_empty`, `idle_expired`.
- State data: `code`, `seats: list[Seat]` (`Seat(player_id, token, display_name,
color, ready, host, connected, last_seen, seat_index)`), `level: Level`,
`state`, `state_since`, `countdown_left`, `sim_state`, `last_result`,
`round_no`, `events_since_snapshot` buffer, `snapshot_seq`.
- `async def tick_loop()` — `while` loop targeting `1/tick_hz` using
`clock.now()`-based scheduling (no `asyncio.sleep(1/hz)` drift: compute the
next deadline), calling `self.tick_once(now)`.
- `def tick_once(now: float | None = None)` — synchronous core so tests can drive it
manually: `PLAYING` → `sim.step(sim_state, dt, queued_inputs)`, drain events into the
buffer, on `result is not None` fire `round_over`; `COUNTDOWN` → decrement, fire
`countdown_done`; `LOBBY`/`ROUND_END` → idle-expiry checks.
- `queue_input(player_id, input)` — only legal in `PLAYING`, unknown player →
`err` event; input queue is per-tick and cleared after each step.
- Crash isolation: `tick_once` wrapped so any exception logs (code + round, never
player tokens), calls `sim.round_state.abort(...)`, fires `close`, and never
propagates out of the loop (one room must not kill the server).
- `start()`/`close()` manage the task; `close()` is idempotent and frees seats.
2. `docs/ROOMS.md` — the FSM diagram (ASCII), the transition table, and what each state
accepts from clients (forward reference to phase 06's whitelist).
3. `tests/integration/test_room_fsm.py` — every legal transition, every illegal pair
raises, `host_start` requires ≥ 1 ready non-host or a solo host, countdown counts 3
frames of 1 s, `round_over` stores `last_result` and payload, `retry` reuses the level
with a new seed, `next_level` switches to an explicitly supplied level id (task 04 wires
that value from the session store), `close` idempotent,
`all_seats_empty` closes after grace, queued inputs dropped outside `PLAYING`.
4. `tests/integration/test_room_tick.py` — `tick_once` drives the sim forward
(player positions change under a scripted input), tick cadence has no drift over 100
manual calls, event buffer drains exactly once, injected exception aborts the round and
leaves the room `CLOSED` without raising out of `tick_loop`.
## Testing & Quality
- Integration: full transition table, tick cadence with `FakeClock`, crash isolation.
- Unit: seat helpers, input legality, seed freshness on retry.
- Coverage: **>90%** on `room.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_room_fsm.py tests/integration/test_room_tick.py -q` → green
- [ ] the illegal-transition test covers the full `states × events` matrix except legal pairs
- [ ] a `Room` driven by `tick_once` reaches `WON` on level 1 with a scripted input
- [ ] injected sim exception closes only that room (asserted via a second live room)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,59 @@
# Task 03 — Seats, ready-up, host election, spectators
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-02_join_by_code`, `US-03_ready_up_and_start`
## Objective
Make the lobby mean something: named seats with colors, ready toggles, host authority and
handoff, level selection restricted to unlocks, and spectators for people who arrive
mid-round.
## Work
1. Extend `src/undercooked/server/room.py` (or split into `server/seats.py` if it exceeds
~400 lines):
- `Seat` behavior: `toggle_ready()`, `mark_connected()/mark_disconnected(now)`,
`is_host`, `display_name` sanitized at the boundary.
- `sanitize_name(raw: str | None, seat_index: int) -> str` — strip control chars and
`<>"'`, collapse whitespace, trim, cap at 16 chars, fall back to `Cook #{seat+1}`;
duplicate names allowed; the result is rendered as text by the client (never HTML).
- Color assignment: first free color from the 4-color palette in seat order; reconnect
keeps the same color.
- Host rules: seat 0 of the creator is host; `transfer_host()` picks the lowest
occupied, connected seat when the host leaves **in `LOBBY` or `ROUND_END`**; in
`PLAYING`/`COUNTDOWN` the round continues and the host slot is empty (no mid-round
election) — documented, tested. If the host returns, they do not automatically regain
the crown (explicit, avoids flip-flopping).
- `set_level(token, level_id)` — host only, level must exist and be unlocked for the
session; rejects with `NotHost`/`LevelLocked`/`UnknownLevel`; only in `LOBBY`.
- `start(token)` — host only; legal when `len(occupants) == 1` (solo) or
`any(seat.ready for seat in occupants if not seat.host)`; raises `NothingToStart`.
- Spectators: `join()` during `COUNTDOWN`/`PLAYING`/`ROUND_END` adds a spectator entry
(no seat, `role="spectator"`); `leave()` removes it; spectators receive state/snapshot
but their inputs are ignored (`err` `not_seated`).
- `lobby_payload()` — the `lobby` message body: `code`, `state`, `level`, `host_seat`,
`can_start`, `seats[{index, name, color, ready, host, connected}]`,
`spectators[{name, color}]`, `unlocked` (for the requesting session), `round_no`.
- Emotes: `emote(token, id)` validated against an `EMOTES` frozenset constant, throttled
to 2/s per connection, re-broadcast as an event (no chat in v1).
2. `docs/ROOMS.md` — host rules, spectator semantics, name rules, emote list.
3. `tests/integration/test_lobby.py` — ready toggles visible in `lobby_payload`,
solo-start allowed, 2-player start requires a non-host ready, non-host `start`/
`set_level` rejected, host handoff in lobby and *not* in play, spectator join during
play + ignored inputs, spectator removal on leave, color stability across reconnect,
emote whitelist + throttle, name sanitization table (control chars, 40 chars, emoji,
`"`, `<script>`, whitespace-only → fallback name).
4. `tests/unit/test_sanitize_name.py` — property-ish table incl. unicode, RTL marks, zero
width chars, and a length-1000 input; output length ≤ 16 and never contains `<`, `>`, `"`.
## Testing & Quality
- Integration: lobby behaviors above.
- Unit: sanitizer hardening.
- Coverage: **>90%** on the lobby code paths.
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_lobby.py tests/unit/test_sanitize_name.py -q` → green
- [ ] `<script>alert(1)</script>` as a display name comes back sanitized and ≤ 16 chars (asserted)
- [ ] no mid-round host election occurs (asserted explicitly, not by omission)
- [ ] a spectator's `input` is rejected with `not_seated` (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,56 @@
# Task 04 — In-memory session store and campaign progression
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-07_campaign_progression`
## Objective
Remember, per browser session and purely in memory, which levels are unlocked and the
best score per level — the smallest thing that makes a campaign possible (A6).
## Work
1. `src/undercooked/server/sessions.py`:
- `SessionRecord` dataclass: `token`, `display_name`, `unlocked_level: int`
(campaign *order*, starts at 1), `best: dict[level_id, BestRun]`, `rounds_played`,
`dishes_served`, `created_at`, `last_seen`, `endless_best: int | None`.
- `BestRun`: `score`, `stars`, `completed_at` (monotonic-relative, not wall-clock PII —
store `time.time()` rounded to seconds is fine and documented; no timezone, no IP).
- `SessionStore(max_entries=10_000)`:
- `get_or_create(token) -> SessionRecord`, `record_result(token, level, result_payload)`
→ updates `best` when the score beats the stored one, bumps `unlocked_level` to
`level.order + 1` **only on `WON`** and never decreases it, increments counters;
- `is_unlocked(token, level_id) -> bool`, `next_level_id(token, content) -> str | None`,
- LRU eviction on insert (oldest `last_seen` first), `stats()` for `/api/rooms`
diagnostics, `to_public(token)` returning only what the client may see.
- Optional bounded persistence: **none** (A6). A comment at the top of the module
states "in-memory only; a process restart clears all progression" and the README
repeats it (US-07 criterion 8).
- No personal data: the module must never store IP, user-agent, or raw headers
(enforced by test).
2. Wire it: `RoomSupervisor` owns a `SessionStore`; `POST /api/rooms` creates/uses a
session from the `X-Undercooked-Token` header (or a `token` body field) and returns the
token in the body **once**; `GET /api/session` returns `to_public(...)`; the `Room`
asks `session_store.is_unlocked(...)` in `set_level` (task 03) and
`next_level_id(...)` for `next_level` (task 02); `round_over` calls
`record_result` for every seated player.
3. `tests/integration/test_sessions.py` — unlock only on win, unlock monotonic (win L1 →
L2 unlocked; lose L2 → still unlocked), best score replaces only when higher, stars
recorded, counters accumulate across rooms, `to_public` leaks nothing beyond the public
shape, unknown token auto-creates at `unlocked_level=1`, LRU eviction evicts the stalest
and never the current session, 10 001 sessions stay bounded in size.
4. `tests/unit/test_session_privacy.py` — assert the `SessionRecord` field set and a
`repr()` scan for `ip`, `user_agent`, `authorization`, `cookie`; assert
`SessionStore` has no attribute holding raw headers.
5. `docs/ROOMS.md` — "Progression and what survives a restart".
## Testing & Quality
- Integration: unlock/best/LRU/privacy behaviors.
- Unit: privacy surface.
- Coverage: **>90%** on `sessions.py`.
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_sessions.py tests/unit/test_session_privacy.py -q` → green
- [ ] winning level 1 then losing level 2 leaves level 2 unlocked (asserted)
- [ ] a lower score never overwrites a stored best (asserted)
- [ ] `GET /api/session` with a fresh token returns `unlocked: 1` and empty `best`
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,57 @@
# Task 05 — Lifecycle integration, GC behaviour, crash isolation
**Phase:** `05_anonymous_lobbies_and_room_lifecycle` · **Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`, `US-03_ready_up_and_start`
## Objective
Prove the whole lobby/room lifecycle end to end over HTTP (still no WebSockets),
including the unglamorous parts: idle expiry, seat grace, capacity refusal, and one room
failing without taking the server down.
## Work
1. `tests/integration/test_lifecycle_end_to_end.py` — using `TestClient` + direct
`tick_once` driving (no sleeps):
- full happy path: create → 3 joins → readies → host start → countdown completes →
tick to `WON` → `ROUND_END` payload correct → `next_level` → back to `LOBBY` with the
same seats and colors;
- solo path: create → start (no other players) → win → unlock advanced;
- full-room path: 5th join refused `room_full`, code retained;
- bad-code path: `GET /api/rooms/ZZZZZ` and `GET /api/rooms/nope!` return the *same*
status and body shape (US-02 criterion 4 — no oracle for room existence beyond
"not here"), and the response is rate-limit friendly (no stack trace);
- abandon path: all players leave a `LOBBY` → after `SEAT_GRACE_S` + one `gc_pass()`
the room is `CLOSED` and its code no longer resolvable;
- idle lobby expiry with `FakeClock` advancing past `lobby_idle_timeout_s`;
- capacity: with `max_rooms=2`, the third `POST /api/rooms` → 503 `room_full`, and the
existing rooms are untouched.
2. `tests/integration/test_room_isolation.py`:
- two live rooms; inject an exception into room A's sim via a test-only
`UNDERCOOKED_TEST_HOOK` seam (a `Callable` on `Room` named `_test_step_override`,
set only in tests — never read from the network);
- assert: room A becomes `CLOSED`/aborted with an error event delivered, room B keeps
ticking correctly, the supervisor's `gc_pass()` reaps room A, and the server keeps
serving `/healthz`.
3. `tests/integration/test_no_wall_clock.py` — grep test: no `asyncio.sleep(` with a
non-zero literal and no `time.time()`/`time.monotonic()` inside
`src/undercooked/server/room*.py`, `rooms.py`, `sessions.py` (all timing through
`Clock`), so the lifecycle stays deterministically testable.
4. `docs/ROOMS.md` — add the "Lifecycle walkthrough" section mirroring the happy-path
test step by step, and the "Failure and expiry" section (grace windows, GC period,
crash policy).
5. Docs sweep: confirm README mentions the token header/`/api/session`, `SEAT_GRACE_S`,
`GUEST_IDLE_TIMEOUT_S` naming is consistent between README table, `config.py`, and
docs (rename in this task if they drifted, updating all three).
## Testing & Quality
- Integration: the full lifecycle, expiry, capacity, and isolation suites above.
- Coverage: **>90%** on new/modified code; the phase must leave `server/room.py`,
`server/rooms.py`, `server/sessions.py` each ≥ 95%.
- Full suite green; `bash .agents/validate.sh` exits 0.
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_lifecycle_end_to_end.py -q` → green (all five paths)
- [ ] `uv run pytest tests/integration/test_room_isolation.py -q` → one room's failure does not affect the other
- [ ] `uv run pytest tests/integration/test_no_wall_clock.py -q` → no direct clock calls in the lifecycle modules
- [ ] `curl` walk-through of create → join → ready → (HTTP has no start yet: document the gap in `docs/ROOMS.md` as closed by phase 06) works as written
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,55 @@
# Phase 06 — Realtime WebSocket protocol
**Story:** `US-02_join_by_code`, `US-03_ready_up_and_start`, `US-05_orders_score_and_result` (delivery), `US-09_disconnect_and_rejoin` (partial)
**Context:** `.agents/PLAN.md` §7 (protocol), §4 (data flow, snapshot), A3, A4, A14
## Objective
Put the tested room core on the wire: `/ws/{code}` with a versioned JSON protocol, strict
inbound validation (shape, size, rate, per-state legality), input ingestion with
sequence numbers, and snapshot/event broadcasting at the configured rate — so two browsers
can already play a round. This is the last phase before the game becomes visible.
## Dependencies
- `05_anonymous_lobbies_and_room_lifecycle` (complete) — supervisor, `Room`, lobby
payloads, session store
- `04_kitchen_pipeline_and_scoring` (complete) — sim state and result payloads to encode
## Tasks
1. `01_protocol_models_and_wire_contract.md` — `server/protocol.py`, message models, `docs/PROTOCOL.md`, protocol version
2. `02_ws_endpoint_and_handshake.md` — `/ws/{code}`, origin/token checks, caps, error codes, single-socket-per-token
3. `03_input_ingestion_and_validation.md` — `input` handling, seq discipline, clamping, per-state whitelist
4. `04_snapshot_and_event_encoding.md` — `server/encoding.py`, quantized snapshots, event batching, `SNAPSHOT_HZ` pacing, size budget
5. `05_heartbeat_disconnect_and_seat_grace.md` — heartbeats, seat grace on drop, ghost parking, rejoin within the window
6. `06_websocket_integration_suite.md` — two-socket full round, malformed flood, rate limit, size cap, latency injection flag
## Testing & Quality
- Unit: every protocol model's accept/reject surface; encoder quantization and size;
whitelist per room state; seq stale/rollback rules.
- Integration (real WebSockets via `TestClient`/`websockets` against a live uvicorn on an
ephemeral port): handshake, welcome shape, `ready`→`start`→`countdown`→`snap` stream,
two clients both reach the same `round` payload, unknown message types, oversized frame,
message flood, wrong protocol version close code `4400`, second socket evicts first,
abrupt close keeps the seat for the grace window and a reconnect resumes snapshots.
- Budget tests: encoded snapshot ≤ 1.5 KB for a 4-player level-1 state; downlink ≤ 60 KB/s
per player over a measured 5 s window.
- Coverage: **>90%** on new/modified code; `server/protocol.py`, `server/encoding.py` ≥ 95%.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- `docs/PROTOCOL.md` is the deliverable contract: message tables, close codes, error codes,
size/rate limits, versioning policy, and a sequence diagram of a full round. Every field
in code must appear in the doc (a test compares model field sets to the doc tables).
## Completion Criteria
- [ ] a Python `websockets` client can: create room over HTTP → connect `/ws/{code}` → `hello` → `ready` → host `start` → receive `countdown` then ≥ 20 `snap` frames → drive a scripted input to `round`
- [ ] two clients in one room receive identical `round` payloads
- [ ] protocol version mismatch closes with code `4400` and a clean client message
- [ ] oversized (> `max_message_bytes`) and flood (> `max_inputs_per_s`) cases are refused with the documented error/close codes and no memory growth
- [ ] snapshot ≤ 1.5 KB and downlink ≤ 60 KB/s measured by tests
- [ ] `docs/PROTOCOL.md` matches the implemented models (test-enforced) and includes the round sequence diagram
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,53 @@
# Task 01 — Protocol models and wire contract
**Phase:** `06_realtime_websocket_protocol` · **Story:** `US-02_join_by_code`, `US-03_ready_up_and_start`
## Objective
Define the wire contract in code and in prose before any socket exists, so the client and
server are built against one unambiguous spec.
## Work
1. `src/undercooked/server/protocol.py`:
- `PROTOCOL_VERSION = 1`; `MIN_SUPPORTED_VERSION = 1`.
- Client→server models (Pydantic, `extra="forbid"`, frozen): `HelloMsg(p, token?,
name?)`, `ReadyMsg`, `SetLevelMsg(level)`, `StartMsg`, `InputMsg(seq, move: Vec2Msg,
act, face)`, `EmoteMsg(id)`, `ToLobbyMsg`, `NextLevelMsg`, `PingMsg(t)`.
- Server→client models: `WelcomeMsg(you, room, state, level, seats)`, `LobbyMsg`,
`CountdownMsg(n)`, `SnapMsg(raw: dict)` (encoder-owned, see task 04), `RoundMsg`,
`EvMsg(events)`, `ErrMsg(code, msg, seq?)`, `PongMsg(t, now)`.
- `ClientMsgType` / `ServerMsgType` literal unions; `parse_client_message(raw: bytes)
-> ClientMessage | ProtocolError` that never raises, returns a typed error with a
stable `code`, and enforces `p` on `hello`.
- `ErrorCode` constants: `unknown_type`, `bad_json`, `bad_field`, `too_big`,
`rate_limited`, `not_seated`, `not_host`, `level_locked`, `wrong_state`,
`room_full`, `room_gone`, `protocol_mismatch`, `evicted`, `internal`.
- `CloseCode` constants: `4400` protocol mismatch, `4401` room gone, `4402` room full,
`4403` evicted, `4404` abuse (flood/oversize repeat), `1000` normal.
- `MESSAGE_WHITELIST: Mapping[RoomState, frozenset[ClientMsgType]]` — the single source
of "what is legal when" (imported by the endpoint, never duplicated).
2. `docs/PROTOCOL.md`:
- handshake + full-round sequence diagram (ASCII), C→S and S→C tables with every field,
type, and unit, `snap` frame structure with quantization rules, error/close code
tables, size/rate limits, and the versioning policy (patch-compatible fields vs.
`PROTOCOL_VERSION` bump; unknown-field rejection is intentional, so the version is
bumped on any field change).
3. `tests/unit/test_protocol_parse.py` — every message type parses; unknown type, bad JSON,
wrong types, missing fields, extra fields, `p` mismatch, `move` magnitude > 1, `seq`
negative/non-int, `act` non-bool, unknown `face` — each yields the documented `code`.
4. `tests/unit/test_protocol_doc_sync.py` — extract field names from the doc tables and
compare with `model_fields` for every message model in both directions (no undocumented
field, no doc field that does not exist).
5. `tests/unit/test_protocol_whitelist.py` — asserts the whitelist covers all room states ×
all client types (totality) and pins the expected matrix literal.
## Testing & Quality
- Unit: parse accept/reject surface, doc sync, whitelist totality.
- Coverage: **>90%** on `protocol.py` (target 95%+).
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_protocol_parse.py tests/unit/test_protocol_doc_sync.py tests/unit/test_protocol_whitelist.py -q` → green
- [ ] adding a field to a message model without documenting it fails `test_protocol_doc_sync` (verify once by hand, then revert)
- [ ] `parse_client_message(b"{")` returns a `ProtocolError(code="bad_json")` and does not raise
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,65 @@
# Task 02 — WebSocket endpoint, handshake, and connection guardrails
**Phase:** `06_realtime_websocket_protocol` · **Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`
## Objective
Open the socket: `/ws/{code}` with authentication by token, origin and size/rate
guardrails, a `hello`→`welcome` handshake, and clean close codes.
## Work
1. `src/undercooked/server/ws.py`:
- `Connection` dataclass: `ws`, `token`, `player_id`, `seat_index | None`,
`role (player|spectator)`, `last_seq`, `rate: TokenBucket`, `bytes_in`,
`deadlines`, `send_queue: asyncio.Queue`, `alive`.
- `async def websocket_endpoint(ws: WebSocket, code: str)` registered in
`create_app()`:
1. `websocket.accept()` **only after** checks: normalized code exists (else accept +
close `4401` — never close before accept in a way that looks like a network error),
`max_message_bytes` passed to `accept(max_size=…)`, origin check against
`settings.allowed_origins` (empty setting = same-host check; never `*` in
production mode),
2. wait for `hello` with a 5 s timeout (else close `4400`),
3. seat/spectate via the supervisor (`rejoin` when the token already holds a seat,
spectate when the room is `PLAYING`, refuse `4402` when the room is full and the
token is new),
4. send `welcome` then the current `lobby`, then start two tasks: `pump_out` (drains
`send_queue`, batching ≤ 4 frames per `send`) and `read_in`.
- `TokenBucket(rate=max_inputs_per_s, burst=max_inputs_per_s)`; on empty →
`ErrMsg(rate_limited)` and, after 3 consecutive violations inside 10 s, close `4404`.
- Single socket per token: a second `hello` with the same token sends `ErrMsg(evicted)`
+ close `4403` to the *old* connection and marks it dead (no ghost sends).
- Inbound loop: `websocket.receive_text()`; `bytes_in` accounting; unknown type →
`ErrMsg(unknown_type)`; after 5 violations close `4404`; per-state whitelist from
`protocol.MESSAGE_WHITELIST` → `ErrMsg(wrong_state)`.
- Outbound helper `send(model)` that never blocks the game loop (queue put with
`put_nowait`, dropping the oldest snapshot when the queue exceeds 8 — snapshots are
droppable, `lobby`/`round`/`err` are not).
- `finally`: notify the room (`on_disconnect`) exactly once, cancel tasks, release.
2. `src/undercooked/server/app.py` — register the route; add
`GET /api/health`-style room stats (`{rooms, players, sockets, tick_hz}`) for ops; keep
`/healthz` cheap and unchanged.
3. `tests/integration/test_ws_handshake.py` (live uvicorn on an ephemeral port via a
`uvicorn.Server` task fixture in `tests/integration/conftest.py`):
- happy handshake → `welcome` + `lobby`, seat index/host flag correct;
- unknown code → close `4401`; full room new token → `4402`;
- no `hello` within the timeout → `4400` (with the timeout shortened via settings);
- disallowed origin → rejected; same-host origin → allowed;
- second socket same token evicts the first with `4403` and the first stops receiving;
- oversized frame → refused; message flood → `rate_limited` then `4404`;
- abrupt `ws.close(transport=True)` → room sees one `on_disconnect`, no double release,
no exception in the server log.
4. `tests/unit/test_send_queue_policy.py` — snapshot droppability under a slow reader,
non-droppable frames preserved in order, no unbounded queue growth.
## Testing & Quality
- Integration: real sockets for every guardrail path.
- Unit: queue policy, token bucket arithmetic.
- Coverage: **>90%** on `ws.py`; every guardrail branch exercised.
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_ws_handshake.py -q` → green
- [ ] `websocat`-equivalent manual step in `docs/PROTOCOL.md` reproduces a handshake against `uv run undercooked`
- [ ] the flood test shows bounded memory (no per-connection list that grows with message count)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,48 @@
# Task 03 — Input ingestion, sequence discipline, and clamping
**Phase:** `06_realtime_websocket_protocol` · **Story:** `US-04_move_and_cook`, `US-06_coop_shared_kitchen`
## Objective
Turn client `input` messages into sim `PlayerInput`s safely: reject stale and replayed
sequences, clamp magnitudes server-side, and never let a hostile client move a cook at
impossible speed.
## Work
1. In `src/undercooked/server/ws.py` + `room.py`:
- `Connection.last_seq` handling: `seq` must be `> last_seq`; equal → silently dropped
(idempotent resend), smaller → `ErrMsg(bad_field, seq)` + `resync` event (the client
hard-snapshots); gaps of ≤ 3 are tolerated (UDP-ish loss), larger gaps accepted but
logged in room stats.
- `to_player_input(conn, msg, tick)` — clamp `move` to length ≤ 1, quantize to 3
decimals; `face` must be a known value (else derived from `move`); `act` coerced to
bool; attach `received_at_tick` (server truth) for contested arbitration.
- Room input queue is per tick, capacity `4 × len(seats)`; overflow drops the **oldest**
entry for that player and bumps `stats().input_drops` (never grows unbounded).
- Actions from a `DISCONNECTED` seat are ignored; from a spectator → `not_seated`.
- `stats()` gains `inputs_received`, `inputs_dropped`, `stale_seqs`, `clamped_moves`.
2. Anti-cheat invariants to assert (and enforce, not just log):
- a cook cannot exceed `MAX_SPEED` because the sim integrates velocity from intents —
a test that sends `move = {x: 1000, y: 1000}` every tick proves the distance per tick
stays within `MAX_SPEED × dt × 1.01`;
- teleport attempts (a client sending a position field) are impossible: the protocol has
no position field, and `extra="forbid"` rejects it (test).
3. `tests/integration/test_ws_input.py`:
- monotonic seq accepted, equal seq ignored, backwards seq → `err` + resync event,
tolerated gap, huge gap accepted with stat bump;
- clamp test above; face derivation; `act` non-bool rejected upstream;
- spectator input → `not_seated`; disconnected seat input ignored;
- queue overflow drops oldest and the sim keeps stepping.
4. `docs/PROTOCOL.md` — "Input rules": seq semantics, resync contract, clamping, why no
position is ever client-sent.
## Testing & Quality
- Integration: every seq path, clamping under a hostile client, spectator/disconnected rules.
- Coverage: **>90%** on the input path.
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_ws_input.py -q` → green
- [ ] the hostile-speed test proves the cook's per-tick distance bound holds
- [ ] sending a `pos` field is rejected by the schema (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,59 @@
# Task 04 — Snapshot and event encoding
**Phase:** `06_realtime_websocket_protocol` · **Story:** `US-05_orders_score_and_result`, `US-06_coop_shared_kitchen`
## Objective
Compress the sim into the small, interpolable frame the client consumes, and pace it:
quantized snapshots at `SNAPSHOT_HZ` for players, half that for spectators, with events
batched onto frames.
## Work
1. `src/undercooked/server/encoding.py`:
- `encode_snapshot(state, prev: SnapshotMeta | None) -> dict` producing exactly the
PLAN §4 shape: `t`, `p[]` (`i,x,y,f,h,s,pi`), `st[]` (`k,x,y,g,q,p,s`), `o[]`
(`id,r,l,pts`), `s`, `tgt`, `tl`, `hz[]`, `e[]`; positions quantized with
`Vec2.quantized(2)`, progress/quality to 2 decimals, enums as short strings.
- Optional field-level delta mode: when `prev` is supplied, emit `d` with only changed
entities plus a `full` flag every `FULL_SNAPSHOT_EVERY_TICKS = 20` (so a client can
always resync cheaply). Delta mode is enabled per connection and documented; if a
client misses a frame with no `full`, the server sends a full frame on the next tick.
- `encode_events(events) -> list[dict]` with the stable `k` names from `EventKind` and
coordinates; events are appended to the frame the tick they occurred in, capped at
`MAX_EVENTS_PER_FRAME = 24` with `stats().events_dropped` on overflow.
- `snapshot_size(frame) -> int` (UTF-8 length) — the budget hook.
- No `pickle`/`dataclasses.asdict` reflexes: an explicit field map per type (greps
clean, keeps the wire shape stable, and prevents accidentally leaking internal ids —
a test asserts station internal ids appear only as `k`+coordinates).
2. Pacing in `room.py` / `ws.py`:
- `Room` emits an encode pass every `tick_hz / snapshot_hz` ticks for players and every
`2 ×` for spectators; encoding happens once per (frame, audience) pair and is reused
across identical-audience connections (encode-once, fan-out-many).
- `UNDERCOOKED_SIM_LATENCY_MS` applies a server-side send delay (asyncio queue delay)
so netcode tests can simulate RTT without a proxy; documented in README config table
(already listed) and `docs/PROTOCOL.md`.
3. `tests/integration/test_encoding.py`:
- exact key-set equality for a level-1 frame against a literal expectation,
- quantization (1.23456 → 1.23), delta mode shrinks a typical frame by ≥ 40 % versus
full and produces identical decoded state,
- snapshot ≤ 1.5 KB with 4 players / 4 orders / 3 hazards on level 1 (and a generous
4 KB assert for a 64-tile level to catch pathological growth),
- downlink ≤ 60 KB/s per player measured over 100 ticks,
- events cap and `full` cadence.
4. `tests/unit/test_encoding_no_leak.py` — a frame contains no `token`, no
`display_name`, no `seed`, no station internal `id`, no `rng_state` (recursively).
5. `docs/PROTOCOL.md` — the `snap` section rewritten from the implementation, with a real
captured example frame and the delta rules.
## Testing & Quality
- Integration: frame shape, size, rate, delta correctness, latency flag.
- Unit: no-leak recursion, quantization helpers.
- Coverage: **>90%** on `encoding.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_encoding.py tests/unit/test_encoding_no_leak.py -q` → green
- [ ] the 4-player level-1 frame is ≤ 1.5 KB (assert) and the measured downlink ≤ 60 KB/s
- [ ] delta-mode decode equivalence test passes against full frames
- [ ] `docs/PROTOCOL.md` shows a captured frame that the test also produces (same keys)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,55 @@
# Task 05 — Heartbeats, disconnect handling, and seat grace
**Phase:** `06_realtime_websocket_protocol` · **Story:** `US-09_disconnect_and_rejoin`
## Objective
Make the socket honest about liveness and make dropping out survivable: heartbeats, a
grace window that keeps the seat, ghost presentation for the others, and clean teardown.
## Work
1. In `ws.py` + `room.py`:
- client `ping{t}` → `pong{t, now}`; server-side `last_seen` per connection;
`HEARTBEAT_DEAD_S = 12` — no inbound traffic for that long ⇒ the server closes `1000`
and treats it as a drop (also catches half-open TCP).
- `Room.on_disconnect(seat, now)` — mark `connected=False`, keep the seat, emit
`GHOST_ON` event; the sim's player switches to `DISCONNECTED` state and parks after
`PARK_AFTER_S = 3` (carried item released onto the current tile's station, or dropped
into `counters["lost_items"]` if there is none — never deleted silently).
- `guest_idle_timeout_s` (config) expiry ⇒ `SeatAbandoned`: the seat empties, others get
`GHOST_OFF`, `all_seats_empty` may close the room (phase 05 GC).
- Rejoin inside the grace window (`hello` with the same token) ⇒ `welcome` with
`rejoined: true`, seat restored, `connected=True`, `GHOST_OFF` broadcast, snapshot
stream resumes on the next tick.
- Rejoin after expiry ⇒ `err(room_gone)` for that seat and spectate/join rules apply.
- Close-code policy: normal leave `1000`, abuse `4404`, room closed `4401` — and the
client must be able to tell "you left" from "the room died".
- Test-only seam `POST /api/test/kill_socket/{code}/{token}` (registered **only** when
`UNDERCOOKED_TEST_MODE=1`) that force-closes a connection server-side, so e2e tests
can simulate a hard drop. Documented as test-only in `docs/PROTOCOL.md`.
2. `docs/PROTOCOL.md` — heartbeat interval, grace semantics, ghost events, close-code
decision table, and the rejoin handshake.
3. `tests/integration/test_ws_disconnect.py`:
- abrupt close keeps the seat for the grace window (`FakeClock` + manual `tick_once`),
`GHOST_ON` emitted once, cook parks after 3 s, carried item lands on a station;
- rejoin inside grace restores the seat and the color, resumes snapshots within one
tick, and emits `GHOST_OFF` once;
- rejoin after grace → seat given up, room may close, stale token rejected with
`room_gone`;
- heartbeat timeout closes cleanly and is indistinguishable (to the room) from a drop;
- no double-release when both `finally` and the timeout fire;
- `UNDERCOOKED_TEST_MODE=0` ⇒ the test seam route does not exist (assert 404).
4. `tests/unit/test_ghost_parking.py` — park timing, item release placement, counters.
## Testing & Quality
- Integration: every disconnect/rejoin path with a fake clock.
- Unit: ghost/parking rules.
- Coverage: **>90%** on the disconnect paths; the test seam itself is excluded only if it
cannot be covered — prefer covering it.
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_ws_disconnect.py tests/unit/test_ghost_parking.py -q` → green
- [ ] rejoin inside grace resumes the snapshot stream within 1 tick (asserted) and does not create a second seat
- [ ] the test-mode seam is absent by default (`curl` on a normal server → 404)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,51 @@
# Task 06 — WebSocket integration suite and protocol budget
**Phase:** `06_realtime_websocket_protocol` · **Story:** `US-02_join_by_code`, `US-03_ready_up_and_start`, `US-05_orders_score_and_result`
## Objective
Play a complete round over real sockets with two clients, and lock the abuse-resistance
and bandwidth budgets so later phases cannot silently regress them.
## Work
1. `tests/integration/conftest.py` — `live_server` fixture: uvicorn on an ephemeral port
in-process, `UNDERCOOKED_LEVELS_DIR` pointed at shipped levels, `tick_hz` and
`snapshot_hz` overridable, plus a `ws_client(code, token=None)` helper returning a tiny
scripted client (`send_model`, `expect(type, timeout)`, `collect(type, n)`).
2. `tests/integration/test_ws_round_two_clients.py`:
- A creates the room, B joins; both send `ready`; A sends `start`;
- both receive `countdown` 3→1 then ≥ 20 `snap` frames in increasing `t`;
- both drive inputs; both observe the same `SERVED` event and the same final `score`;
- the round ends and both receive byte-identical `round` payloads (JSON-compared);
- a third connection mid-round arrives as a spectator and receives snapshots but its
`input` is refused;
- after `round`, `to_lobby` returns both to `LOBBY` with seats preserved.
3. `tests/integration/test_ws_abuse.py`:
- 2 000 malformed messages → server stays healthy, connection closed at the violation
limit, other connections unaffected, `/healthz` still 200;
- 500 messages in a burst from one client → `rate_limited` then `4404`, no queue growth
(assert `stats().send_queue_max` stays within the documented bound);
- a 64 KB frame is refused; a 4 KB boundary frame is accepted exactly at the cap;
- flood of `start`/`set_level`/`emote` cannot bypass the whitelist or host check;
- 200 concurrent connections that only handshake (rooms not started) stay within
`stats().memory_mb` bounds and complete handshakes < 1 s p95.
4. `tests/integration/test_ws_budgets.py` — the numbers PLAN §10 promises, re-measured here
as the authoritative gate: mean tick cost ≤ 2 ms for a 4-player room (measured inside
`tick_once` over 400 ticks), snapshot ≤ 1.5 KB, downlink ≤ 60 KB/s, input→snapshot echo
≤ 120 ms on localhost. Mark the expensive ones `slow` so `validate.sh` runs them
deliberately.
5. `docs/PROTOCOL.md` — "Testing the protocol" section: how to run the abuse suite, the
test seam, and the budgets table with the measured values from this run.
## Testing & Quality
- Integration: full two-client round, abuse resistance, budgets.
- Coverage: **>90%** on new/modified code; this task should push `ws.py` to ≥ 95%.
- Full suite green; `bash .agents/validate.sh` exits 0.
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_ws_round_two_clients.py -q` → green with byte-identical `round` payloads
- [ ] `uv run pytest tests/integration/test_ws_abuse.py -q` → green, server healthy afterward
- [ ] `uv run pytest -m slow tests/integration/test_ws_budgets.py -q` → all four budgets met, numbers recorded in the report
- [ ] a manual `uv run undercooked` + two browser tabs reaches `ROUND_END` (record what you saw in the phase report)
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,59 @@
# Phase 07 — Canvas client, art, and HUD
**Story:** `US-01_create_anonymous_lobby`, `US-04_move_and_cook`, `US-05_orders_score_and_result`
**Context:** `.agents/PLAN.md` §4 (client, snapshot contract), A9, A10, A11, A12
## Objective
Build the browser half: the repo's own SVG tileset, a DPR-aware Canvas 2D renderer that
draws interpolated snapshots, the screen state machine (landing → lobby → countdown →
play → result), and the HUD (orders, score, timer). At the end of this phase a person can
create a lobby, join from a second tab, and actually play level 1 — with desktop keyboard
input only.
## Dependencies
- `06_realtime_websocket_protocol` (complete) — `/ws/{code}`, `welcome`/`lobby`/`snap`/
`countdown`/`round`/`ev`, `docs/PROTOCOL.md`
- `02_level_content_and_validation` (complete) — `/api/levels` for the level list
## Tasks
1. `01_svg_tileset_and_atlas.md` — generated in-repo art: floors, counters, stations, items in every form, cooks, order tickets
2. `02_screen_state_machine_and_lobby_ui.md` — landing, create/join wiring, lobby seats/ready/level picker, result screen
3. `03_snapshot_buffer_and_interpolation.md` — snapshot buffer, delta decode, entity diffing, 100 ms interpolation, the single render loop and composition root
4. `04_canvas_renderer_and_hud.md` — camera fit, layer draw order, sprites, HUD (orders/score/timer/streak), event FX hooks
5. `05_keyboard_input_and_first_playable_e2e.md` — desktop input, first end-to-end browser story, renderer smoke assertions
## Testing & Quality
- E2E (Playwright, one suite per story touched): create lobby and see the code; two tabs
in one lobby see each other's seat; countdown → kitchen renders (canvas pixel-variance
check, not a blank frame); a scripted keyboard cook completes one soup and the HUD score
bumps; result screen shows stars and the retry action.
- Renderer unit-level checks run in the browser: the interpolation math and the snapshot
buffer are pure ES modules exercised by a Playwright page that calls them directly
(`tests/e2e/test_us06_interp_math.py`) — no bundler, no jsdom. Suites added here:
`test_atlas_loads.py`, `test_us01_create_lobby.py`, `test_us02_join_by_code.py`,
`test_us03_ready_and_start.py`, `test_us06_interp_math.py`, `test_us06_smoothness.py`,
`test_us04_render_smoke.py`, `test_us04_move_and_cook.py`.
- Asset guards: every `src`/`href` is same-origin (extends the phase-01 no-CDN test to the
real asset set); no SVG file exceeds 40 KB; the atlas loads with zero console errors.
- Coverage: **>90%** on new/modified server code (any route/encoder tweak); client
correctness is demonstrated by the e2e assertions and pure-module tests above.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks create `docs/CLIENT.md`: module map, render loop, snapshot buffer, asset
conventions (how to add a sprite), and "no bundler" rules. README gains the
"how to play" section it has been promising.
## Completion Criteria
- [ ] two browser tabs: create in A, join in B, both ready, start, play level 1, and serve one dish end to end with keyboard only
- [ ] `uv run pytest tests/e2e -q` → green (all suites added in this phase)
- [ ] the canvas is provably not blank: a pixel-variance assertion passes during play and changes after a move
- [ ] HUD score/timer/orders update within 200 ms of the server event (e2e-asserted)
- [ ] zero console errors and zero non-same-origin network requests during a full round (e2e-asserted)
- [ ] `docs/CLIENT.md` exists and matches the module layout on disk
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,62 @@
# Task 01 — In-repo SVG tileset, sprites, and atlas loader
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-04_move_and_cook`
## Objective
Create the game's whole visual language as small, in-repo SVG files plus a loader, with no
external art, fonts, or CDNs.
## Work
1. `assets_src/` (source art) and `app/static/assets/` (served output) — hand-authored SVG
(simple, readable geometry — this is a vector-cartoon kitchen, not pixel art):
- `tiles.svg` — floor, counter, wall, void (with 4 subtle variants of floor for texture),
- `stations.svg` — spawn shelf (per ingredient tint), chop board, stove (off/working/
ready/burning), pot, pan, prep counter, serve window, sink, trash,
- `items.svg` — every `ItemKind` × `ItemForm` combination that can appear
(raw/chopped/cooked/burnt/plated/dirty), each 32×32 viewBox,
- `cooks.svg` — 4 cook bodies (palette-swappable via CSS `fill` on a `use`-able symbol),
4 facings × 2 walk frames, plus a `carrying` overlay layer and a `stun` spin frame,
- `fx.svg` — chop spark, steam, flame, splash, deny cross, score pop, sparkly serve,
- `ui.svg` — order ticket, star, crown (host), ready check, disconnect ghost, streak flame.
Keep each file ≤ 40 KB (guard test), one `<symbol id>` per drawable, ids namespaced
(`tile-floor`, `item-tomato-chopped`, `cook-2-front-1`).
2. `tools/gen_art_index.py` — a small stdlib script that parses the SVGs, writes
`app/static/assets/index.json` (`{id: {file, w, h}}`), and fails if a symbol id is
duplicated or if an expected id from `EXPECTED_IDS` (in the script, derived from the
sim enums by importing `undercooked.sim.model`) is missing. Run it as part of the task;
commit the generated `index.json`.
3. `app/static/js/render/atlas.js` — fetches `index.json`, loads each SVG into an `Image`,
exposes `atlas.ready()`, `atlas.draw(ctx, id, x, y, w, h, opts)` (with `tint`,
`alpha`, `flipX`); caches by id; surfaces a `Error` on unknown id (never a silent
nothing-drawn).
4. `app/static/assets/palette.css` — the cook colors, colorblind-safe alternate palette
(`[data-palette="cb"]`), and the semantic colors (danger/hot/ready/dirty) used by both
CSS and canvas (a JS module re-exports the same hex values; a test asserts the two
files agree).
5. `tests/unit/test_assets.py` — every served asset exists, ≤ 40 KB, parses as XML with a
`<symbol>` per id in `index.json`, no external `xlink:href`/`http` reference; the
sim-enum coverage check (every `ItemKind`×reachable form has a symbol); palette hex
agreement between `palette.css` and `js/render/palette.js`.
6. `tests/e2e/test_atlas_loads.py` — loads `/`, requests the atlas, asserts
`atlas.ready()` resolves, zero console errors, and `atlas.draw` on a probe canvas
produces non-blank pixels.
7. Fix the phase-01 guard for XML namespaces: `tests/unit/test_no_external_assets.py`
currently rejects any `http` occurrence, but every SVG legitimately contains
`xmlns="http://www.w3.org/2000/svg"`. Teach the guard an explicit allowlist
(`http://www.w3.org/2000/svg`, `…/xlink`, `…/xml`, `…/1999/xhtml` — namespace
declarations only, never `href`/`src`/`url()` targets) and add a test that a planted
`xlink:href="https://evil.example/x.svg"` still fails while the namespace declaration
passes.
## Testing & Quality
- Unit + e2e as listed; art completeness is enforced against the sim enums, not by eye.
- Coverage: **>90%** on `tools/gen_art_index.py`.
## Completion Criteria
- [ ] `uv run python tools/gen_art_index.py` writes `index.json` and exits 0
- [ ] removing a required symbol makes the generator (and `tests/unit/test_assets.py`) fail with the missing id named
- [ ] every asset is ≤ 40 KB and same-origin
- [ ] `uv run pytest tests/e2e/test_atlas_loads.py -q` → green
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,60 @@
# Task 02 — Screen state machine, lobby UI, and result screen
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`, `US-03_ready_up_and_start`, `US-05_orders_score_and_result`
## Objective
Replace the phase-01 shell placeholders with the real DOM screens and a single client-side
state machine that owns them, driven by server messages.
## Work
1. `app/static/js/app.js` — `createApp()` with an explicit screen enum
(`LANDING, CONNECTING, LOBBY, COUNTDOWN, PLAYING, ROUND_END, ERROR`) and
`show(screen, data)`; unknown transitions log once and go to `ERROR`; every screen is a
module exporting `{mount(root, data), unmount()}` — no global DOM spaghetti.
2. Screens (real HTML, keyboard operable, `aria`-labelled, no innerHTML with server data):
- `screens/landing.js` — *Create lobby*, join form with code input
(`inputmode="text"`, `autocapitalize="characters"`, `spellcheck="false"`,
`maxlength="5"`), inline validation, last-code prefill from `sessionStorage`, and the
offline/"server full" messages from `err` codes.
- `screens/lobby.js` — room code with copy button (clipboard API + fallback), seat list
(`index, name, color chip, ready, crown, disconnected`), *Ready* toggle, host-only
*Level* picker fed by `/api/levels` (difficulty pips, target, time, blurb), *Start*
button enabled from `lobby.can_start`, disabled-with-reason otherwise, session panel
(`/api/session`: unlocks + best scores), spectator notice.
- `screens/countdown.js` — big numerals 3/2/1, reduced-motion-safe.
- `screens/play.js` — canvas host element (`#kcanvas`), HUD root, *Leave* control,
reconnecting overlay (populated in phase 08), `emote` button row (whitelist from
`docs/PROTOCOL.md`).
- `screens/round_end.js` — result, stars (1–3, animated unless reduced motion),
score/target, best, per-player list, counters (served/missed/wasted, longest streak),
`Next level` (only when unlocked) / `Retry` / `Back to lobby`, `aria-live="polite"`
announcement of the outcome.
- `screens/error.js` — friendly copy per `ErrorCode` (`room_full`, `room_gone`,
`protocol_mismatch`, `rate_limited`, `evicted`, …) with the right retry affordance and
the code shown for support.
3. `app/static/js/net/connection.js` (thin here, hardened in phase 08) — `connect(code,
token)`, message dispatch to a single `onMessage` reducer, `send(model)`, and a
`protocol_mismatch` path that stops reconnecting and shows `error`.
4. `app/static/css/screens.css` — layout for the screens, 360 px → 4K, 44 px targets,
focus rings, `[data-palette]` aware, `prefers-reduced-motion` respected.
5. `tests/e2e/test_us01_create_lobby.py`, `test_us02_join_by_code.py`,
`test_us03_ready_and_start.py` — the story suites: create/join/ready/start across two
contexts, full-room and bad-code copy, non-host controls disabled, host crown moves when
the host leaves, countdown then kitchen on both, result screen content after a forced
short round (test-only level with `time_limit: 4`), and `aria-live` announcement of the
result.
6. `docs/CLIENT.md` — screen state machine diagram and the message→screen mapping table.
## Testing & Quality
- E2E: the four story suites above; assert visible text, not pixels, for DOM screens.
- Coverage: **>90%** on any server code touched; client behavior covered by e2e.
- Full suite green including phases 01–06 suites (no regressions).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us01_create_lobby.py tests/e2e/test_us02_join_by_code.py tests/e2e/test_us03_ready_and_start.py -q` → green
- [ ] two contexts: ready state visible on both, non-host start control disabled, countdown identical on both
- [ ] the result screen shows stars, score/target, best, and per-player rows after a forced short round
- [ ] every interactive element is reachable and operable with the keyboard alone (asserted in the e2e suite)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,67 @@
# Task 03 — Snapshot buffer and interpolation
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-06_coop_shared_kitchen`, `US-05_orders_score_and_result`
## Objective
Turn the incoming `snap` stream into smooth motion: a jitter-tolerant buffer, entity
diffing, and render-time interpolation at a fixed delay.
## Work
1. `app/static/js/net/snapshots.js` (pure module, no DOM):
- `createSnapshotBuffer({interpDelayMs = 100, maxAgeMs = 1000})` with
`push(frame)`, `sample(now)` → `{a, b, alpha}` (the two bracketing frames plus the
lerp factor), `latest()`, `needsFullResync()`, `stats()`.
- Rules: frames keyed by `t` (server tick), inserted in order, older-than-current
dropped, `t` gaps recorded (`gaps`), a gap longer than `MAX_GAP_TICKS = 6` triggers
`needsFullResync()` (the client asks for a full snapshot; phase 06's cadence guarantees
one arrives), and if no frame arrives for `maxAgeMs` the buffer freezes rather than
extrapolating forever (never animate a ghost cook into a wall).
- `lerpState(a, b, alpha)` → a render-ready interpolated state: positions lerped along
the shorter path, facing snapped at the alpha ≥ 0.5 boundary, `progress` lerped,
entities added/removed between frames handled (appear at `b`, vanish at `b`), station
and order lists diffed by id.
- `applyDelta(frame, prev)` — mirrors the server's delta encoding (task 04 of phase 06)
into a full frame client-side; correctness is asserted against full frames.
2. `app/static/js/net/decode.js` — the client-side counterpart of `server/encoding.py`:
delta application, enums mapped through `app/static/js/shared/enums.js`, and
`tests/unit/test_client_enum_parity.py` asserting every Python value of `TileKind`,
`StationKind`, `ItemKind`, `ItemForm`, `Facing`, `EventKind`, `ErrorCode` appears in the
matching JS module and vice versa (same drift-guard pattern as the movement constants).
3. `app/static/js/render/loop.js` + `app/static/js/main.js` — one `requestAnimationFrame`
loop (`sample → render → hud.update`, self-measured `frameMs` p50/p95 on `window.__uc`,
paused when `document.hidden` or the screen is not `PLAYING`, `?freeze=1` hook for
pixel-diff tests) and the composition root that parses URL params (`room`, `seed`,
`controls`, `freeze`, `motion`), builds the buffer/hud/connection, and routes messages
(snapshots → buffer, everything else → the screen machine).
4. `app/static/js/net/events.js` — event queue with a per-kind `ttlMs`, dedup window, and
`drift(now)` pruning, so a burst of chop events cannot leak listeners or replay after a
resync (`tick` reset detection).
5. `tests/e2e/test_us06_interp_math.py` — Playwright page that imports the pure module and
asserts, via `page.evaluate`: two frames 1 tick apart lerp to the midpoint at
`alpha 0.5`; out-of-order frames are dropped and counted; the gap rule triggers
`needsFullResync()`; a 30-frame synthetic stream yields monotonic sampled positions with
max step ≤ `MAX_SPEED × (1/snapshot_hz) × 1.35`; frozen buffer holds position;
`applyDelta` output equals the full frame the server would have sent (fixture pair).
6. `tests/e2e/test_us06_smoothness.py` — against the live server, two clients: the observed
remote cook's per-frame pixel delta stays within the bound above, no visual stutter
(frame-to-frame position difference never exceeds the bound) with `sim_latency_ms: 60`
injected, and both converge within 1 s of the round ending.
7. `docs/CLIENT.md` — buffer rules, `interpDelayMs` rationale, resync contract, the render
loop's single-owner rule, and the "never extrapolate forever" policy.
## Testing & Quality
- E2E-driven unit tests for pure math (deterministic fixtures) + live smoothness bounds.
- Coverage: **>90%** on any server code touched; the pure client module's paths are each
exercised by at least one `page.evaluate` assertion.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us06_interp_math.py -q` → green
- [ ] `uv run pytest tests/e2e/test_us06_smoothness.py -q` → green with 60 ms injected latency
- [ ] delta-applied frames equal the full-frame fixture (asserted field by field)
- [ ] a synthetic gap > 6 ticks triggers a resync request (asserted, and debounced to one per 500 ms)
- [ ] `uv run pytest tests/unit/test_client_enum_parity.py -q` → green (removing a JS enum value breaks it: verify once, then revert)
- [ ] the render loop pauses when the tab is hidden and resumes on show (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,60 @@
# Task 04 — Canvas renderer, camera, and HUD
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-04_move_and_cook`, `US-05_orders_score_and_result`
## Objective
Draw the kitchen and everything on it, and put the game's readouts on screen — the phase
that makes the game legible.
## Work
1. `app/static/js/render/camera.js` — fit-to-viewport with a min/max zoom in tiles-per-pixel,
integer-ish scaling to avoid shimmer, letterboxing, DPR-aware (`devicePixelRatio`,
capped at 2), resize/visibility handling, `worldToScreen`/`screenToWorld`.
2. `app/static/js/render/canvas.js`:
- explicit layer order: floor → floor decals (spills) → station bases → carried-under
items → players (y-sorted) → carried overlays → station progress rings → hazards →
particles → selection/target highlight,
- `drawFrame(interpolated, now)`; sprite selection maps `TileKind`/`StationKind`+state/
`ItemKind`+form/`Facing`+anim-frame to atlas ids; unknown id throws (surfaced to the
console once, then suppressed to avoid spam),
- animation clocks derived from `now`, pausable when `document.hidden`
(`visibilitychange`), fixed-step sprite animation so 120 Hz displays do not animate
faster,
- `renderStats()` — `drawCalls`, `frameMs` moving average, exposed on `window.__uc` for
the perf assertions;
- target highlight: the tile the server would target (client-side approximation of
`interact.candidate_cells` from the local facing/position) drawn faintly, so reaching
for a station is predictable.
3. `app/static/js/render/fx.js` — event→effect mapping (`CHOPPED` spark, `COOKED`/`READY`
steam, `SERVED` pop + score float, `BURNT` smoke, `DENIED` cross + nudge, `STUNNED`
spin, `ORDER_MISSED` flash, `SCORE` float), a small particle pool (no allocation per
event), and `[data-motion="reduced"]` fallbacks (static markers, no shake).
4. `app/static/js/ui/hud.js` — order rail (ticket per order: recipe icon chain, time bar,
points; the oldest highlighted), score + target bar, countdown timer (turns amber < 20 s,
red < 10 s, with a non-color cue), streak indicator, dirty-dish counter, connection
indicator, spectator banner, all DOM (not canvas) so it stays crisp and readable by
assistive tech; updates coalesced to one write per animation frame.
5. `tests/e2e/test_us04_render_smoke.py`:
- canvas not blank during play (variance over sampled pixels) and changes after a move,
- `renderStats().drawCalls` within a sane band (no per-frame explosion: < 4 000),
- frame time ≤ 16 ms p95 over a 10 s scripted round on the CI profile,
- HUD elements exist and update (score text changes after a serve; timer decreases),
- `document.hidden` simulation pauses the loop (frame count stops advancing),
- reduced-motion mode: no shake transform, hazard markers still visible.
6. `docs/CLIENT.md` — layer order table, atlas id conventions, how to add a new drawable
(SVG symbol → `index.json` → mapping entry), HUD DOM structure.
## Testing & Quality
- E2E: pixel-variance and perf budgets, HUD update assertions, reduced-motion check.
- Coverage: **>90%** on server-side changes; renderer covered by the above plus task 03's
math tests.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us04_render_smoke.py -q` → green
- [ ] frame time ≤ 16 ms p95 and `drawCalls` within band are both asserted, not printed
- [ ] every station state and item form is reachable by the mapping (a test walks the mapping keys against the sim enums)
- [ ] HUD changes are visible within 200 ms of the server event (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,57 @@
# Task 05 — Keyboard input and the first playable end-to-end story
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-04_move_and_cook`
## Objective
Close the loop: desktop keyboard control wired to the protocol, and a browser test that
plays a real soup from shelf to serve window — the first time the whole stack is proven by
a single test.
## Work
1. `app/static/js/input/keyboard.js`:
- WASD + arrow keys → an 8-way intent vector; opposite keys cancel; the vector is sent
only when it changes, plus a keep-alive empty input at ≥ 5 Hz,
- `Space`/`Enter` = action (press + hold tracked with `action_held_ticks` semantics from
the protocol), `Escape` = leave/confirm-cancel, `1`–`4` = emotes,
- `seq` counter per connection, monotonic, reset only on a new connection,
- ignores key events when focus is in an input field, when `document.hidden`, or when a
modifier is held (browser shortcuts survive: no `preventDefault` on
Ctrl/Cmd/Meta combos),
- `blur`/`visibilitychange`/pagehide → send a zero-intent input and release the action
(no cook running away when you alt-tab),
- key map exposed via `getKeyMap()` and rendered in the help panel (also used by
`README.md`'s controls section — keep them identical; a test asserts the two agree).
2. `app/static/ui/help.js` + a `?`-key help overlay listing the controls (no external
docs needed mid-game).
3. `tests/e2e/test_us04_move_and_cook.py` — the flagship suite, against the live server on
level 1 with a fixed seed (test-only `?seed=` query param honored when
`UNDERCOOKED_TEST_MODE=1`, documented):
- `test_moves_and_faces` — key presses produce the expected position deltas,
- `test_stops_at_walls` — walking into a wall does not pass through and does not jitter,
- `test_serves_one_soup` — the full chain (grab tomato, grab onion, chop both, pot,
cook, plate, serve) with the HUD score bump and the order ticket disappearing,
- `test_denied_out_of_reach` — an early press shows the deny feedback and the item stays,
- `test_two_players_same_target` — two contexts press simultaneously; exactly one gets
the item, the other sees `denied`, and the server's item count is conserved,
- `test_alt_tab_parks_the_cook` — hidden tab sends zero intent.
4. `tests/e2e/README.md` — how to run the browser suites (start the server, install
chromium), what `?seed=` and the test seam do, and why the flagship suite is the
release gate.
5. README "How to play" section: controls, objectives, tips (chop before pot, wash or run
out of plates), and a pointer to the in-game help overlay.
## Testing & Quality
- E2E: the flagship suite above (this is the suite every later phase must keep green).
- Coverage: **>90%** on any server-side change (e.g. the test-only seed seam); keyboard
logic verified through the browser.
- Full suite green, including all prior phases' suites.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us04_move_and_cook.py -q` → green, including the full soup chain
- [ ] a human can create a lobby, start, cook, and serve with the keyboard (record what you saw in the phase report)
- [ ] two contexts on the same target produce exactly one winner and one `denied` (asserted)
- [ ] alt-tab sends zero intent (asserted via the server's observed position freeze)
- [ ] README control list matches `getKeyMap()` exactly (test-enforced)
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,53 @@
# Phase 08 — Netcode hardening, prediction, and touch input
**Story:** `US-06_coop_shared_kitchen`, `US-09_disconnect_and_rejoin`, `US-11_play_on_phone`
**Context:** `.agents/PLAN.md` §4 (client interpolation), §10 (latency budgets), A4, A9
## Objective
Make remote play feel local and stay unbroken: a resilient connection lifecycle with
backoff and resume, client-side prediction with gentle reconciliation against the
authoritative snapshots, on-screen touch controls, and measurable budgets for echo delay,
drift, and reconnect time under injected latency.
## Dependencies
- `07_canvas_client_and_hud` (complete) — renderer fed by the snapshot buffer, keyboard
input, `net/connection.js`, `window.__uc` telemetry
- `06_realtime_websocket_protocol` (complete) — `UNDERCOOKED_SIM_LATENCY_MS`, the test-only
socket-kill seam, snapshot resync rules
## Tasks
1. `01_connection_lifecycle_and_backoff.md` — reconnect with jittered backoff, resume semantics, protocol-mismatch stop, input flush on drop
2. `02_input_channel_and_seq_discipline.md` — input pacing, change-only sends, seq ownership, resync handling, blur/hidden safety in the net layer
3. `03_client_prediction_and_reconciliation.md` — mirrored integrator, ring buffer, gentle correction, divergence telemetry
4. `04_touch_controls_and_responsive_layout.md` — thumb stick, action button, safe areas, viewport/scroll lockdown
5. `05_netcode_budget_and_latency_suite.md` — the §10 budgets as tests: echo ≤ 120 ms, drift bound, rejoin ≤ 3 s, 4-client latency matrix
## Testing & Quality
- E2E with latency injection (`UNDERCOOKED_SIM_LATENCY_MS` = 0 / 60 / 150 / 250) and the
server's socket-kill seam; assertions read `window.__uc` telemetry and server stats, not
screenshots.
- Client/server constant parity test: `MAX_SPEED`, `ACCEL`, `RADIUS`, `REACH`, `STEP_DT`
must be identical between `src/undercooked/sim/movement.py` and
`app/static/js/shared/constants.js` (fails if either drifts).
- Budgets asserted, not printed: input→echo ≤ 120 ms; visible error ≤ 0.25 tile at 150 ms;
corrections ≤ 4 per 10 s steady movement; rejoin-to-playable ≤ 3 s; 0 lost frames per
connection over a clean 30 s round.
- Coverage: **>90%** on new/modified server code; client paths covered by the e2e matrix.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks update `docs/CLIENT.md` (connection lifecycle, prediction rules, touch controls)
and `README.md` (mobile play, controls, what "connection lost" means).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us06_prediction.py -q` → green at 0 and 150 ms injected latency
- [ ] killing the socket mid-round (server seam) shows the reconnecting overlay and returns to playable ≤ 3 s
- [ ] touch controls move the cook and complete an action on a 390×844 touch context, with no page scroll or zoom
- [ ] the constant-parity test fails if a movement constant is changed on one side only (demonstrated once, then reverted)
- [ ] `uv run pytest tests/e2e/test_us11_touch_mobile.py -q` → green
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases (level 1 soup chain from phase 07 still passes)
@@ -0,0 +1,57 @@
# Task 01 — Connection lifecycle, backoff, and resume
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-09_disconnect_and_rejoin`
## Objective
Give the client a connection that survives reality: dropped Wi-Fi, laptop sleep, server
restart, and protocol mismatch each get a distinct, correct behavior.
## Work
1. `app/static/js/net/connection.js` (harden the phase-07 stub):
- explicit lifecycle enum (`IDLE, CONNECTING, OPEN, RECONNECTING, FAILED, CLOSED`);
- jittered exponential backoff (0.5 s base, ×2, 8 s cap, ±25 % jitter), attempt counter
exposed, `online`/`offline` events used to pause and resume immediately,
- `hello` carries the stored token; a `welcome.rejoined` response clears the overlay, a
`room_gone` error routes to `LANDING` with the code preserved in the join field,
- stop-reconnecting conditions (each shows a distinct message): protocol mismatch
(`4400`), abuse close (`4404`), room gone (`4401`), repeated `room_full` (`4402`) —
reconnect only for network-level failures,
- on drop: flush the pending input queue, zero the intent, tell the renderer to freeze
(`snapshots.freeze()`), show the `RECONNECTING` overlay with attempt count and a
"back to lobby" escape hatch,
- on resume: the client must not wait up to `FULL_SNAPSHOT_EVERY_TICKS` for the next
full frame. Add `snap` to the `welcome` payload (server includes the current full
snapshot when the room is `PLAYING`): a documented wire change, so bump
`PROTOCOL_VERSION` to `2`, update `docs/PROTOCOL.md`, `MIN_SUPPORTED_VERSION` policy
(server accepts only 2), and let `tests/unit/test_protocol_doc_sync.py` prove the doc
and models move together. Record the tick gap in telemetry.
- never leak timers: `close()` clears backoff timers and listeners (test for listener
count stability across 5 reconnect cycles).
2. `app/static/ui/overlay.js` — `RECONNECTING`, `FROZEN`, `SPECTATING`, `RATE_LIMITED`
overlays: `aria-live="polite"`, non-blocking, keyboard-dismissable where sensible.
3. `tests/e2e/test_us09_reconnect.py`:
- server seam kill mid-round → overlay within 1 s, playable ≤ 3 s, same seat and color,
score continuous (no reset),
- kill during `LOBBY` → rejoins the lobby with the ready state intact,
- server restart (fixture restarts uvicorn on the same port) → distinct message, no
infinite spinner, join field keeps the code,
- offline event (`context.set_offline(True)`) → backoff visibly slows and stops the
moment `online` fires,
- protocol mismatch (client sends a bogus `p`) → stops reconnecting, shows `error` copy
with a reload affordance,
- 5 reconnect cycles leave the listener/timer count flat (no leak).
4. `docs/CLIENT.md` — lifecycle diagram, backoff parameters, and the stop-reconnect matrix.
## Testing & Quality
- E2E: every lifecycle branch, with the seam and offline emulation.
- Coverage: **>90%** on any server-side change; leak check via listener counts.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us09_reconnect.py -q` → green
- [ ] rejoin-to-playable ≤ 3 s measured in the test (assert, not logged)
- [ ] protocol mismatch does not loop reconnects (asserted by attempt count ≤ 1)
- [ ] listener/timer count after 5 reconnect cycles equals the count after 1 (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,48 @@
# Task 02 — Input channel and sequence discipline (client side)
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-04_move_and_cook`, `US-06_coop_shared_kitchen`
## Objective
Own the outbound input channel: one place that paces sends, owns `seq`, survives tab
changes, and behaves sanely when the server rejects or drops a sequence.
## Work
1. `app/static/js/net/input_channel.js`:
- single writer of `input` messages (keyboard and touch both call
`setIntent(vec)`, `setAction(bool)` — neither sends directly),
- send-on-change plus a keep-alive at ≥ 5 Hz; send cadence capped at
`min(settings.max_inputs_per_s × 0.5, 30)/s` (the client stays well under the server's
rate limit so legitimate play never trips it),
- `seq` ownership: monotonic per connection, persisted only in memory, zeroed on new
connection, `err{seq}` handled by adopting the server's `seq` hint (from the `resync`
event payload) rather than resuming a broken count,
- drop-oldest pending slot when the socket backpressures (one pending frame is enough;
an old intent is worse than a missing one),
- `freeze(reason)` on: `document.hidden`, `blur`, overlay open, `ROUND_END`, spectating
role — zero intent, action released, sends paused (keep-alive only),
- telemetry: `sent`, `dropped`, `staleErrs` on `window.__uc`.
2. `app/static/js/input/*` refactor: keyboard (phase 07) and touch (task 04) become pure
intent producers; assert no other module calls `connection.send({type:"input"})`
(grep test).
3. `tests/e2e/test_us06_input_channel.py`:
- input send rate stays within the cap while holding a direction for 10 s, and
change-only (no send storm while idle),
- tab hidden → exactly one zero-intent send, then silence; unhide resumes,
- overlay open (reconnecting) → no inputs reach the server (server stats unchanged),
- forced seq error (test hook rewinds `seq`) → adopted cleanly, no error loop,
- spectator role → no inputs sent at all.
4. `docs/CLIENT.md` — input channel rules, seq ownership, freeze reasons table.
## Testing & Quality
- E2E: rate caps, freeze semantics, seq recovery; server-side stats used as ground truth.
- Coverage: **>90%** on server-side changes (test hooks), client verified behaviorally.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us06_input_channel.py -q` → green
- [ ] a grep test proves only `input_channel.js` sends `input` messages
- [ ] idle client sends ≤ 6 messages/s (asserted from server `stats()`)
- [ ] seq error recovery does not loop (≤ 1 error in 5 s, asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,64 @@
# Task 03 — Client prediction and reconciliation
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-06_coop_shared_kitchen`
## Objective
Make the local cook feel instantly responsive without ever becoming authoritative:
dead-reckon from local input, reconcile against server snapshots gently, and stay bounded
under latency.
Prediction is *presentation only*: the server state remains the truth the rules run on, and
no client-side value is ever sent back as authoritative.
## Work
1. `app/static/js/net/predict.js`:
- mirrors the server's movement integrator (accel/decel/max speed/radius) — keep the
mirrored constants in `app/static/js/shared/constants.js` with a comment naming
`src/undercooked/sim/movement.py` as the source of truth.
- `pushLocalInput(input)` → applies to the local ghost immediately and stores
`{seq, applied}` in a ring buffer of `PREDICT_WINDOW = 60` entries.
- `reconcile(snapshot)`: re-simulate the buffered inputs newer than `snapshot.t` from the
snapshot's position for the local player; if the resulting position differs from the
ghost by < `SNAP_ACCEPT = 0.15` tile, snap silently; if larger, ease toward the
corrected position over ≤ 150 ms (never teleport: US-06 criterion 3) and keep easing
bounded so a bad frame cannot fling the cook.
- Anti-rubber-band rules: at most one correction in flight; corrections during a
contested `denied` are applied immediately (the server won on purpose); when
`stun`/`denied` is active, prediction pauses.
- Divergence telemetry: `window.performance` counters exposed on `window.__uc` for tests
(`maxCorrPx`, `corrCount`).
2. `app/static/js/net/echo.js` — input echo suppression: the local cook's rendered
position always prefers the predicted ghost, never the raw snapshot, to avoid double
motion.
3. Tests (Playwright, deterministic; drive the client against the real server with
`UNDERCOOKED_SIM_LATENCY_MS` set by a fixture):
- `tests/e2e/test_us06_prediction.py`:
- input → visible echo ≤ 120 ms (measure key-down timestamp → pixel change in the
cook region),
- at 150 ms injected latency the visible error vs. the authoritative snapshot stays
≤ 0.25 tile while moving (read `window.__uc` + a test-only debug hook that exposes
the latest snapshot),
- corrections ≤ 4 per 10 s of steady movement (no rubber-banding),
- a forced divergence (test hook sets ghost position 3 tiles off) eases to the server
position in ≤ 150 ms without teleport frames ≥ 0.5 tile,
- prediction pauses while stunned (assert the ghost matches the snapshot).
4. `docs/CLIENT.md` — prediction/reconciliation section with the constants table and the
"presentation only" rule.
## Testing & Quality
- E2E with injected latency; assertions read telemetry rather than screenshots.
- Constant parity (browser-free): `tests/unit/test_client_constants_parity.py` parses the
numeric constants out of `src/undercooked/sim/movement.py` (`ast`, no import tricks) and
out of `app/static/js/shared/constants.js` (regex on `export const NAME = value`) and
fails on any mismatch for `MAX_SPEED`, `ACCEL`, `DECEL`, `RADIUS`, `REACH`, `STEP_DT`.
The mirrored integrator itself stays in a pure ES module and is exercised through the
browser in the suite above.
- Coverage: **>90%** on new server-side code; client logic covered by the e2e assertions.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us06_prediction.py -q` → green at 0 and 150 ms injected latency
- [ ] a test fails if `MAX_SPEED`/`ACCEL`/`RADIUS` differ between `movement.py` and `constants.js` (proven by temporarily changing one value)
- [ ] `window.__uc.maxCorrPx` stays within the bound during a 30 s scripted movement
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,58 @@
# Task 04 — Touch controls and responsive layout
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-11_play_on_phone`
## Objective
Make the game playable with two thumbs on a phone: a drag stick, a hold-to-work action
button, and a layout that survives every viewport without scroll or zoom.
## Work
1. `app/static/js/input/touch.js` (Pointer Events only):
- left half of the play surface is a floating thumb zone: touch-down anchors the stick,
drag sets the intent, quantized to 8 directions with a 12 px dead zone and a 55 px rim,
release → zero intent; stick rendered as a light vector overlay (no DOM layout thrash),
- right side: one large *act* button (≥ 72 px), press → action `true`, release →
`false`, long-press (≥ 400 ms) also satisfies the trash hold,
- multi-touch: stick and action simultaneously (2 pointers tracked by `pointerId`);
a third pointer (a second player's finger on one device) is ignored, not glued,
- no `touchstart`/`click` double-fire, no 300 ms delay reliance, `setPointerCapture` for
the stick, `pointercancel` treated as release,
- controls auto-hide on non-touch devices and can be forced on via
`?controls=touch` (test hook + accessibility preference).
2. Layout lockdown in `app/static/css/screens.css` + `play.js`:
- `touch-action: none` **scoped to the play surface and stick zone only**;
`overscroll-behavior: none`; `height: 100dvh` with a `100svh` fallback;
`viewport-fit=cover` + `env(safe-area-inset-*)` padding; `user-select: none` during
play. Do **not** add `maximum-scale`/`user-scalable=no` to the viewport meta — that
blocks accessibility zoom everywhere. Pinch-zoom stays available on DOM screens, and on
the play surface `touch-action: none` is what stops in-game zoom/pan/scroll.
- HUD reflow: order rail to the top edge, score/timer compact, controls never overlap the
serve window or order rail (asserted geometrically in the test),
- orientation: both portrait and landscape playable; a hint suggests landscape below
500 px height without blocking play.
3. `tests/e2e/test_us11_touch_mobile.py` (`devices["iPhone 13"]`-style context plus a
360×640 custom context):
- create + join by typing the code (US-02 on mobile) with the right input attributes,
- pointer drag moves the cook in the expected direction and stops on release,
- act button completes a chop (progress ring visible, `CHOPPED` event),
- simultaneous stick+act works (one pointer each),
- no page scroll/zoom: `document.scrollingElement.scrollTop` stays 0 and
`visualViewport.scale` stays 1 during vigorous drags,
- control hit targets ≥ 44 px (computed `getBoundingClientRect`),
- controls absent on a desktop context, forced with `?controls=touch`.
4. `docs/CLIENT.md` — touch mapping, dead zone/rim numbers, safe-area strategy, and the
"pinch zoom stays on DOM screens" rationale.
## Testing & Quality
- E2E on two mobile viewports + a desktop negative case.
- Coverage: **>90%** on any server-side change.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us11_touch_mobile.py -q` → green on iPhone-13 and 360×640 contexts
- [ ] stick + act simultaneously completes a chop (asserted via the `CHOPPED` event)
- [ ] no scroll or zoom during play (asserted), pinch-zoom still works on the lobby screen
- [ ] every touch control ≥ 44 px (asserted from bounding boxes)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,47 @@
# Task 05 — Netcode budget and latency matrix
**Phase:** `08_client_prediction_and_touch` · **Story:** `US-06_coop_shared_kitchen`, `US-09_disconnect_and_rejoin`
## Objective
Turn PLAN §10's promises into an executable latency matrix so "it feels fine on my
machine" can never again be an argument.
## Work
1. `tests/e2e/conftest.py` — a `latency` fixture that parameterizes the server's
`UNDERCOOKED_SIM_LATENCY_MS` over `{0, 60, 150, 250}` (server restarted per value, or a
per-room override via the test seam if cheaper) and exposes
`measure(page, fn) -> Metrics` reading `window.__uc` plus CDP `performance` data.
2. `tests/e2e/test_us06_latency_matrix.py` — for each latency value, a 30 s scripted round
with two clients:
- input → visible echo ≤ 120 ms **local** (this is local prediction, so it must stay
flat across all latency values — the point of the phase),
- remote-cook visible error vs. authoritative ≤ 0.25 tile while moving,
- corrections ≤ 4 per 10 s of steady movement, `maxCorrPx` within the ease bound,
- zero dropped frames attributable to the net layer (`stats().netStallFrames == 0` at
≤ 150 ms; ≤ 2 at 250 ms),
- score/result identical on both clients within 1 s of `round`.
3. `tests/e2e/test_us09_latency_matrix.py` — for each latency value: kill mid-round via the
server seam, assert rejoin-to-playable ≤ 3 s (≤ 4 s at 250 ms), no duplicate seat, no
score discontinuity, and the ghost label appears for the other client within 1 s.
4. `tests/integration/test_server_latency_flag.py` — the `UNDERCOOKED_SIM_LATENCY_MS`
injects the configured delay within ±20 % on localhost (server-side check so the matrix
is trustworthy) and `0` adds nothing measurable.
5. `BUDGETS.md` (repo root) — the numbers table with the **measured** values per latency
setting from this run, dated, and the rule "a phase that regresses a budget must update
PLAN.md with the user's sign-off, not relax the test". `README.md` links it.
6. `.agents/validate.sh` — run the netcode matrix as a `slow` stage (so it is gated, not
optional) and print the measured table on failure.
## Testing & Quality
- E2E matrix across 4 latency values, asserting the PLAN §10 budgets.
- Coverage: **>90%** on any server-side change (latency flag, test seams).
- Full suite green; the whole pipeline gate passes with the matrix included.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us06_latency_matrix.py -q` → green at 0/60/150/250 ms
- [ ] `uv run pytest tests/e2e/test_us09_latency_matrix.py -q` → green (rejoin ≤ 3 s at ≤ 150 ms)
- [ ] `BUDGETS.md` records this run's measured numbers with a date
- [ ] `bash .agents/validate.sh` includes the matrix stage and exits 0
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases (the phase-07 soup-chain suite still passes)
@@ -0,0 +1,47 @@
# Phase 09 — Cooperative play with 2–4 humans
**Story:** `US-06_coop_shared_kitchen` (the contract), `US-04_move_and_cook`
**Context:** `.agents/PLAN.md` §5 (contested resolution), `docs/SIMULATION.md` carry rules
## Objective
Prove and polish the part that makes this a co-op game rather than four single players
sharing pixels: deterministic arbitration of contested items and stations, station
occupancy that reads on screen, an item pass between two cooks, and a multi-browser test
harness that other suites reuse.
## Dependencies
- `08_client_prediction_and_touch` (complete) — prediction, input channel, latency tooling
- `07_canvas_client_and_hud` (complete) — renderer, HUD, deny feedback
- `04_kitchen_pipeline_and_scoring` (complete) — contested-resolution rules to test
## Tasks
1. `01_contested_arbitration_and_station_locks.md` — tighten server arbitration, `denied` payloads, lock release timing, fairness stats
2. `02_multi_context_playwright_harness.md` — reusable two/three/four-context room fixture with shared code+seed and deterministic input recording
3. `03_cooperative_workflow_e2e.md` — division of labour, item pass, shared station queue, both clients converge
4. `04_four_player_fairness_and_starvation.md` — 4-player starvation guard, soft-push fairness, fairness telemetry
## Testing & Quality
- Unit (sim): contested arbitration tie-breaks across all pairings; station lock acquire/
release timing; `denied` payload correctness per reason; fairness counters.
- E2E: the flagship two-context suites (below) plus regression on phase 07/08 suites.
- Fairness metric: over a 3-minute scripted 4-player round, no player receives `denied`
more than 25 % more often than the mean (guards against systematic seat-order bias).
- Coverage: **>90%** on new/modified code.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks update `docs/SIMULATION.md` (arbitration contract), `docs/CLIENT.md` (deny
presentation), and `README.md` (co-op tips: passing, queueing, who chops).
## Completion Criteria
- [ ] two browser contexts complete one order together (one chops, one cooks and serves) and both show the same score and result
- [ ] a contested pickup yields exactly one winner and one visible `denied` (asserted at both server and DOM level)
- [ ] an item pass between two cooks via the prep counter works and is attributed as `HANDED_OFF`
- [ ] the 4-player starvation guard passes (fairness counters within band)
- [ ] the multi-context harness is used by at least two suites and documented in `tests/e2e/README.md`
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,46 @@
# Task 01 — Contested arbitration and station locks
**Phase:** `09_co_op_two_player_e2e` · **Story:** `US-06_coop_shared_kitchen`
## Objective
Make sharing one kitchen feel fair: exact, explainable arbitration when two cooks want the
same tomato or the same stove, with payloads the client can render meaningfully.
## Work
1. `src/undercooked/sim/arbitration.py` (extract from `interact.py` if it grew there):
- `resolve_contest(state, requests) -> tuple[Winner, list[Loss]]` implementing the
documented rule: smallest `received_at_tick`, then smallest `seat`; stable across
runs; no randomness.
- Station occupancy: `WORKING` + `worker_id` is the lock; lock released when the worker
leaves reach for `RELEASE_TICKS`, when the station reaches `READY`, or when the worker
is disconnected/parked; a queued second player gets `STATION_BUSY` with
`{"busy_by": "<name>", "eta_s": <remaining>}` so the client can say "Marco is chopping".
- `denied` payloads are mandatory and typed per reason (extend `EventKind.DENIED`
payload rules): `OUT_OF_REACH` → target cell, `STATION_BUSY` → busy_by + eta,
`HANDS_FULL` → carried item, `CONTESTED_LOST` → winner id, `NO_PLATE` → why.
- Fairness counters on the state: `contest_requests`, `contest_losses[player]`,
`lock_waits[player]` (ticks spent blocked by a busy station) — surfaced in
`result_payload` under `fairness` (display/debug only).
- A `denied` must never cost the player time they already invested (chop progress stays;
verify explicitly).
2. `docs/SIMULATION.md` — the arbitration rule as a numbered contract with two worked
examples (this is what the E2E asserts).
3. `tests/sim/test_arbitration.py` — all pairings (1v2, 2v3, 4-way), tick tie-break, seat
tie-break, lock release on `READY`/leave/disconnect/park, eta correctness, per-reason
payload shape, invested progress preserved, counters accumulate correctly, determinism
of a 1 000-tick contest-heavy fuzz.
4. `tests/integration/test_ws_denied_payloads.py` — two sockets contest a real item; the
loser receives the documented payload; the winner does not.
## Testing & Quality
- Unit + integration as listed; contest-heavy fuzz keeps determinism honest.
- Coverage: **>90%** on `arbitration.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_arbitration.py tests/integration/test_ws_denied_payloads.py -q` → green
- [ ] a 4-way contest resolves to exactly one winner with three `CONTESTED_LOST` events (asserted)
- [ ] `STATION_BUSY` payload includes `busy_by` and `eta_s` (asserted)
- [ ] chop progress is not lost on a denied second press (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,50 @@
# Task 02 — Reusable multi-context Playwright harness
**Phase:** `09_co_op_two_player_e2e` · **Story:** `US-06_coop_shared_kitchen`
## Objective
Build the multiplayer test rig every later suite needs: N browser contexts in one room,
deterministic seeds, scripted keyboard drivers, and server-side observation — so co-op
tests read like choreography instead of plumbing.
## Work
1. `tests/e2e/conftest.py` (extend, do not duplicate):
- `game_server` fixture — one uvicorn instance per module with overridable settings
(`levels_dir`, `tick_hz`, `sim_latency_ms`, `UNDERCOOKED_TEST_MODE=1`), port from an
ephemeral bind, and clean shutdown;
- `room` fixture — creates a room over HTTP and returns `{code, seed}`;
- `player` factory — `player(name, viewport=None, touch=False, latency_ms=0)` →
a `PlayerCtx` wrapping a browser context + page that: opens `/`, joins `code`
(typing the code with `page.fill` + submit), waits for `welcome`, exposes
`hold(key, seconds)`, `tap(key)`, `walk(dx_cell, dy_cell)`, `act(seconds)`,
`snapshot()` (server-side sim state via the test seam), `score()`, `dom_text(sel)`,
`telemetry()`, `kill_socket()`, `close()`;
- `assert_eventually(predicate, timeout=5)` — polling helper with a readable failure
message (never bare `sleep`);
- `record_frames(player, seconds)` — samples `window.__uc` for motion assertions.
2. `tests/e2e/test_harness_itself.py` — meta-suite proving the rig works: 4 players join
one room, all four report the same seat list, `hold("ArrowRight", 1)` moves each within
the expected bounds, `kill_socket` produces exactly one ghost, fixture teardown leaves
zero live rooms (`/api/health` → `rooms == 0`).
3. `tests/e2e/README.md` — rewrite around the harness: the canonical co-op test shape, the
"assert on the server, present in the DOM" rule, why seeds are pinned, how to debug a
failing multiplayer test (`--headed`, `--slowmo`, `PLAYWRIGHT_TRACING=1` → traces
artifacts are gitignored), and the artifact paths.
4. Add tracing/screenshot-on-failure config to `tests/e2e/conftest.py` (context
`tracing="retain-on-failure"`, artifacts under `test-results/`, already gitignored) and
document that these artifacts must never be committed.
## Testing & Quality
- E2E meta-suite; teardown hygiene (zero leaked rooms/connections after the suite).
- Coverage: harness code is test code; **>90%** requirement applies to any server change.
- Full suite green, including phases 07–08 suites refactored onto the harness (refactor only
their fixtures, never their assertions).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_harness_itself.py -q` → green with 4 players
- [ ] at least two pre-existing e2e suites now use the `player` factory (diff shows fixture-only changes)
- [ ] after `uv run pytest tests/e2e -q`, `curl /api/health` reports `rooms: 0` and the server logs show no leaked tasks
- [ ] a deliberately failing test produces a trace + screenshot under `test-results/` (and that path is gitignored)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,46 @@
# Task 03 — Cooperative workflow end-to-end
**Phase:** `09_co_op_two_player_e2e` · **Story:** `US-06_coop_shared_kitchen`
## Objective
Test the behaviors that only exist with two humans: division of labour, handing work over,
queueing at one stove, and both screens agreeing at the end.
## Work
1. Scripted choreographies on the harness (two `player`s, level 1, fixed seed) in
`tests/e2e/test_us06_coop_workflow.py`:
- `test_division_of_labour_soup` — A chops tomato+onion and drops them on the prep
counter; B picks them up, runs the pot and plate, serves; both clients show the same
`SERVED` event, order count, and score;
- `test_item_pass_via_counter` — the dropped item is pickable by the other player and
attributed `HANDED_OFF` server-side;
- `test_handoff_while_holding` — passing by dropping onto the other player's tile is
rejected with `HANDS_FULL`/`CONTESTED_LOST` as documented (no teleporting items);
- `test_two_cooks_one_stove_queue` — A starts the pot, B gets `STATION_BUSY` with the
right eta, and B's cook visibly waits then takes the finished pot;
- `test_shared_dirty_duty` — both players wash; the sink counter and the shared dirty
pool converge on both clients;
- `test_soft_push_does_not_trap` — two cooks pushing in a corridor never produce an
infinite jitter loop (position variance bounded over 5 s) and neither is displaced into
a wall;
- `test_both_see_the_same_result` — after `round`, both result payloads are equal
(JSON-compared) including per-player rows.
2. `docs/SIMULATION.md` — a short "Co-op contract" subsection restating what these tests
assert, in player terms ("if you both grab it, one of you gets it and the other is told
so").
3. `tests/unit/test_soft_push_stability.py` — 500 randomized two-body pushes: no
wall penetration, no NaN, position delta bounded, symmetric outcome when the seats swap.
## Testing & Quality
- E2E choreographies (the phase's headline) + a soft-push stability fuzz.
- Coverage: **>90%** on new/modified code.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us06_coop_workflow.py -q` → green (all scenarios)
- [ ] the hand-off chain is attributed `HANDED_OFF` and visible on both clients (asserted)
- [ ] queued cook receives `STATION_BUSY` with a plausible eta and eventually serves (asserted)
- [ ] soft-push fuzz finds no wall penetration or NaN over 500 cases
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,41 @@
# Task 04 — Four-player fairness and starvation guard
**Phase:** `09_co_op_two_player_e2e` · **Story:** `US-06_coop_shared_kitchen`
## Objective
Scale the co-op guarantees to a full 4-player room and prove the simulation does not
systematically favour one seat.
## Work
1. Headless fairness harness `tests/sim/test_fairness_4p.py`:
- a 3-minute (3 600-tick) scripted 4-player round on level 2 (tight layout) driven by a
deterministic bot policy (`tests/sim/bots.py`: nearest-target greedy with a per-seat
bias), measuring: `contest_losses` per seat, `lock_waits` per seat, served-count per
seat, spawn-collision refusals per seat;
- assert no seat's `contest_losses` exceed the mean by > 25 %, no seat's `lock_waits`
exceed the mean by > 40 %, and each seat serves ≥ 60 % of the top seat's count;
- permutation test: the same run with seats rotated 4× must produce a
rotation-equivalent distribution (assert max/min ratio of the metric ≤ 1.5) — this is
what catches seat-order bias rather than layout bias.
2. Fix whatever the harness finds, in the sim (typical culprits: iteration order in
`interact.resolve`, spawn spacing on tight layouts, target-order ties broken by seat
alone). Document each fix as a numbered rule in `docs/SIMULATION.md`.
3. `tests/integration/test_ws_four_players.py` — 4 real sockets in one room: all four
receive identical `SERVED` events for the same order, the same final payload, and the
4-player layout renders (canvas not blank, HUD shows 4 names).
4. `docs/DESIGN.md` — a "fairness" note: what we measure, the thresholds, and why they are
measured on a rotated seat permutation.
## Testing & Quality
- Sim + integration; the permutation test is the interesting part and must be able to fail.
- Coverage: **>90%** on new/modified code.
- Full suite green (phases 01–09).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_fairness_4p.py -q` → green, including the 4× rotation permutation
- [ ] `uv run pytest tests/integration/test_ws_four_players.py -q` → green with identical payloads on 4 sockets
- [ ] at least one real fairness fix landed, or the report states that none was needed with the measured margins
- [ ] fairness thresholds and measured values recorded in `docs/DESIGN.md`
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,56 @@
# Phase 10 — Hazards and pressure mechanics
**Story:** `US-08_hazards_under_pressure`
**Context:** `.agents/PLAN.md` §5 (hazards), §6 (`hazards` level block), `docs/SIMULATION.md`
## Objective
Add the mechanics that create the panic: fire that spreads and is extinguishable, spills
that make cooks slip, moving obstacles that stun, and rush waves that shorten the clock —
each with a warning window, a counter-play, and an explicit reduced-motion presentation.
Every mechanic is authored through the level YAML `hazards` block, never hard-coded per
level.
## Dependencies
- `09_co_op_two_player_e2e` (complete) — arbitration, station locks, multi-context harness
- `04_kitchen_pipeline_and_scoring` (complete) — order/score/spoil systems hazards plug into
- `07_canvas_client_and_hud` (complete) — event→FX pipeline, reduced-motion mode
## Tasks
1. `01_fire.md` — ignition, spread rules, extinguishing (sink/trash), damage to items and orders
2. `02_spills_and_slipping.md` — spill creation, slip stun, recovery, cleaning loop
3. `03_moving_obstacles.md` — path-driven obstacles, telegraph, stun, item knock-off
4. `04_rush_waves_and_pressure_modifiers.md` — timed waves, shortened order timers, spawn-rate ramps, level-declared modifier table
5. `05_hazard_presentation_and_warnings.md` — telegraphs, FX, audio-free cues, reduced-motion alternates, onboarding tooltip for the first hazard
6. `06_hazard_determinism_and_validator_hardening.md` — determinism under hazard fuzz, validator rules for the new hazard fields, `docs/SIMULATION.md` rules
## Testing & Quality
- Unit (sim): every hazard rule with a fake clock; spread/stun/wave boundaries; interaction
matrix (fire × carried item, spill × soft push, obstacle × parked disconnected cook).
- Sim determinism: 3 000-tick hazard-heavy replay reproduces hash exactly; a hazard seeded
run is bit-identical across two `Room` instances.
- E2E (`test_us08_hazards_under_pressure.py`): fire visible + extinguishable by a player,
slip visibly stuns and is recoverable, obstacle telegraph appears ≥ its warning window
before contact, rush wave visibly shortens the timer, and reduced-motion mode still shows
every hazard as a static marker.
- Safety floor: hazards never kill, never permanently block the serve window, and never make
a level unwinnable (asserted by the phase-11 solver reuse here on the hazard levels).
- Coverage: **>90%** on new/modified code.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks update `docs/SIMULATION.md` (hazard rules), `docs/LEVELS.md` (every hazard field and
its defaults), `docs/CLIENT.md` (warning/FX and reduced-motion mapping), `README.md`
(tips: extinguish, mop, watch the telegraph).
## Completion Criteria
- [ ] `uv run pytest tests/sim -q -k "fire or spill or obstacle or rush"` → green
- [ ] `uv run pytest tests/e2e/test_us08_hazards_under_pressure.py -q` → green (all mechanics, both motion modes)
- [ ] 3 000-tick hazard-heavy replay is hash-identical to the golden and across two room instances
- [ ] every hazard field in `levels/*.yaml` is documented in `docs/LEVELS.md` and rejected by the validator when out of range (schema test)
- [ ] no level becomes unwinnable with its hazards enabled (solver bound re-run)
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,53 @@
# Task 01 — Fire
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
## Objective
Fire as a fair, readable threat: it starts from burnt food, spreads to adjacent
combustible tiles on a schedule, damages what is on it, and is put out with water.
## Work
1. `src/undercooked/sim/hazards/fire.py`:
- `IgnitionSource`: a `BURNT` item on a `stove`/`chop`/`prep` tile ignites that tile with
probability 1 (deterministic, no RNG — reproducibility beats realism here);
- `HazardState {kind=FIRE, cell, stage, ticks_in_stage, fuel}`; stages
`SPARKING` (warning, `spark_ticks` from level YAML, default 30) → `BURNING` →
`OUT`/`EXTINGUISHED`;
- spread: while `BURNING`, every `spread_ticks` (level field, default 40) ignite one
adjacent `COMBUSTIBLE` tile (counters, not walls/void) in a deterministic order
(N, E, S, W) — cap the simultaneous fire count per level (`max_fires`, default 4) so a
kitchen can't cascade into an unwinnable state;
- effects: items on a `BURNING` tile are destroyed (`WASTED` counter, `ITEM_BURNED`
event); a cook entering takes a `STUNNED(short)` and drops the carried item onto the
nearest safe tile (never deleted); orders are never destroyed by fire directly — the
player loses the ingredients, not the order;
- extinguish: interacting with a `sink` (or `trash`) while `action_held` and adjacent to
the fire cell puts it out (`FIRE_OUT` event), costs one `dirty` dish if the cook is
holding nothing (the bucket is dirty — keeps the sink loop meaningful); water has a
cooldown (`water_cooldown_ticks`, default 10) so it cannot be spam-toggled;
- disconnected/parked cooks never spread or extinguish fire.
2. Level fields (`level.py` + validator + `docs/LEVELS.md`): `hazards.fire.{enabled,
spark_ticks, spread_ticks, max_fires, water_cooldown_ticks}` with bounds, and the
validator rule: if `fire.enabled`, the level must have ≥ 1 `sink` reachable from every
fire-capable tile (BFS) — no fire without a way to fight it.
3. Events: `FIRE_IGNITED`, `FIRE_SPREAD`, `FIRE_OUT`, `ITEM_BURNED` (+ client FX mapping in
`render/fx.js`, with static markers under reduced motion).
4. `tests/sim/test_fire.py` — ignition from burnt food, stage timing at each boundary,
spread order and cap, item destruction + counter, cook stun + item relocation (never
lost), extinguish path + cooldown, no spread to walls/void/serve window, parked
disconnected cook is inert, 3 000-tick determinism with fire enabled.
5. `tests/unit/test_level_validator_fire.py` — the "fire requires a reachable sink" rule
(positive + two negatives), out-of-range field rejection, defaults filled.
## Testing & Quality
- Unit (sim) + validator tests; boundaries at `spark_ticks-1/spark_ticks/+1`.
- Coverage: **>90%** on `sim/hazards/fire.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_fire.py tests/unit/test_level_validator_fire.py -q` → green
- [ ] fire never exceeds `max_fires` and never spreads to non-combustible tiles (asserted)
- [ ] a carried item displaced by fire always lands on a reachable tile or is counted in `lost_items` (asserted, never silently dropped)
- [ ] `uv run undercooked-levels validate` passes shipped levels with fire enabled
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,50 @@
# Task 02 — Spills and slipping
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
## Objective
Wet floors: dropped/burst contents make a spill, stepping in it slips you, and mopping is a
real chore that competes with cooking.
## Work
1. `src/undercooked/sim/hazards/spill.py`:
- creation: `ITEM_BURNED`, a dropped `pot`/`pan` with contents, an ingredient destroyed by
an obstacle, and level-declared `hazards.spills.seed_cells` (used by level 5) each create
a `HazardState {kind=SPILL, cell, life_ticks}`; multiple spills do not stack on one cell
(life refreshes instead);
- slip: a cook entering a spill cell rolls a slip (`slip_chance` expressed deterministically
as `every_n_th_entry`, default 1 — a spill always slips you on the level that has them, so
the mechanic is legible; per-level tunable) → `STUNNED(slip_stun_ticks, default 20)` plus a
short scripted slide in the entry direction until a blocking tile (slide never enters
`VOID`, never crosses a wall, capped at `max_slide_cells`, default 2);
- slide carries the carried item with the cook (it does not fall off mid-slide) unless the
slide ends adjacent to a `BURNING` tile;
- cleanup: interacting with the spill while `action_held` cleans it (`SPILL_CLEANED`) and adds
one `dirty` dish to the sink pool (mop/bucket realism), or the cook can walk the dirty-dish
route — both are viable; cleanup has a `clean_cooldown_ticks` (default 6);
- spills decay after `life_ticks` (default 300) so an ignored kitchen does not become
permanently unplayable; decay emits `SPILL_GONE`.
2. Level fields: `hazards.spills.{enabled, every_n_th_entry, slip_stun_ticks,
max_slide_cells, life_ticks, clean_cooldown_ticks, seed_cells[]}`; validator: `seed_cells`
must be `FLOOR` and reachable from ≥ 2 spawn points (a spill you can never clean is a bug).
3. Events `SPILL_CREATED`, `SLIPPED`, `SPILL_CLEANED`, `SPILL_GONE` + FX (decal, slide dust,
static marker under reduced motion).
4. `tests/sim/test_spills.py` — creation sources, no stacking, slip + slide boundaries
(`max_slide_cells`, wall stop, void stop), carried item stays through the slide, cleanup +
cooldown + dirty-dish accounting, decay, deterministic entry counting, 3 000-tick replay
with spills seeded.
5. `tests/unit/test_level_validator_spills.py` — `seed_cells` must be floor and reachable;
bounds rejection.
## Testing & Quality
- Unit (sim) + validator; slide math tested with a wall and void fixture map.
- Coverage: **>90%**.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_spills.py tests/unit/test_level_validator_spills.py -q` → green
- [ ] a slide never enters a wall or void and never exceeds `max_slide_cells` (asserted on a fixture map)
- [ ] cleanup always adds exactly one dirty dish and respects the cooldown (asserted)
- [ ] an ignored spill decays and the round stays winnable (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,55 @@
# Task 03 — Moving obstacles
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
## Objective
Things that move through the kitchen on rails: a telegraph, a stun on contact, and a clear
way to avoid them.
## Work
1. `src/undercooked/sim/hazards/obstacle.py`:
- authored as `hazards.obstacles[]: {id, path: [[x,y],...], speed_cells_s, mode:
pingpong|loop, period_s?, offset_s?, stun_ticks}`; position is a pure function of the
tick (`pos_at(tick)`) — no integration drift, trivially replayable and testable;
- telegraph: `TELEGRAPH_TICKS` (default 12) before entering a cell, that cell is flagged
in the snapshot (`hazard_cells` with intensity) so the client can draw the warning tile
before the obstacle is there;
- contact: a cook in the obstacle's cell (or the telegraph cell at contact time) takes
`STUNNED(stun_ticks)` and drops the carried item one tile back along their movement
vector (onto a reachable tile, else `lost_items` + `ITEM_LOST`); the obstacle never
moves a cook through a wall;
- items on the path are knocked, not destroyed (destroy only if the destination is
`BURNING`, reusing the fire rule);
- orders and stations are unaffected; an obstacle never occupies the serve-window tile or
a spawn tile while a cook is spawning;
- two obstacles on intersecting paths pass through each other (documented, avoids
deadlock); obstacle vs. parked disconnected cook = stun on the ghost, no effect on state
beyond the counter.
2. Snapshot/protocol: `hazard_cells` array (`{x, y, kind, intensity}`) added to the state
encoding — a wire change, so bump `PROTOCOL_VERSION`, update `docs/PROTOCOL.md`,
`tests/unit/test_protocol_doc_sync.py`, and the client renderer's hazard layer.
3. Level fields + validator: `path` cells in bounds and traversable (floor, not wall/void),
`speed_cells_s` within `0.5..4`, at least one cell on the path reachable by no cook at
spawn, obstacles cannot fully separate the serve window from the spawns at any tick
(BFS over the union of blocked cells across one full period) — this is the rule that keeps
levels fair and is the interesting part of the validator.
4. `tests/sim/test_obstacles.py` — `pos_at` periodicity/ping-pong correctness, telegraph
timing, stun + knockback placement, no wall traversal, path never disconnects the kitchen
(property check over one period), two obstacles passing, determinism across two rooms.
5. `tests/unit/test_level_validator_obstacles.py` — bad path, bad speed, unreachable-path,
kitchen-severing path (each rejected with the specific message).
6. `tests/e2e/test_us08_obstacle_telegraph.py` — the warning tile is visible ≥ telegraph
window before contact, contact stuns visibly, reduced motion still shows the marker.
## Testing & Quality
- Unit (sim) + validator + one e2e; the path/BFS checks are property-tested over a full period.
- Coverage: **>90%** on `obstacle.py` and the new validator rules.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_obstacles.py tests/unit/test_level_validator_obstacles.py -q` → green
- [ ] no obstacle path ever disconnects the serve window from the spawns (asserted over a full period)
- [ ] the telegraph precedes contact by exactly `TELEGRAPH_TICKS` (asserted) and the e2e sees it
- [ ] `PROTOCOL_VERSION` bump + doc sync test green
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,49 @@
# Task 04 — Rush waves and pressure modifiers
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
## Objective
Escalate inside a single round: timed waves that shorten clocks and raise the spawn rate,
declared in level YAML so every level composes pressure from the same vocabulary.
## Work
1. `src/undercooked/sim/hazards/rush.py`:
- `hazards.rush.waves[]: {at_s, duration_s?, order_time_mult, spawn_mult?, max_orders_delta?,
label?}`; effects applied as multipliers over the level's base order budget, computed in
`orders.py` via a `PressureModel` the round step consults (no ad-hoc timer edits);
- deterministic by tick: `pressure_at(tick)` is a pure function; overlapping waves compose
multiplicatively with a documented clamp (`order_time_mult ≥ 0.35`, `spawn_mult ≤ 3.0`) so
a level cannot author an impossible squeeze;
- `RUSH_START` / `RUSH_END` events with the `label` for the HUD banner; the HUD shows the
active wave name and the modified timer scale;
- `hazards.modifiers` (existing level field) wired to the same hook: `slow_cook`,
`fast_spoil`, `no_plates_until_wash`, `dark_kitchen` (vision-limited client overlay,
server unchanged), each a named, tested rule with a `docs/SIMULATION.md` entry — a
modifier that is declared but unimplemented fails the validator.
2. Validator + `docs/LEVELS.md`: wave bounds, monotonic `at_s`, clamp documentation, and a
rule that a level's total `order_time_mult` schedule cannot drop below the solver's minimum
feasible window (reuses the phase-11 solver hook if present; otherwise the static bound
`time_limit × min(order_time_mult) ≥ shortest_recipe_seconds × 2`).
3. `tests/sim/test_rush_waves.py` — `pressure_at` step boundaries at `at_s`, `at_s+durations`,
clamp behaviour, overlap composition, event emission once per wave, no wave mutating
already-spawned orders (documented: pressure affects new orders only), determinism.
4. `tests/sim/test_modifiers.py` — one test per modifier, plus "declared-but-unimplemented
fails".
5. `tests/e2e/test_us08_rush_wave.py` — HUD banner appears at the wave, the order time bar
visibly shorter for a post-wave order, and the round remains winnable.
6. `docs/SIMULATION.md` — pressure model as a formula, and the "pressure never retroactively
shortens a live order" rule.
## Testing & Quality
- Unit (sim) + validator + e2e; boundary tests at every `at_s`.
- Coverage: **>90%**.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_rush_waves.py tests/sim/test_modifiers.py -q` → green
- [ ] pressure affects only orders spawned after the wave (asserted)
- [ ] clamps hold for a deliberately over-aggressive test level (asserted, not clamped away silently)
- [ ] declaring an unknown/unimplemented modifier fails `uv run undercooked-levels validate`
- [ ] `uv run pytest tests/e2e/test_us08_rush_wave.py -q` → green
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,49 @@
# Task 05 — Hazard presentation, warnings, and reduced motion
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`, `US-12_play_accessibly`
## Objective
Make every hazard readable at a glance on any screen and without motion — a player must be
able to survive hazards they cannot animate.
## Work
1. `app/static/js/render/hazards.js` — draw from `hazard_cells`: intensity ramp, telegraph
(dashed outline + 3-pip countdown), fire stages, spill decal, obstacle body; obstacles
drawn on the player layer so they are never hidden behind a counter.
2. Non-color, non-motion redundancy (required, not optional):
- each hazard kind has a distinct glyph (flame / droplet / arrow) drawn at the cell,
- a textual status strip in the HUD (`FIRE ×2`, `WET FLOOR`, `CONVEYOR`, `RUSH: ORDER RUSH`)
with `aria-live="polite"` on appearance only (not every tick),
- `data-motion="reduced"`: no pulsing, no screen shake, no particle storm — static markers,
full-opacity glyphs, telegraph shown as a filled tile instead of an animation,
- colorblind palette keeps hazards distinguishable (guard: hazard glyph set must be drawn
even when colors are forced to grayscale in the test).
3. First-hazard onboarding: the first time a level's hazard appears, a one-time caption
("Fire! Use the sink to put it out") for 3 s, dismissible, suppressed for repeats and
stored per session in `sessionStorage` — no tutorial screens.
4. `assets_src/fx.svg` extensions (flame stages, droplet, arrow, telegraph pips) regenerated
into `index.json` by `tools/gen_art_index.py` (extend `EXPECTED_IDS`).
5. `tests/e2e/test_us08_hazard_presentation.py`:
- telegraph pixel region changes before contact (variance sample, not screenshot equality),
- HUD status strip lists the active hazard kinds within 200 ms,
- reduced-motion context: no CSS `transform: translate` shake on the canvas wrapper, glyphs
still present (probe: draw the reduced-motion frame and assert non-background pixels at
the hazard cell),
- caption appears once and not again after a retry,
- grayscale filter context: hazard cells still differ from non-hazard cells by luminance.
6. `docs/CLIENT.md` + `docs/SIMULATION.md` cross-reference: the redundancy contract (glyph +
text + color, never color alone).
## Testing & Quality
- E2E with four contexts (normal, reduced motion, forced grayscale, colorblind palette).
- Coverage: **>90%** on any server-side change (none expected beyond event payloads).
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us08_hazard_presentation.py -q` → green across all four contexts
- [ ] every hazard kind has a glyph, a HUD text label, and a distinct color (a test walks the hazard enum against the mapping)
- [ ] reduced motion removes shake/pulses but keeps every hazard visible (asserted by pixel probe)
- [ ] the first-hazard caption shows once per session and is dismissible
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,46 @@
# Task 06 — Hazard determinism, fuzz, and validator hardening
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
## Objective
Prove the hazard suite cannot desync, cannot wedge a round, and cannot be authored into an
unwinnable level.
## Work
1. `tests/sim/test_hazard_determinism.py`:
- property-based (hypothesis-free, seeded random generators are enough) fuzz: 200 generated
hazard configurations (random fire/spill/obstacle/rush combos on the shipped maps) ×
600 ticks with 4 scripted bots; assert two runs of the same config+seed produce identical
state hashes and identical event logs;
- no-crash sweep: NaN/inf checks on every numeric field, `stun` never negative, hazard
counts never exceed caps, no exception escapes `step()`;
- invariants every run: serve window reachable, ≥ 1 spawn tile free, item count + wasted +
lost conserved, dirty-dish pool ≤ plates total.
2. `tests/sim/test_hazard_fairness.py` — for each hazard level, the solver bound re-runs with
hazards enabled: the level is still finishable within `time_limit` and the star thresholds
stay achievable (assert the same invariant phase 11 establishes; if the solver hook does not
exist yet, use a scripted greedy bot that must reach `target_orders`).
3. Validator extension (`level.py`) + tests: cross-field rules that only make sense once all
hazards exist — fire enabled but no sink (phase 10 task 01) plus: spill seeds plus obstacles
may not block the only path to the sink; `max_fires` ≤ combustible tiles; a level with ≥ 2
hazard kinds must have `time_limit ≥ 100` (pressure needs room); `rush` waves must not start
before `t=20 s`.
4. `docs/SIMULATION.md` — "Hazard invariants" section listing exactly the properties the fuzz
asserts (this is the contract future phases must not break).
5. Replay golden: commit `tests/sim/goldens/hazards_3000s.json` (config, seed, hash chain
sample, final hash) produced by a documented `uv run python tools/record_hazard_golden.py`.
## Testing & Quality
- Fuzz + invariant + golden; the suite must run in < 90 s (mark `slow` if it exceeds that, and
still run it in `validate.sh`).
- Coverage: **>90%** on new/modified code; hazard modules ≥ 95%.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_hazard_determinism.py tests/sim/test_hazard_fairness.py -q` → green
- [ ] `uv run python tools/record_hazard_golden.py` regenerates the golden byte-identically (run twice, compare hashes)
- [ ] corrupting one generated hazard config in a scratch copy makes a validator rule fire (demonstrated, then reverted)
- [ ] every invariant in `docs/SIMULATION.md` names the test that enforces it
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,50 @@
# Phase 11 — The ten-level campaign and progression
**Story:** `US-07_campaign_progression`
**Context:** `.agents/PLAN.md` §6 (levels), §12 (escalation axes), `docs/LEVELS.md`
## Objective
Ship the campaign: author levels 3–10 on the escalation axes, make progression (unlock,
best score, next-level flow) real across a session, and prove the curve climbs — every
level measurably harder than the last by the metrics, not by opinion.
## Dependencies
- `10_hazards_and_escalation_mechanics` (complete) — hazards, pressure model, solver/fairness hooks
- `05_anonymous_lobbies_and_room_lifecycle` (complete) — session progression store
- `07_canvas_client_and_hud` (complete) — level picker, result screen `Next level`
## Tasks
1. `01_author_levels_03_to_06.md` — two-room split, first conveyor, tight galley, spill kitchen
2. `02_author_levels_07_to_10.md` — firehouse, rush gauntlet, dark kitchen, the finale
3. `03_escalation_curve_gate.md` — per-level metrics computed and asserted monotonic non-decreasing
4. `04_campaign_flow_and_level_picker.md` — unlock gating, next/retry flow, best scores, host picker UX
5. `05_campaign_playthrough_suite.md` — scripted 10-level playthrough proving unlock order and persistence
## Testing & Quality
- Content: `uv run undercooked-levels validate` clean; manifest count test never hard-codes the
number of levels; every level's `id`, `order_book`, `hazards`, and `stars` documented in
`docs/LEVELS.md` with a doc-coverage test.
- Curve: `tests/sim/test_escalation_curve.py` — for consecutive levels, the composite difficulty
score (defined in the task) must be non-decreasing, with at least 7 of 9 strict increases
across the campaign; solver completion time must rise or the star target must rise.
- E2E: full campaign playthrough (fast-forced via the test clock), unlock gating enforced
server-side (a client cannot start a locked level), best score round-trip.
- Coverage: **>90%** on new/modified code.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks complete `docs/LEVELS.md` (authoring guide + per-level sheet), `README.md` campaign
list, `docs/DESIGN.md` difficulty model.
## Completion Criteria
- [ ] 10 shipped levels validate clean, and `tests/` still contains no hard-coded level list
- [ ] `uv run pytest tests/sim/test_escalation_curve.py -q` → green (non-decreasing composite, ≥ 7 strict increases)
- [ ] `uv run pytest tests/e2e/test_us07_campaign_progression.py -q` → green (locked level refused server-side)
- [ ] a scripted session completes levels 1→10 in order with best scores persisted and displayed
- [ ] `docs/LEVELS.md` documents every level and every field (doc-coverage test green)
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,47 @@
# Task 01 — Author levels 3–6
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
## Objective
Build the middle of the curve: four levels that each introduce exactly one new pressure and
teach it before testing it.
## Work
1. Author `levels/03_two_rooms.yaml` … `levels/06_spill_kitchen.yaml` to the schema, each with
`id`, `name`, `blurb`, `difficulty`, `time_limit`, `target_orders`, `order_book`,
`stations`, `hazards`, `stars`, `spawn_points`, `grid`:
- **03 Two Rooms** — kitchen split by a wall with two doorways; one side holds the chop +
pot, the other the stove + serve window; teaches route cost. Hazards: none. Difficulty 3.
- **04 Conveyor** — first `mode: conveyor` counter row (deterministic 1-tile-per-
`conveyor_period` shift, wraps at the end into a `drop_slot` that returns items to the
spawn shelf): teaches timing your grab. Difficulty 4.
- **05 Galley** — 14 × 6 corridor map, 2 spawn points, tight reach overlaps, no hazards but
`max_orders: 5` and a 2-recipe book; teaches contested space (the phase-09 arbitration
shows up here naturally). Difficulty 5.
- **06 Spill Kitchen** — `hazards.spills.enabled` with 3 `seed_cells`, `every_n_th_entry: 1`,
one sink shared by both halves; mop-or-lose tension. Difficulty 6.
2. Each level gets a fixture test in `tests/sim/test_level_playability.py` (parametrized by the
manifest, never by a hard-coded list): a greedy scripted bot reaches `target_orders` within
`time_limit` for 1–4 player counts, the star thresholds are strictly ordered
(`1 < 2 < 3` and 3-star requires beating the target by the documented margin), and the
reachable-set assertions from phase 02 hold.
3. `docs/LEVELS.md` — one sheet per level: intent, the one new pressure, layout ASCII map, the
metric snapshot from task 03 (filled by task 03, placeholder until then), and authoring notes.
4. Balance pass: create `tools/curve.py` now (stdlib + the sim package only) — it loads the
manifest, runs the greedy bot per player count, and prints the per-level metric table
defined in task 03; task 03 adds the monotonicity gate on top. Adjust `time_limit`,
`target_orders`, and `order_book` weights until the composite score is non-decreasing for
1→6, and record the numbers in the phase report.
## Testing & Quality
- Sim playability fixtures per level + validator; no hard-coded level lists anywhere.
- Coverage: **>90%** on any changed sim/validator code.
## Completion Criteria
- [ ] `uv run undercooked-levels validate` → clean with 6 levels
- [ ] `uv run pytest tests/sim/test_level_playability.py -q` → green for 1, 2, and 4 players on every shipped level
- [ ] each new level introduces exactly one new pressure (stated in `docs/LEVELS.md` and checkable from the YAML's diff vs the previous level)
- [ ] composite difficulty is non-decreasing from level 1 through 6 (numbers in the phase report)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,42 @@
# Task 02 — Author levels 7–10
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
## Objective
The back half: layered hazards and pressure, ending on a level that uses everything the
campaign taught.
## Work
1. Author `levels/07_firehouse.yaml` … `levels/10_finale.yaml`:
- **07 Firehouse** — high-burn recipes (`burn_ticks` tightened), 2 stoves, sink central,
`hazards.fire.enabled` with `max_fires: 3`; teaches extinguish-vs-cook triage. Difficulty 7.
- **08 Rush Gauntlet** — `hazards.rush.waves` (3 waves at 40 s / 95 s / 140 s,
`order_time_mult` 0.8 / 0.65 / 0.5, labels `LUNCH`, `RUSH`, `LAST CALL`), no physical
hazards; pure time pressure. Difficulty 7.
- **09 Obstacle Course** — 2 conveyor rows + 1 `pingpong` obstacle crossing the only bridge;
`hazards.spills` disabled to keep one variable legible. Difficulty 8.
- **10 Finale** — two rooms + conveyor + fire + a 2-wave rush + `dark_kitchen` modifier for
the final 30 s; 3 recipes; `time_limit: 200`; the everything-at-once test. Difficulty 10.
2. Same playability fixtures as task 01 (the parametrized file picks them up automatically) —
if the greedy bot cannot finish a level, that is a level bug (fix the level), unless the bot
genuinely lacks the mechanics (then say so in the report and add the missing bot behaviour to
`tests/sim/bots.py`, which is allowed).
3. `dark_kitchen` modifier: server effect is already defined in phase 10 task 04 — verify the
client overlay (`render/veil.js`) is driven **only** by the modifier flag in state (no
position hiding on the server; this stays a presentation effect, documented).
4. `docs/LEVELS.md` sheets for 7–10, plus an "everything the campaign teaches" table mapping
each mechanic to the level that introduces it and the level that tests it under pressure.
## Testing & Quality
- Sim playability + validator + curve numbers.
- Coverage: **>90%**.
- Full suite green.
## Completion Criteria
- [ ] `uv run undercooked-levels validate` → clean with 10 levels
- [ ] `uv run pytest tests/sim/test_level_playability.py -q` → green on all 10 levels, 1/2/4 players
- [ ] level 10 is completable by the bot with all four hazard systems active (asserted)
- [ ] `docs/LEVELS.md` has the mechanic-introduction table complete for all 10 levels
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,45 @@
# Task 03 — Escalation curve gate
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
## Objective
Turn "harder" into a number, and make CI fail if the campaign stops climbing.
## Work
1. Define the composite difficulty score in `tools/curve.py` as an explicit, documented formula
over measurable axes (no vibes):
- `throughput_demand = target_orders / time_limit` (normalized),
- `route_cost` = mean bot path length per served order (cells),
- `contention` = `contest_losses + lock_waits` per 100 ticks from the bot run,
- `pipeline_depth` = mean recipe steps across the `order_book`,
- `hazard_load` = weighted count of active hazard systems × their intensity fields,
- `dirty_pressure` = sink round-trips per served order,
- `score = Σ w_i · axis_i` with the weights declared once in `tools/curve.py` (and mirrored in
`docs/DESIGN.md`), so a weight change is a deliberate, reviewable edit.
2. `tests/sim/test_escalation_curve.py`:
- loads the manifest, runs the bot at 2 players for every level, computes the axes,
asserts the composite is non-decreasing across `difficulty` order, with ≥ 7 of 9 strict
increases (equal scores allowed only for adjacent levels explicitly annotated
`difficulty_tie: <reason>` in the YAML — the annotation is validated too),
- asserts `stars` thresholds are strictly increasing per level and that 3 stars requires
beating `target_orders` by ≥ the documented margin,
- prints a table on failure (so a balance regression names the offending axis).
3. `tools/curve.py --report` writes the current table to `docs/LEVELS.md`'s metrics section
between `<!-- curve:begin -->` / `<!-- curve:end -->` markers; `tests/unit/test_docs_index.py`
extended to assert the markers exist and the table is not stale (regenerating produces
identical bytes).
4. Fix any level the gate flags by editing YAML, not the weights (weights move only with a
documented rationale in `docs/DESIGN.md`).
## Testing & Quality
- The gate itself is the deliverable; runtime < 120 s (mark `slow`, run in `validate.sh`).
- Coverage: **>90%** on `tools/curve.py`.
## Completion Criteria
- [ ] `uv run pytest -m slow tests/sim/test_escalation_curve.py -q` → green
- [ ] temporarily editing one level's `time_limit` to flatten the curve makes the test fail and names the axis (demonstrated, then reverted)
- [ ] `uv run python tools/curve.py --report` is idempotent (byte-identical on a second run)
- [ ] weights and axes documented in `docs/DESIGN.md` with the rationale for each
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,46 @@
# Task 04 — Campaign flow, unlock gating, and the level picker
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
## Objective
Make progression real and tamper-proof: the server decides what is unlocked, the picker shows
it, and finishing a level offers the right next step.
## Work
1. Server (`rooms/session.py`, `server/rooms.py`):
- `unlocked_levels(session_id)` derived from `best_scores` (a level unlocks when the previous
one is completed at ≥ 1 star) with `levels/campaign.yaml` order as the source of truth;
- `set_level` **and** `start` reject a level the starting player's session has not unlocked
→ `err(level_locked)` with the payload naming the highest unlocked id; spectating/host
changes never bypass it, and a room whose members have different progress uses the **host's**
progress (documented, and shown in the picker as "host progress");
- `/api/session` gains `unlocked: [...]`, `next_level`, `campaign_complete: bool`;
- `result` payload gains `unlocked_next` (`id | null`) and `campaign_complete`.
2. Client:
- level picker: locked levels visible-but-dimmed with the unlock condition as a tooltip,
best score + stars per level, `difficulty` pips, and the `blurb`; keyboard navigable,
`aria-disabled` rather than hidden;
- result screen: `Next level` only when `unlocked_next`, focus moves to the primary action,
`Retry` keeps the room and level, `Back to lobby` returns to `LOBBY`, and a
`campaign_complete` celebration panel (non-blocking, dismissible, announced);
- "host progress" badge plus a note when your own progress differs.
3. `tests/server/test_unlock_gating.py` — unlock derivation, `set_level`/`start` rejection with
the exact error code, host-progress rule, `campaign_complete`, a spoofed `set_level` for a
locked id over a real socket refused.
4. `tests/e2e/test_us07_picker.py` — locked level not startable (control disabled + server
refusal if forced), best score/stars render, `Next level` appears only when unlocked,
keyboard-only navigation reaches every control, screen-reader text present for locked state.
## Testing & Quality
- Server + e2e; the spoofed-locked-level test is the security-relevant one.
- Coverage: **>90%** on new/modified code.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/server/test_unlock_gating.py tests/e2e/test_us07_picker.py -q` → green
- [ ] forcing a locked `set_level` over the wire is refused with `level_locked` (asserted)
- [ ] `Next level` / `Retry` / `Back to lobby` behave correctly and are keyboard reachable
- [ ] campaign completion state is visible and announced once
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,48 @@
# Task 05 — Full campaign playthrough suite
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
## Objective
One suite that plays the whole campaign start to finish and proves the progression story holds
end to end, in a browser, with real sockets.
## Work
1. `tests/e2e/test_us07_campaign_progression.py` (marked `slow`):
- a single browser context + session token plays levels 1→10 via the bot driver
(`tests/e2e/bot_driver.py`: reuses the sim bot, replaying its input intents through the
real input channel rather than poking the server), finishing each level, then clicking
`Next level`;
- asserts after every level: result screen shows stars and best score, the next level became
unlocked (picker state + `/api/session`), `unlocked_next` matches, and the session record
advanced;
- asserts a retry after a "lost" round (forced by a test-only `?fail=1` flag that makes the
bot idle) does not lose progress and does not re-unlock anything;
- asserts mid-campaign disconnect (socket kill between levels) preserves progress on rejoin;
- asserts a second browser context with a fresh token sees level 1 only, and that the two
sessions do not leak into each other;
- finishes the campaign and asserts the completion panel + `campaign_complete` in
`/api/session`.
2. Runtime discipline: the suite uses the test clock (`UNDERCOOKED_TIME_SCALE=8`, honored only
when `UNDERCOOKED_TEST_MODE=1`) so 10 levels run in < 4 minutes; if `TIME_SCALE` does not
exist yet, implement it as a room-level tick-source change (the sim's `STEP_DT` never
changes — only how fast ticks are pulled), documented in `docs/SIMULATION.md`, with unit
tests that a scaled round produces identical state to an unscaled one.
3. `tests/server/test_time_scale.py` — scaling changes wall-clock pacing only: same input
script, same tick count, same final hash, half the wall time (± tolerance).
4. `tests/e2e/README.md` — how to run just the campaign suite, and how to shorten it
(`-k "level_0"` for the first three).
## Testing & Quality
- E2E `slow` campaign suite + time-scale unit/server tests; the whole thing must be deterministic
(fixed seeds, no wall-clock assertions tighter than 3× tolerance).
- Coverage: **>90%** on new/modified code.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest -m slow tests/e2e/test_us07_campaign_progression.py -q` → green in < 6 minutes
- [ ] `uv run pytest tests/server/test_time_scale.py -q` → green with identical final state hash scaled vs unscaled
- [ ] progress survives a between-levels socket kill (asserted)
- [ ] a fresh session sees only level 1 (asserted), and sessions are isolated
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,48 @@
# Phase 12 — Resilience, garbage collection, and scale
**Story:** `US-09_disconnect_and_rejoin`, `US-01_create_anonymous_lobby`
**Context:** `.agents/PLAN.md` §10 (performance budgets), §8 (lifecycle), A6
## Objective
Make the service boringly reliable: rooms that always empty, memory that always comes back,
rejoins that work under churn, and measured capacity for 200 concurrent rooms with hard
limits that fail safe instead of degrading everyone.
## Dependencies
- `11_campaign_levels_and_progression` (complete) — realistic room load (10 levels, hazards) to
scale against
- `06_realtime_websocket_protocol` (complete) — heartbeats, seat grace, socket-kill seam
## Tasks
1. `01_room_garbage_collection.md` — every room-releasing path, idle GC, leak test that memory returns to baseline
2. `02_rejoin_chaos_matrix.md` — disconnect/rejoin/restart churn under load with invariant checks
3. `03_tick_scheduler_under_load.md` — one scheduler for all rooms, adaptive coalescing, per-room fairness
4. `04_capacity_and_degradation.md` — 200-room capacity test, admission control, graceful "full" behavior
5. `05_ops_observability_and_runbook.md` — `/api/health` deep metrics, structured logs, `docs/DEPLOYMENT.md` runbook
## Testing & Quality
- Integration + perf (`slow`): leak test (200 create/play/close cycles → RSS within 5 % of
baseline), churn matrix, 200-room capacity with p95 tick budget respected, admission control
returning the documented `503`/`room_full` instead of queuing.
- Invariants under every stress: no orphaned tasks (`asyncio.all_tasks` bounded), no room whose
`last_activity` exceeds the GC window, no session record for a closed room beyond the TTL, zero
unhandled exceptions in the server log.
- Coverage: **>90%** on new/modified code.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks finish `docs/DEPLOYMENT.md` (capacity numbers, limits, runbook: "rooms not emptying",
"tick lag", "memory climb"), and README's "what this demo is / is not" section.
## Completion Criteria
- [ ] `uv run pytest -m slow tests/integration/test_capacity.py -q` → 200 rooms, p95 tick cost ≤ 2 ms, memory within budget
- [ ] `uv run pytest tests/integration/test_room_gc.py -q` → green: 200 create/close cycles return RSS to within 5 % of baseline
- [ ] `uv run pytest -m slow tests/integration/test_rejoin_chaos.py -q` → green, zero invariant violations
- [ ] admission control returns the documented response at the cap (asserted), never a silent queue
- [ ] `docs/DEPLOYMENT.md` runbook covers the three named failure modes with the actual commands
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,47 @@
# Task 01 — Room garbage collection and leak proof
**Phase:** `12_reconnect_resilience_and_scale` · **Story:** `US-09_disconnect_and_rejoin`
## Objective
Every code path that could keep a room alive must be enumerated, tested, and provably
non-leaking.
## Work
1. `src/undercooked/server/gc.py`:
- single owner of reclamation: `RoomReaper` with rules — empty room past
`empty_grace_s`, all guests disconnected past `abandon_s`, round finished past
`result_hold_s` with no activity, lobby idle past `lobby_idle_s`, session records past
`session_ttl_s` with no connected seat;
- every release path funnels through `release(room_code, reason)` with a `reason` enum
(`host_leave`, `all_empty`, `idle`, `finished`, `evicted`, `shutdown`) — no ad-hoc
`del`/dict pops anywhere else (enforced by a grep test);
- cancellation is total: room task, tick task, per-connection send tasks, heartbeat timers;
`release` awaits them with a timeout and logs (counted in `/api/health`) any that had to be
abandoned;
- `sweep_once(now)` returns the swept codes + reasons; runs on a timer **and** is callable
from tests with a `FakeClock`.
2. `tests/integration/test_room_gc.py`:
- one test per release reason, each asserting: room gone from the registry, no tasks left,
connections closed with the right code, session record TTL'd, and `sweep_once` is idempotent
(a second sweep sweeps nothing);
- the leak cycle: 200 create → start → play 5 s → close cycles; assert RSS within 5 % of
baseline after forced `gc.collect()` (measured with `tracemalloc` top-3 report printed on
failure), and `asyncio.all_tasks()` count back to the baseline;
- a room with one connected spectator is never swept while the spectator watches.
3. `tests/unit/test_release_paths_exclusive.py` — static guard: no `rooms._rooms.pop` outside
`gc.py` (AST scan of `src/undercooked/server/`), so future code cannot leak by side door.
4. `/api/health` gains `rooms`, `seats`, `spectators`, `tasks`, `rss_mb`, `sweeps_total` broken
out by reason.
## Testing & Quality
- Integration + unit (AST guard); tracemalloc-backed leak assertions.
- Coverage: **>90%** on `gc.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_room_gc.py tests/unit/test_release_paths_exclusive.py -q` → green
- [ ] 200 create/close cycles leave RSS within 5 % of baseline (asserted, with a tracemalloc report on failure)
- [ ] planting a `rooms._rooms.pop` outside `gc.py` fails the AST guard (demonstrated, then reverted)
- [ ] every sweep reason is covered by exactly one named test
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,44 @@
# Task 02 — Rejoin chaos matrix
**Phase:** `12_reconnect_resilience_and_scale` · **Story:** `US-09_disconnect_and_rejoin`
## Objective
Throw connection churn at real rooms and prove nothing corrupts: no double seats, no lost
items, no score jumps, no stuck countdowns.
## Work
1. `tests/integration/test_rejoin_chaos.py` (`slow`):
- matrix over `{players: 2, 4} × {level: 1, 7, 10} × {churn: abrupt_close, offline_event,
server_side_kill, token_reuse_storm, join_during_countdown, rejoin_after_grace}`;
- each scenario: 30 random churn events during a live round (seeded), then assert the
invariants below;
- invariants after every scenario: exactly one seat per token, no seat with two connections,
item conservation (`counted + wasted + lost == produced`), score monotonic non-decreasing,
`round` payload produced exactly once per round, no room stuck in `COUNTDOWN` past
`countdown_s + 2`, ghost states converge (`GHOST_ON` ⇒ eventually `GHOST_OFF` or seat
released), zero unhandled exceptions in the captured server log;
- a `token_reuse_storm` (same token connecting 10× concurrently) must end with exactly one
live connection for that seat and the others closed with `4404` or `1000` (documented).
2. Fix the failures it finds in `server/room.py` / `server/ws.py`; each fix gets a focused
regression test in `tests/integration/test_ws_disconnect.py` (extend) with a name that states
the race.
3. `docs/SIMULATION.md` / `docs/PROTOCOL.md` — document the concurrency rules that make this
work (single-writer room task, inputs applied only inside the tick, no awaits inside the sim
step) so future code does not break them.
4. `/api/health` gains `rejoins_total`, `ghost_seats`, `seat_conflicts_total` (the last must stay
0 — asserted in the chaos suite).
## Testing & Quality
- Integration stress (`slow`), seeded and reproducible (failure prints the seed and a
reproduction command).
- Coverage: **>90%** on new/modified code.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest -m slow tests/integration/test_rejoin_chaos.py -q` → green for all 12 scenarios
- [ ] `seat_conflicts_total == 0` across the matrix (asserted)
- [ ] item conservation and score monotonicity hold in every scenario (asserted)
- [ ] every fix has a named regression test; the report lists the races found
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,45 @@
# Task 03 — One scheduler for all rooms
**Phase:** `12_reconnect_resilience_and_scale` · **Story:** `US-01_create_anonymous_lobby`
## Objective
Replace per-room tick loops with a single fair scheduler so 200 rooms cost ~200× the sim and
not 200× the event-loop overhead.
## Work
1. `src/undercooked/server/scheduler.py`:
- one `RoomScheduler` task owning all room tick timers; rooms register/unregister;
- fixed 20 Hz budget loop: each cycle computes the due rooms, runs their `tick_once` in
registration order (deterministic, no starvation), and yields between rooms every
`YIELD_EVERY_ROOMS = 16` to keep the event loop responsive;
- adaptive degradation ladder (documented + metrics): if the cycle overruns
`budget_ms`, degrade in order — drop snapshot rate for distant spectators → halve tick rate
for rooms with all-seats-idle → refuse new rooms (task 04's admission control) — never
drop a room's inputs, and always emit a `DEGRADED` metric with the ladder step;
- per-room fairness counter (`starved_cycles`) exposed in `/api/health`; must stay 0 in the
capacity test.
2. Remove per-room tick tasks from `server/room.py` (keep the room's single-writer queue
semantics: inputs still applied only inside `tick_once`).
3. `tests/integration/test_scheduler.py`:
- 50 rooms tick at the correct rate (measured ticks/second per room within 2 %),
- a deliberately slow room (test-only injected sleep) does not starve others
(`starved_cycles == 0` for the other 49),
- degradation ladder engages and reports the right step, then recovers when the load drops,
- scheduler shutdown cancels cleanly (no pending tasks), and a room unregistering mid-cycle
is safe (no "task was destroyed", no tick on a released room).
4. `docs/DEPLOYMENT.md` — the ladder, what each step means for players, and the metric to watch.
## Testing & Quality
- Integration with a controllable clock and injected slow rooms.
- Coverage: **>90%** on `scheduler.py` (target 95%).
- Full suite green (the phase-06 two-client round suite must pass unchanged — it is the real
behavior guard).
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_scheduler.py -q` → green
- [ ] 50 rooms hold 20 Hz within 2 % (asserted)
- [ ] one slow room does not starve the others and the ladder reports the correct step (asserted)
- [ ] `uv run pytest tests/integration/test_ws_round_two_clients.py -q` → still green
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,50 @@
# Task 04 — Capacity, admission control, and fail-safe limits
**Phase:** `12_reconnect_resilience_and_scale` · **Story:** `US-01_create_anonymous_lobby`
## Objective
Know the ceiling, enforce it politely, and prove the service degrades for nobody rather than
for everybody.
## Work
1. `src/undercooked/server/admission.py`:
- limits from config: `max_rooms`, `max_connections_total`, `max_connections_per_ip`,
`max_rooms_per_ip`, `max_starting_per_second`;
- `POST /api/rooms` returns `503` + `Retry-After` + JSON `{code: "server_full"}` when at
capacity (documented in `docs/PROTOCOL.md`), `/ws` closes with `4403` (new close code) for
the same condition, and the client shows "the kitchen is full, try again in a moment"
(retry affordance, code preserved) instead of spinning;
- never queue: refuse immediately (a queue would create the timeout UX we are avoiding);
- `/api/health` reports `capacity: {rooms, connections, limit, degraded}`;
`ErrorCode` gains `server_full` and close code `4403` is added, so bump
`PROTOCOL_VERSION`, update `docs/PROTOCOL.md`, and let
`tests/unit/test_client_enum_parity.py` force the JS side to follow (AGENTS §5).
2. `tests/integration/test_capacity.py` (`slow`):
- 200 rooms × 4 seats created and started (bots idle at the counter), measured over 60 s:
p95 `tick_once` cost ≤ 2 ms, mean ≤ 1 ms, RSS ≤ `max_memory_mb`, snapshot downlink per
connection ≤ 60 KB/s, `starved_cycles == 0`, zero errors in the log;
- 201st room → `503`/`server_full` and the 200 existing rooms keep their budgets (measured
before/after, no regression > 5 %);
- per-IP limits enforced with a rotated-source simulation (fixture patches the ASGI
`client` scope), and `max_rooms_per_ip` prevents one browser from hoarding codes;
- memory after the test returns to baseline (uses task 01's reaper).
3. Client: `screens/error.js` handles `server_full` and close `4403` with a countdown retry and
the join code preserved; `docs/CLIENT.md` documents the codes.
4. `docs/DEPLOYMENT.md` — measured capacity table (rooms, seats, RSS, CPU), the limits and how
to raise them, and the "one process, no horizontal scaling in v1" statement with the reason
(in-memory rooms, A6) and what v2 would need (consistent hashing / sticky routing — explicitly
out of scope).
## Testing & Quality
- Integration perf (`slow`) with hard numeric gates; the suite is the release capacity proof.
- Coverage: **>90%** on `admission.py`.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest -m slow tests/integration/test_capacity.py -q` → green with all budgets met
- [ ] the 201st create request returns `503 server_full` and existing rooms are unaffected (asserted)
- [ ] `/ws` at capacity closes `4403` and the client shows the retry copy (e2e-asserted)
- [ ] `docs/DEPLOYMENT.md` records the measured numbers with the date and machine profile
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,50 @@
# Task 05 — Observability and the operations runbook
**Phase:** `12_reconnect_resilience_and_scale` · **Story:** `US-09_disconnect_and_rejoin`
## Objective
Make the running service diagnosable from the outside, with logs that never leak anything
personal, and write the runbook someone else would follow at 2 a.m.
## Work
1. `src/undercooked/server/observability.py`:
- structured JSON logs (stdlib `logging` + a JSON formatter): `ts`, `level`, `event`,
`room` (short code), `seat`, `tick`, `dur_ms`, `code` — no names, no tokens, no IPs in
info logs (IP only at `debug`, and `debug` is off by default);
- counters/histograms in-process (`metrics.py`): rooms live, seats, rejoins, ghosts, sweeps
by reason, denies by reason, tick cost histogram, snapshot bytes, ws closes by code,
admission rejections, degradation steps;
- `GET /api/health` (public, counts only, no room codes) and `GET /api/metrics`
(text/plain, Prometheus-style exposition) gated by `metrics_enabled` **and**
`metrics_bind_private` (default: only served when the request comes from a private/
loopback client — the reverse proxy is the intended consumer), documented in
`docs/PROTOCOL.md`.
2. Log-leak guard `tests/unit/test_no_pii_in_logs.py`: run a scripted round with capability-tagged
fake secrets (`SECRET_TOKEN_marker`, `SECRET_NAME_marker`) as tokens/names, capture logs, and
fail if any `SECRET_*_marker` appears in INFO/WARNING output; also assert room codes *are*
present (they are the support handle) so the guard is not trivially satisfied.
3. `tests/server/test_metrics.py` — exposition format parses, the documented metric names exist,
private-network gate allows loopback and refuses a public client IP, `metrics_enabled=false`
yields `404`.
4. `docs/DEPLOYMENT.md` — full runbook: build, run, TLS via the deployment repo's certbot/nginx
pattern (WebSocket `proxy_http_version 1.1` + `Upgrade` headers spelled out), systemd unit,
log rotation, the three failure modes ("rooms not emptying", "tick lag", "memory climb") with
the exact `/api/health` + `/api/metrics` fields to read, and the restart-is-wipes-everything
caveat (A6) stated plainly.
5. `README.md` — "Running this for real" section pointing at `docs/DEPLOYMENT.md`, plus an honest
"what this is / is not" paragraph (single process, in-memory, demo-grade persistence,
production-grade transport).
## Testing & Quality
- Unit (log leak, formatter) + server (metrics, gating).
- Coverage: **>90%** on `observability.py`/`metrics.py`.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_no_pii_in_logs.py tests/server/test_metrics.py -q` → green
- [ ] planting a token in an INFO log makes the guard fail (demonstrated, then reverted)
- [ ] `/api/metrics` is 404 for a public client IP and 200 for loopback (asserted)
- [ ] `docs/DEPLOYMENT.md` nginx block actually proxies a WebSocket (verified once against a local nginx config or stated as unverified in the report)
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,50 @@
# Phase 13 — Endless mode and session scoreboard
**Story:** `US-10_endless_mode_and_scoreboard`
**Context:** `.agents/PLAN.md` §6 (endless), A6 (in-memory only — no persistence anywhere)
## Objective
Add the mode that never ends and the board that makes it worth retrying: procedurally
escalating endless rounds from the shipped level vocabulary, and an in-memory session
scoreboard — explicitly volatile, honestly labelled as such in the UI.
## Dependencies
- `11_campaign_levels_and_progression` (complete) — 10 authored levels, curve tooling, bots
- `12_reconnect_resilience_and_scale` (complete) — GC and capacity so endless rooms cannot grow
without bound
## Tasks
1. `01_endless_generator.md` — deterministic level remixer: seeded mutations of authored maps within the validator's rules
2. `02_endless_round_loop.md` — escalating waves with no round end, failure/streak rules, per-wave events
3. `03_session_scoreboard_store.md` — in-memory top scores keyed by session/room with TTL and hard caps
4. `04_scoreboard_ui_and_honesty.md` — board UI, "stored in memory only" labelling, personal-best comparison
5. `05_endless_e2e_and_bounds.md` — e2e story suite plus the hard-bounds tests (memory, time, entries)
## Testing & Quality
- Unit (sim): generator determinism, generated maps pass every phase-02/10 validator rule
(property test over 500 seeds), escalation monotonicity, no unwinnable configuration.
- Server: scoreboard caps, TTL, ordering rules, tie-breaks, and a restart-clears-it test.
- E2E (`test_us10_endless_mode_and_scoreboard.py`): start endless, survive waves, die/restart,
see yourself on the board, and see the volatility notice.
- Hard bounds: generated level count, scoreboard entries, and per-room endless memory growth are
capped and asserted (a 30-minute accelerated endless round must not grow RSS beyond a stated
per-room budget).
- Coverage: **>90%** on new/modified code.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks update `docs/LEVELS.md` (generator vocabulary), `docs/SIMULATION.md` (endless rules),
`docs/PROTOCOL.md` (new messages/codes), `README.md` (endless mode + the persistence caveat).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_endless_generator.py -q` → 500 seeds validate, deterministic, monotonic escalation
- [ ] `uv run pytest tests/e2e/test_us10_endless_mode_and_scoreboard.py -q` → green
- [ ] an accelerated 30-minute endless round stays within the stated per-room memory budget (asserted)
- [ ] the UI states plainly that scores are in-memory and vanish on restart (e2e-asserted text)
- [ ] restarting the server clears the board (asserted) and nothing is written to disk
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,49 @@
# Task 01 — Endless level generator
**Phase:** `13_endless_mode_and_scoreboard` · **Story:** `US-10_endless_mode_and_scoreboard`
## Objective
Produce infinite kitchen layouts from the authored vocabulary — deterministic per seed, always
valid, never unwinnable.
## Work
1. `src/undercooked/sim/endless/gen.py`:
- input: a base level id + `seed` + `wave_index`; output: a `Level` object (not YAML — built
in memory, validated by the same `level.validate()` code path as authored levels);
- mutations (each parameterized, each individually testable): `rotate_map`, `mirror_x`,
`relocate_station(kind, count_delta)`, `resize(width, height)`, `spawn_order_book(weights,
depth)`, `hazard_intensity(scale)`, `conveyor_repath`, `obstacle_repath`;
- invariants the generator must guarantee *before* returning (belt-and-braces with the
validator): reachable serve window from every spawn, ≥ 1 sink and ≥ 1 chop station, no
isolated region, obstacles never sever the map (phase-10 rule), `spawn_points` on free
floor, station counts within `[min,max]` per kind;
- determinism: pure function of `(base_level, seed, wave_index)` — same inputs, byte-identical
level (test hashes the serialized level).
2. `src/undercooked/sim/endless/escalate.py` — the wave curve: `wave_params(wave_index)` returns
`{order_time_mult, spawn_interval, max_orders, hazard_scale, recipes_allowed}` with an
explicit asymptote (it approaches but never exceeds the clamp from phase 10), so difficulty
cannot run away into impossibility.
3. `tests/sim/test_endless_generator.py`:
- 500 seeds × 3 base levels × waves 0/5/15: every generated level passes `level.validate()`;
- determinism (hash equality across two generator instances and two processes — use a
subprocess with the same seed);
- monotonic escalation (`wave_params` composite non-decreasing, clamped at the asymptote);
- solvability: greedy bot reaches `target_orders` for waves 0, 3, 8 and *attempts* waves 12+
without crashing or hanging (assert the bot makes progress ≥ N serves, not a win);
- mutation coverage: every mutation function exercised by at least one seed (assert the
coverage set, so a mutation that never fires cannot hide).
4. `docs/LEVELS.md` — the generator vocabulary table, invariants, and "why generated levels go
through the same validator as authored ones".
## Testing & Quality
- Property-style seeded tests + subprocess determinism check; runtime < 120 s (`slow` if longer).
- Coverage: **>90%** on `endless/` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_endless_generator.py -q` → green (500 seeds, 3 bases)
- [ ] same seed in a separate process yields the identical level hash (asserted)
- [ ] every mutation function is proven to fire at least once (asserted coverage set)
- [ ] generated levels cannot exceed the difficulty clamp (asserted at wave 50)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,47 @@
# Task 02 — Endless round loop
**Phase:** `13_endless_mode_and_scoreboard` · **Story:** `US-10_endless_mode_and_scoreboard`
## Objective
Run the mode that has no round end: waves regenerate, mistakes accumulate, and the room stays
bounded and honest.
## Work
1. `src/undercooked/server/endless.py` + `sim/endless/loop.py`:
- room mode `ENDLESS`: no `time_limit`; wave N runs until `orders_served >= wave_target(N)` or
`misses >= wave_miss_limit(N)`; on wave clear the generator produces wave N+1's level and
the room transitions in place (players keep positions when the tile is still floor,
otherwise relocate to the nearest free spawn — carried items are preserved or counted in
`lost_items`, never deleted);
- failure rule: `strikes` accumulate across waves (`max_strikes`, level-config); at max, the
run ends with `reason: "strikes"`; a clean wave clear refunds one strike (documented);
- scoring: cumulative score + per-wave bonus, streak rules unchanged, `endless_result`
payload `{waves, score, best_wave, longest_streak, duration_s, seed, base_level}`;
- bounding: only the current and previous wave's level objects are retained; order history is
a ring buffer of 64 entries; events older than the client TTL are dropped server-side too
(`event_history_limit`);
- protocol: `start{mode: "endless", base_level, seed?}` (new optional fields ⇒ bump
`PROTOCOL_VERSION`, update `docs/PROTOCOL.md`), `ev WAVE_START/WAVE_CLEAR/STRIKE/RUN_END`.
2. `tests/sim/test_endless_loop.py` — wave transitions (position carry-over, item preservation),
strike accounting and refund, run end at max strikes, ring-buffer caps, retention (only two
levels alive), determinism for a seeded 20-wave run, and the invariant "no wave is generated
while a tick is mid-flight".
3. `tests/server/test_endless_room.py` — `start` mode routing, spectator behaviour, host-only
restart, room GC while endless (an idle endless room is swept like any other — an endless
mode must never defeat the reaper).
4. `docs/SIMULATION.md` — endless rules (waves, strikes, refunds, caps).
## Testing & Quality
- Sim + server tests; the GC interaction is a required test, not an assumption.
- Coverage: **>90%** on `endless/`.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_endless_loop.py tests/server/test_endless_room.py -q` → green
- [ ] a 20-wave seeded run is deterministic and retains ≤ 2 level objects (asserted)
- [ ] strikes refund on a clean clear and end the run at max (asserted)
- [ ] an idle endless room is garbage collected (asserted)
- [ ] `PROTOCOL_VERSION` bump + doc-sync test green
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,44 @@
# Task 03 — In-memory scoreboard store
**Phase:** `13_endless_mode_and_scoreboard` · **Story:** `US-10_endless_mode_and_scoreboard`
## Objective
A scoreboard that is fast, bounded, honestly volatile, and impossible to poison.
## Work
1. `src/undercooked/server/scores.py`:
- `ScoreStore` (no disk, no env, no external service): `add(entry)`, `top(scope, limit)`,
`around(scope, session_id)`, `prune(now)`;
- scopes: `global` (whole process) and `room` (per code); entry
`{id, scope, session_id, name, score, waves, streak, mode, base_level, seed, ts}`;
- caps: `max_entries_global` (default 500), `max_entries_room` (default 50),
`score_ttl_s` (default 3 600); insertion beyond the cap evicts the lowest score (ties by
oldest `ts`), and an entry is only accepted if it came from a room that actually reported a
finished run for that `session_id` (the room is the authority — the client never posts
scores over HTTP; assert no unauthenticated write path exists);
- dedupe: one best entry per `(session_id, mode, base_level)`; a better score replaces it and
returns `improved: true`;
- name handling: the display name is sanitized at write time with the lobby's sanitizer and
length-capped; `session_id` is the token hash, never the raw token.
2. `GET /api/scores?scope=&room=&limit=` (public read, `limit ≤ 50`, no session ids in the
response — only a `you: true` marker computed from the caller's token) documented in
`docs/PROTOCOL.md`.
3. `tests/server/test_scores.py` — ordering, tie-breaks, caps + eviction, TTL pruning, dedupe
replacement, `around()` windowing, authority (a forged score with an unknown session/room is
refused), privacy (response contains no session ids/tokens, sanitized names), and
`prune()` idempotence.
4. `tests/unit/test_scores_no_disk.py` — AST/`monkeypatch` guard: importing and exercising
`scores.py` performs zero filesystem writes (patch `open`/`pathlib.Path.write_text` to raise).
## Testing & Quality
- Server + a hard "no disk writes" guard (A6 is a promise, so it is tested).
- Coverage: **>90%** on `scores.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/server/test_scores.py tests/unit/test_scores_no_disk.py -q` → green
- [ ] a forged score submission for an unknown room/session is refused (asserted)
- [ ] the API response contains no raw tokens or session ids (asserted)
- [ ] caps and TTL pruning verified with a fake clock
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,43 @@
# Task 04 — Scoreboard UI and volatility honesty
**Phase:** `13_endless_mode_and_scoreboard` · **Story:** `US-10_endless_mode_and_scoreboard`, `US-12_play_accessibly`
## Objective
Show the board — and never let a player believe their score is saved somewhere it isn't.
## Work
1. `app/static/js/ui/scoreboard.js` + `screens/scoreboard.js`:
- two tabs (`This room`, `All time` — the latter's tooltip says "this server process only"), sorted
list with rank, name, score, waves, streak, mode badge, `you` highlight, personal-best row
pinned on top when you are not in the list;
- live refresh during a room (≤ every 5 s, only while the tab is visible, paused when hidden),
`aria-live="polite"` on rank changes only;
- honesty, required and prominent: a persistent caption "Scores live in this server's memory.
They are gone when it restarts." on the board and a shorter form (`In memory only`) beside
the tab control; an empty state that says the same thing;
- endless HUD additions: current wave, strikes (as pips with text), best wave this session.
2. Endless flow UI: *Endless* entry on the lobby level picker (base level = currently selected),
run-end panel with waves/strikes/duration/seed (seed shown so a run is reproducible — links to
`?seed=`), and `Play again` preserving base level.
3. `tests/e2e/test_us10_scoreboard_ui.py`:
- finish an endless run → your row appears with your name and `you` marker;
- the volatility caption is present on the board, in the empty state, and on the run-end panel;
- board refreshes without a page reload when another context sets a better score;
- tab hidden stops polling (asserted from request counts);
- keyboard navigation + `aria` roles on the tabs and list; 44 px targets on mobile context.
4. `docs/CLIENT.md` — scoreboard module, refresh policy, honesty-copy rules (exact strings, so a
later phase cannot quietly drop them).
## Testing & Quality
- E2E with two contexts + request-count assertions; exact-string tests for the honesty copy.
- Coverage: **>90%** on any server change.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us10_scoreboard_ui.py -q` → green
- [ ] the volatility notice is asserted by exact text in three places (board, empty state, run end)
- [ ] polling stops when the tab is hidden (asserted from request counts)
- [ ] the run-end panel shows the seed and `Play again` reproduces the same base level
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,41 @@
# Task 05 — Endless bounds and the story suite
**Phase:** `13_endless_mode_and_scoreboard` · **Story:** `US-10_endless_mode_and_scoreboard`
## Objective
Prove endless mode cannot grow without bound, and ship its user-story e2e suite.
## Work
1. `tests/e2e/test_us10_endless_mode_and_scoreboard.py` (the story suite):
- start endless from the lobby on base level 3 with a fixed seed; two contexts;
- survive waves 1–3 (bot-driven), observe `WAVE_START`/`WAVE_CLEAR` banners on both clients,
wave counter and strike pips in the HUD;
- take strikes to the max (idle the bots) ⇒ run-end panel with waves/score/streak/duration/
seed, `Play again` reproduces the same base level and a new seed;
- both clients see the identical `endless_result` payload;
- the board shows the run for both contexts, with the honesty caption;
- rejoin mid-endless (socket kill) restores wave, strikes, and score within 3 s.
2. `tests/integration/test_endless_bounds.py` (`slow`):
- accelerated 30-minute-equivalent run (test clock, 200 waves) — assert per-room RSS growth
≤ the documented budget (e.g. 4 MB), level objects retained ≤ 2, order ring buffer ≤ 64,
event log bounded, scoreboard entries ≤ cap, and the room is collected after everyone leaves;
- no unbounded structure: a `tracemalloc` snapshot diff between wave 10 and wave 200 printed on
failure, with the top-3 growth sites;
- determinism at scale: the same seed yields the same hash samples at waves 10/50/200.
3. `docs/DEPLOYMENT.md` — the endless-mode budget numbers and the metric to watch
(`rooms_endless`, `rss_mb` trend); `BUDGETS.md` gains the endless rows with measured values.
4. README: endless mode section (what it is, the volatility caveat, how to reproduce a run).
## Testing & Quality
- E2E story suite + bounded-stress integration (`slow`).
- Coverage: **>90%** on new/modified code.
- Full suite green — including every previous phase's suite.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us10_endless_mode_and_scoreboard.py -q` → green
- [ ] `uv run pytest -m slow tests/integration/test_endless_bounds.py -q` → green within the stated budgets
- [ ] 200-wave run retains ≤ 2 levels and ≤ 64 order entries (asserted)
- [ ] `BUDGETS.md` has dated endless rows
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,52 @@
# Phase 14 — Accessibility, input parity, and release hardening
**Story:** `US-11_play_on_phone`, `US-12_play_accessibly`
**Context:** `.agents/PLAN.md` §4 (UI/UX rules), §9 (security/anonymity), §10 (budgets)
## Objective
The last mile: every user story demonstrably satisfied, WCAG 2.1 AA verified mechanically,
every input path equivalent, the security guardrails re-checked against reality, and the docs
telling the truth about what ships.
## Dependencies
- `13_endless_mode_and_scoreboard` (complete) — the full feature set is in place
- all prior phases complete
## Tasks
1. `01_accessibility_gate.md` — axe-powered e2e gate on every screen, contrast/colorblind/motion audits, keyboard-only and focus-order passes
2. `02_canvas_alternative_and_redundancy.md` — text/table mode for the kitchen state, live regions, screen-reader narrative of the game state
3. `03_input_parity_and_localization_hooks.md` — keyboard/touch/gamepad-free parity, pointer fallback, all strings in one catalog with no hard-coded English in logic
4. `04_security_and_abuse_review.md` — re-run the audit against the finished app: headers, WS auth, rate limits, DoS surfaces, anonymity claims vs reality
5. `05_release_hardening_and_docs.md` — production profile, CSP tightening, perf re-measure, README/docs truth pass, demo-vs-production statement
## Testing & Quality
- E2E accessibility gate (axe-core vendored locally — **no CDN**, loaded from
`app/static/vendor/axe/` with its version pinned and recorded) failing on any
critical/serious violation on every screen, including lobby, countdown, play, result, scoreboard,
and each error screen.
- Redundancy: every game state readable without the canvas (text mode), every hazard without
color/motion (already asserted in phase 10 — re-asserted here across the full campaign).
- Security review re-runs phase 06's abuse suite plus new surfaces (scoreboard, endless start,
metrics endpoint, reconnect storms) with an OWASP-shaped checklist written into the report.
- Budgets re-measured and compared to `BUDGETS.md`; any regression > 10 % must be fixed, not
documented away.
- Coverage: **>90%** project-wide (the full-suite gate is re-run with `--cov-fail-under` set to
the project's real number in `pyproject.toml`).
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**. This is the release-truth pass: every claim in README is
verified against the running app before it stays in the file.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_accessibility_gate.py -q` → green, zero critical/serious axe violations on all screens
- [ ] every screen is completable with keyboard alone and with touch alone (asserted parity matrix)
- [ ] the canvas has a text alternative that conveys orders, score, hazards, and positions (screen-reader test)
- [ ] phase-06 abuse suite plus the new-surface tests pass with no new findings open
- [ ] `BUDGETS.md` re-measured with dated values, no regression > 10 %
- [ ] every README claim verified against a running instance (the report lists how each was checked)
- [ ] full test suite **100% passing**, coverage >90% project-wide
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,47 @@
# Task 01 — Accessibility gate on every screen
**Phase:** `14_accessibility_polish_and_hardening` · **Story:** `US-12_play_accessibly`
## Objective
Make WCAG 2.1 AA a build gate rather than an intention, on every screen the app has.
## Work
1. Vendor axe-core for **tests only**: download the pinned release once into
`tests/e2e/vendor/axe.min.js` (version + SHA-256 recorded in `tests/e2e/vendor/VERSIONS.json`),
injected with `page.add_script_tag(path=...)`. It is never served from `app/static/` and never
shipped — the app's bundle stays dependency-free and the no-CDN rule stays intact.
2. `tests/e2e/test_accessibility_gate.py`:
- a `check_a11y(page)` helper run on every screen state: landing, lobby (1/2/4 seats, host and
guest views), countdown, play, round-end (win/lose), scoreboard (populated/empty), and each
error screen (`room_full`, `room_gone`, `level_locked`, `server_full`, `rate_limited`,
`protocol_mismatch`, `evicted`);
- fails on any `critical` or `serious` violation; the report prints the rule id, node selector,
and help URL — with an explicit, reviewed `allowed` list (empty by default; every entry must
carry a comment explaining why it is a false positive);
- contrast audits run at 1.0 and 1.25 zoom and in the colorblind palette;
- `prefers-reduced-motion` and `forced-colors: active` emulations re-run the scan.
3. Fix every violation found (this is the bulk of the task): focus order on the lobby and result
screens, labelled controls, `aria-live` regions, target sizes, disabled-vs-`aria-disabled`
(locked levels must be reachable and explained, not removed), heading order on the landing page,
`alt`/`aria-label` on the atlas-derived icons, and skip-link to the game controls.
4. `tests/e2e/test_keyboard_only_matrix.py` — every flow completed with `keyboard` only
(no mouse): create lobby, join, ready, start, cook+serve one dish, reach the scoreboard, leave;
plus the same flows on the touch context with pointer input only (parity matrix: each cell
asserts the flow completed).
5. `docs/ACCESSIBILITY.md` — what is guaranteed, how it is tested, known limitations stated plainly
(e.g. the canvas is a visual medium; the text mode from task 02 is the alternative), and add it
to PLAN §13's documentation index.
## Testing & Quality
- E2E gate across ~12 screen states × 3 rendering contexts; the keyboard/touch parity matrix.
- Coverage: **>90%** on any server change.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_accessibility_gate.py -q` → green with an empty `allowed` list (or a justified one)
- [ ] `uv run pytest tests/e2e/test_keyboard_only_matrix.py -q` → green (every cell completed)
- [ ] axe version + hash recorded and the file is not present under `app/static/` (asserted)
- [ ] `docs/ACCESSIBILITY.md` exists, is listed in PLAN §13, and states the limitations honestly
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,48 @@
# Task 02 — Text mode: the kitchen without pixels
**Phase:** `14_accessibility_polish_and_hardening` · **Story:** `US-12_play_accessibly`
## Objective
Give the game a non-visual channel: the same state, readable and listenable, at the same
latency budget as the canvas.
## Work
1. `app/static/js/ui/text_mode.js` (toggled by `?text=1`, the in-game `T` key, and
`prefers-reduced-motion` + a screen-reader hint):
- a live text/table rendering of the state: `orders` (each with its recipe chain, remaining
time as seconds **and** a bar of block characters, points), `you` (position, facing, carried
item, station in reach and its state), `others` (same, condensed), `hazards` (kind + cell +
stage), `score`/`target`/`timer`/`streak`/`strikes`, `dirty` count, connection status;
- DOM `role="table"` with proper headers, `aria-live="polite"` on the order list and score,
`aria-live="assertive"` only for run-ending events; announcements are de-duplicated and
rate-limited (≤ 1 per 900 ms per region) so a busy kitchen never floods a screen reader;
- the text mode is a *consumer of the same snapshot buffer* — it must not open a second socket
or recompute rules; a test asserts both modes derive from one buffer instance;
- canvas hidden (not removed) in text mode so switching back is instant; both modes can be on
at once for side-by-side debugging.
2. Announcement vocabulary in the string catalog (task 03) with deterministic phrasing, e.g.
`Order 2: soup — need tomato (chopped), onion (chopped) — 14 seconds — 30 points`.
3. `tests/e2e/test_us12_text_mode.py`:
- text mode reflects the live state within 200 ms (a scripted cook makes the strings change);
- order/score/hazard strings match the authoritative snapshot exactly (compare against the
server's test-seam state, not another client-side guess);
- announcement rate limit holds under an event storm (count DOM live-region updates);
- the same flows (create/join/ready/start/serve/result/scoreboard) are fully usable in text
mode with keyboard only;
- single-buffer assertion (two modes, one socket).
4. `docs/ACCESSIBILITY.md` — text-mode contract, phrasing examples, and the rate-limit rule.
## Testing & Quality
- E2E comparing rendered text against the server's authoritative state (the strongest available
check).
- Coverage: **>90%** on any server change (none expected beyond test seams).
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us12_text_mode.py -q` → green
- [ ] text-mode strings match the server snapshot for orders, score, hazards, and carried items (asserted)
- [ ] live-region updates stay ≤ the rate limit during an event storm (asserted)
- [ ] one socket serves both the canvas and text mode (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,48 @@
# Task 03 — Input parity, pointer fallback, and a string catalog
**Phase:** `14_accessibility_polish_and_hardening` · **Story:** `US-12_play_accessibly`, `US-11_play_on_phone`
## Objective
No input path is a second-class citizen, and no user-facing string is buried in logic.
## Work
1. `app/static/js/input/index.js` — one input abstraction: every producer (keyboard, touch stick,
pointer drag, `?text=1` command bar) emits the same `{intent, action}` into the phase-08 input
channel; `getCapabilities()` reports what is available; parity matrix enforced by tests:
keyboard ↔ touch ↔ pointer produce identical server-observed movement for the same scripted
commands.
2. Pointer fallback for unusual devices: mouse-drag on the play surface steers the cook (same dead
zone/rim as touch), click = action, with a one-time hint; works on desktop without a keyboard
and on a touch device with a stylus; `pointercancel`/`blur` release exactly like touch.
3. Accessibility input options (persisted in `sessionStorage`, exposed in a settings panel):
remappable keys (with conflict detection and a reset), sticky/hold toggle for the action,
input repeat rate, dead-zone size, and a "slow motion" practice flag that is **client-side
interpolation only** (it never changes the server tick rate — asserted, because it must not be a
cheat in a shared room).
4. `app/static/js/i18n.js` + `app/static/strings/en.json` — every user-facing string (HUD, screens,
error copy, text-mode phrasing, announcements) in one catalog with named placeholders; en is the
only locale shipped, but: no English literals in logic modules (guard test scans
`app/static/js/**` for user-visible sentence-shaped string literals outside the catalog —
`console.*` and internal identifiers excluded), and `/api/meta` exposes
`{languages: ["en"], default: "en"}` so the client never guesses.
5. `tests/e2e/test_us12_input_parity.py` — the parity matrix (3 input methods × move/action),
remapping applies immediately and persists for the session, conflict detection refuses a
duplicate binding, slow-motion is client-only (server tick rate and final state unchanged for
the same input script), pointer fallback works with the keyboard absent.
6. `tests/unit/test_string_catalog.py` — catalog parses, every key referenced by JS exists
(static scan), no unused keys, en.json has no unreplaced placeholders, and `docs/CLIENT.md`
documents the "add a string here, not in code" rule.
## Testing & Quality
- E2E parity matrix + catalog completeness (static scan + runtime reference check).
- Coverage: **>90%** on any server change.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us12_input_parity.py tests/unit/test_string_catalog.py -q` → green
- [ ] keyboard, touch, and pointer produce the same server-observed movement (asserted parity)
- [ ] slow motion does not change the server's tick count or final state for a fixed input script (asserted)
- [ ] the catalog scan fails when a user-visible string is hard-coded in a logic module (demonstrated, then reverted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -0,0 +1,55 @@
# Task 04 — Security and abuse review of the finished app
**Phase:** `14_accessibility_polish_and_hardening` · **Story:** `US-12_play_accessibly` (trust), all stories
## Objective
Re-examine the whole shipped surface against the OWASP-shaped questions that matter for a
WebSocket game, and close what is open.
## Work
1. `docs/SECURITY.md` — the review itself, with a finding table (`id`, `area`, `severity`,
`status`, `evidence test`), and the explicit threat model: anonymous public lobbies, hostile
clients, hostile players (griefing, spam, DoS), no accounts, no persisted personal data.
2. Verify each item with a test that fails if the property regresses (extend
`tests/integration/test_ws_abuse.py`, add `tests/integration/test_http_abuse.py`):
- **Transport/headers**: CSP `default-src 'self'` + `ws:`/`wss:` same-origin, `X-Content-Type-Options`,
`Referrer-Policy: no-referrer`, `Permissions-Policy`, HSTS when behind TLS, no `Server` version
leak, no directory traversal in the static mount (encoded `..` attempts), no symlinked asset
served, `Cache-Control` correctness (`no-store` on `/api/*` and `/ws`, fingerprinted static
cacheable);
- **Auth/authz**: room code is the only capability and is 5-char Crockford with ≥ 60 bits of
entropy claim verified by counting the code space; token guessing rate-limited; host-only
actions refused for guests (each whitelisted action tested as a guest); spectator escalation
refused; `set_level`/`start`/`restart`/`endless` host-gated; scoreboard writes require a real
finished run (phase 13's authority rule re-asserted);
- **Injection/XSS**: hostile display names (`<script>`, `"`, RTL override, zero-width, emoji,
200 chars, control chars, ANSI) sanitized and never rendered via `innerHTML` (guard test scans
the client for `innerHTML`/`outerHTML`/`document.write` with non-literal arguments); names in
logs and metrics are sanitized; no HTML-in-JSON reflected unsanitized into DOM text nodes;
- **DoS**: message size/rate caps (re-verify), code-space enumeration throttled with a growing
penalty, room creation per IP capped, `/api/levels` and `/api/scores` cheap and cacheable, no
unbounded loops over player input inside `step()`, generated endless levels bounded regardless
of requested `wave_index` (request wave 10⁹ and assert a clamped, cheap result);
- **Anonymity claims**: no cookies set unless strictly necessary and then `SameSite=Strict`,
`Secure`, no tracking pixels, no fonts/images from anywhere but origin (re-run the phase-01
guard across the final asset tree including the vendor dir rule), no IP persisted in any
in-memory structure beyond the sliding rate-limit window (documented, TTL-verified).
3. Fix everything rated `medium` or higher; `low` items either fixed or recorded in
`docs/SECURITY.md` with the reason and a non-goal statement. No finding is left unlabeled.
4. `tests/unit/test_security_docs_sync.py` — every finding row in `docs/SECURITY.md` names a test
file that exists; every `open` finding is `low` or the build fails.
## Testing & Quality
- Integration + unit as above; the review is only as real as its evidence tests.
- Coverage: **>90%** on new/modified code.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/integration/test_ws_abuse.py tests/integration/test_http_abuse.py tests/unit/test_security_docs_sync.py -q` → green
- [ ] `docs/SECURITY.md` has a finding per reviewed area with severity, status, and evidence test
- [ ] no `medium`+ finding remains open
- [ ] hostile-name fixtures round-trip sanitized through DOM, logs, metrics, and the scoreboard (asserted)
- [ ] requesting endless wave 10⁹ is clamped and cheap (asserted)
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -0,0 +1,57 @@
# Task 05 — Release hardening, re-measurement, and the docs truth pass
**Phase:** `14_accessibility_polish_and_hardening` · **Story:** all
## Objective
Ship it: production profile, budgets re-measured against the real app, and every document
verified claim by claim against a running instance.
## Work
1. Production profile:
- `config.py` gains `profile: dev | prod` (default from `UNDERCOOKED_PROFILE`); `prod` ⇒
debugpy off, `metrics_enabled` on, `test_mode` refused at startup (boot fails loudly if
`UNDERCOOKED_TEST_MODE=1` with `profile=prod`), `log_level=info`, static caching on, verbose
tracebacks off, `docs/*` routes not required (they stay as static markdown in the repo only);
- `Containerfile` builds the prod profile, runs as a non-root user, healthcheck hits
`/api/health`, `compose.yaml` sets limits (`mem_limit`, `cpus`) consistent with the measured
capacity from phase 12, restart policy, and no host-path mounts for app code;
- `.env.example` regenerated so every documented variable exists in `config.py` and vice versa
(guard test already exists — make it pass with the new keys).
2. Re-measure every budget in `BUDGETS.md` (tick cost, snapshot size, downlink rate, echo, drift,
rejoin, capacity, endless growth) on the full feature set, append a dated table, and fix any
regression > 10 % instead of editing the target.
3. `tools/release_check.py` — one command that runs, in order, and prints a single verdict table:
`uv sync --extra dev`, `ruff`, `pyright`, `undercooked-levels validate`, `pytest` (full, with the
coverage gate), the `slow` perf suites, and a fresh-container smoke (`podman build` +
`podman run` + `curl /api/health` + one Playwright round). `bash .agents/validate.sh` calls it.
4. Docs truth pass — for each document, verify the claim, then fix or delete it:
- `README.md`: quickstart commands run verbatim on a clean clone; the controls list matches
`getKeyMap()`; the campaign list matches the manifest; the "what this is / is not" paragraph
states in-memory persistence, single process, no accounts, and the trademark/homage note;
- `AGENTS.md`: every command, path, and test-location claim matches the tree; guardrails each
name the test that enforces them;
- `docs/`: `PROTOCOL.md` message tables match `protocol.py` field-for-field (existing sync test
extended to cover every message added since phase 06), `SIMULATION.md` rules each cite their
test, `LEVELS.md` covers all 10 levels + generator, `CLIENT.md` module map matches
`app/static/js/**` (new: a test walks the tree and asserts each documented module appears),
`DEPLOYMENT.md` commands work, `ACCESSIBILITY.md`/`SECURITY.md` limitations are current;
- `.agents/PLAN.md`: the phase list matches `.agents/phases/`, the documentation index matches
`docs/`, and any LOCKED decision that was amended has its amendment recorded with the phase
that changed it.
5. `tests/unit/test_docs_module_map.py` — the client module-map and docs-tree guards that make the
truth pass permanent rather than a one-time read-through.
## Testing & Quality
- The whole suite plus `slow` perf suites plus a container smoke; docs guards are real tests.
- Coverage: **>90%** project-wide; `pyproject.toml` coverage floor raised to the measured value.
- Full suite green, from a clean checkout (`uv sync --extra dev` then `bash .agents/validate.sh`).
## Completion Criteria
- [ ] `bash .agents/validate.sh` exits 0 on a fresh clone/container build, including the smoke round
- [ ] `uv run python tools/release_check.py` prints the verdict table with every stage green
- [ ] `BUDGETS.md` has the re-measured dated table, no target silently relaxed
- [ ] `profile=prod` + `UNDERCOOKED_TEST_MODE=1` refuses to boot (asserted)
- [ ] every doc claim verified; the report lists how each was checked and what was fixed
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases (all prior story suites green in the same run)