phase: 02_level_content_and_validation

Phase `02_level_content_and_validation` — final verification **green**.

- **Gate:** `bash .agents/validate.sh` → ruff ✅ · pyright 0 errors ✅ · `undercooked-levels validate` OK (2 levels) ✅ · **668 passed** (incl. 10 Playwright e2e) ✅ · coverage **100%** ✅ · repo hygiene ✅
- **Criteria:** `validate` exit 0, one line per level; `list` = `01_first_kitchen`, `02_double_shift` in campaign order; live `:8000` `/healthz` 200, `/api/levels` (+detail) carries both levels with numeric `target_score`/`time_limit` and no grid/station leakage, `/api/config` correct, unknown id → 404, startup log `content loaded: 2 level(s), 4 recipe(s)`; `validate --dir tests/fixtures/levels/_bad/` → exit 1 with named `RECIPE_STATION_MISSING` / `SERVE_REACHABLE` / `TARGET_IMPLAUSIBLE`; A8 lock test proves a YAML-only level reaches loader+API+CLI.
- **Defect fixed (pre-existing path, phase-02-triggered):** `undercooked` with unusable content printed the report then a raw traceback and exited `1`. `src/undercooked/__main__.py` now catches `ContentError` → one closing line, exit `2` (like a bad setting); 2 new tests in `tests/unit/test_main.py` (incl. non-content failures still propagate).
- **Docs pass (full, project-wide):** README (content gate + exit `2`, config section), AGENTS.md §3, `docs/LEVELS.md` (startup refusal + exact CLI exit codes), `docs/DEPLOYMENT.md` (crash-loop diagnosis bullet); DESIGN/TESTING re-read, consistent.
- **Container re-verified (no phase-01 regression):** `podman build -f Containerfile` succeeds; the image ships `levels/` and serves `/healthz` 200, `/api/levels` (2 levels), `/` 200 as non-root.
- **Deviation to note:** the phase overview names `tests/fixtures/levels/bad/`; the actual broken-fixture dir is `tests/fixtures/levels/_bad/` (the `_` prefix is what keeps it out of normal discovery). Overview files are read-only to me — README/LEVELS.md document the real path.
- No git index/history commands run; all changes left unstaged for the harness. ntfy notification sent.
This commit is contained in:
2026-10-06 11:03:28 -04:00
parent 6fd83446ec
commit 3426264ffb
48 changed files with 8094 additions and 54 deletions
+31 -9
View File
@@ -16,9 +16,10 @@
- Execution is owned by the `phased-execution` skill (`run-task.sh`, `run-phase.sh`, - Execution is owned by the `phased-execution` skill (`run-task.sh`, `run-phase.sh`,
`auto-phase.sh`). Each task runs in a fresh subprocess and must pass `auto-phase.sh`). Each task runs in a fresh subprocess and must pass
`.agents/validate.sh` before it moves to `complete/`. That gate fails fast in this `.agents/validate.sh` before it moves to `complete/`. That gate fails fast in this
order: `ruff check .` → `pyright` → `undercooked-levels validate` (skipped with a order: `ruff check .` → `pyright` → `undercooked-levels validate` (unconditional:
notice until phase 02 creates the loader) → the **full** `pytest` suite with the the same loader + PLAN §8 rules `create_app` runs at startup, over
coverage floor → repo-hygiene checks. It runs from the repo root or from `.agents/`. `$UNDERCOOKED_LEVELS_DIR`) → the **full** `pytest` suite with the coverage floor →
repo-hygiene checks. It runs from the repo root or from `.agents/`.
- New work is authored by the `phase-authoring` skill as a phase directory — not - New work is authored by the `phase-authoring` skill as a phase directory — not
implemented straight from chat. implemented straight from chat.
- **Never** modify `.agents/PLAN.md` or anything under `.agents/phases/complete/`. - **Never** modify `.agents/PLAN.md` or anything under `.agents/phases/complete/`.
@@ -37,6 +38,17 @@
deviation from the default FastAPI stack. deviation from the default FastAPI stack.
- **Data-driven content.** Levels/recipes/hazards come from YAML in `levels/` loaded - **Data-driven content.** Levels/recipes/hazards come from YAML in `levels/` loaded
through validated Pydantic models. No hard-coded level or recipe lists in Python. through validated Pydantic models. No hard-coded level or recipe lists in Python.
`create_app()` loads the bundle once (`routes_content.load_validated_content`) and
**refuses to start** when content fails to load or violates a PLAN §8 rule — the
named-rule report goes to stderr, `ContentError` propagates and the `undercooked`
command exits `2` like it does for an impossible setting. The content API
projects through `extra="forbid"` response models: grids, station ids and spawn
coordinates never leave the server. `undercooked-levels` (`tools/levels.py`, its
author CLI) obeys the same rule: it prints whatever the loaded directory holds.
`tests/unit/test_content_not_hardcoded.py` is the lock — it adds a level YAML to a
copy of `levels/` and expects the loader, `/api/levels` and the CLI to carry it with
no Python edited, and fails if any `src/undercooked/**.py` or browser asset names a
shipped level or recipe id as a literal.
- **Client: Canvas 2D + vanilla ES modules + this repo's own SVG art.** No bundler, - **Client: Canvas 2D + vanilla ES modules + this repo's own SVG art.** No bundler,
no framework, no CDN, no analytics, no external fonts. no framework, no CDN, no analytics, no external fonts.
- **Tests: pytest + Playwright.** One Playwright suite per user story; multi-player - **Tests: pytest + Playwright.** One Playwright suite per user story; multi-player
@@ -52,7 +64,8 @@
- [ ] **Full** project suite passes (`uv run pytest`), coverage gate met — including - [ ] **Full** project suite passes (`uv run pytest`), coverage gate met — including
failures the task did not cause: fix them, do not skip, xfail, or delete tests. failures the task did not cause: fix them, do not skip, xfail, or delete tests.
- [ ] `uv run ruff check .` and `uv run pyright` report zero errors. - [ ] `uv run ruff check .` and `uv run pyright` report zero errors.
- [ ] `uv run undercooked-levels validate` passes if any level/content file changed. - [ ] `uv run undercooked-levels validate` passes — `.agents/validate.sh` runs it for
every task, whether or not any content file changed.
- [ ] `README.md` and `AGENTS.md` updated when the change is user-visible - [ ] `README.md` and `AGENTS.md` updated when the change is user-visible
(setup, commands, config, protocol, features, conventions). Every phase's final (setup, commands, config, protocol, features, conventions). Every phase's final
pass re-reviews both **in full, project-wide** and fixes inconsistencies, pass re-reviews both **in full, project-wide** and fixes inconsistencies,
@@ -115,8 +128,10 @@
- **Protocol changes need a version bump**: editing a wire message means editing - **Protocol changes need a version bump**: editing a wire message means editing
`src/undercooked/server/protocol.py`, `docs/PROTOCOL.md`, and the client in the same task, `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md`, and the client in the same task,
and bumping `PROTOCOL_VERSION` (PLAN §11). and bumping `PROTOCOL_VERSION` (PLAN §11).
- **Determinism is a contract.** `sim/step.py` must produce identical state given the - **Determinism is a contract.** `sim/step.py` (phase 03) must produce identical state
same seed and input script — the replay tests in `tests/sim/` are the guard. given the same seed and input script — the replay tests in `tests/sim/` are the guard
once the tick exists; until then `tests/sim/test_purity.py` holds the A5 boundary that
determinism depends on.
- **ntfy is outbound-only.** Never use it to ask questions or wait for approval. - **ntfy is outbound-only.** Never use it to ask questions or wait for approval.
If a task is ambiguous, make the best judgment consistent with PLAN.md, or stop If a task is ambiguous, make the best judgment consistent with PLAN.md, or stop
and let the user rewrite the task. and let the user rewrite the task.
@@ -134,9 +149,12 @@
| Configuration (env vars → typed `Settings`), debugpy bootstrap | `src/undercooked/config.py`, `.env.example`, `src/undercooked/debug.py` | | Configuration (env vars → typed `Settings`), debugpy bootstrap | `src/undercooked/config.py`, `.env.example`, `src/undercooked/debug.py` |
| Pure game rules (movement, stations, orders, scoring, hazards) | `src/undercooked/sim/` | | Pure game rules (movement, stations, orders, scoring, hazards) | `src/undercooked/sim/` |
| Level/recipe content | `levels/*.yaml` | | Level/recipe content | `levels/*.yaml` |
| Content schema, ASCII-grid parser, content loader, playability validator (contract: `docs/LEVELS.md`) | `src/undercooked/sim/recipe.py`, `level.py`, `content.py`, `validation.py` |
| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py`, `room.py` | | Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py`, `room.py` |
| Wire messages + validation | `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md` | | Wire messages + validation | `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md` |
| HTTP app (`/`, `/healthz`, `/static`), lifespan | `src/undercooked/server/app.py` | | HTTP app (`/`, `/healthz`, `/static`), lifespan, startup content gate | `src/undercooked/server/app.py` |
| Content HTTP API (`/api/levels`, `/api/levels/{id}`, `/api/config`) + strict response models | `src/undercooked/server/routes_content.py` |
| Author CLI `undercooked-levels` (`validate`, `list`, `show`, `new`, `axes`); workflow in `docs/LEVELS.md` | `src/undercooked/tools/levels.py` |
| Security headers (CSP `default-src 'self'`, no-sniff, frame-ancestors) | `src/undercooked/server/headers.py` | | Security headers (CSP `default-src 'self'`, no-sniff, frame-ancestors) | `src/undercooked/server/headers.py` |
| CLI entry point (`undercooked`): flags → `--help` → serve | `src/undercooked/__main__.py` | | CLI entry point (`undercooked`): flags → `--help` → serve | `src/undercooked/__main__.py` |
| Renderer, HUD, input, interpolation | `app/static/js/`, `app/static/assets/` | | Renderer, HUD, input, interpolation | `app/static/js/`, `app/static/assets/` |
@@ -146,8 +164,12 @@
Tests mirror that: `tests/unit/` (pure, no-I/O logic incl. HTTP-route unit tests), Tests mirror that: `tests/unit/` (pure, no-I/O logic incl. HTTP-route unit tests),
`tests/integration/` (in-process app boot, live uvicorn + WebSocket clients), `tests/sim/` `tests/integration/` (in-process app boot, live uvicorn + WebSocket clients), `tests/sim/`
(headless determinism and replays), `tests/e2e/` (Playwright, named `test_usNN_*.py`, (the pure kernel without a server: grid geometry, entity model, the A5 purity guard,
marked `e2e`). `tests/` is not a package; shared fixtures live in `tests/conftest.py`. the level validator — headless determinism and replays join it with the tick in
phase 03), `tests/e2e/` (Playwright, named `test_usNN_*.py`, marked `e2e`). `tests/` is
not a package; shared fixtures live in `tests/conftest.py`, and the autouse fixture there
points `UNDERCOOKED_LEVELS_DIR` at `tests/fixtures/levels/` so no test silently reads the
shipped `levels/`.
- The browser suites use the **`playwright` sync API directly with fixtures written in - The browser suites use the **`playwright` sync API directly with fixtures written in
`tests/e2e/conftest.py`** — `pytest-playwright` is deliberately not a dependency, so `tests/e2e/conftest.py`** — `pytest-playwright` is deliberately not a dependency, so
+86 -28
View File
@@ -10,21 +10,34 @@ email, no password: you create a room, share the 5-character code, play.
## Status ## Status
Phase 01 (runnable service scaffold) is **complete**; the next pending phase is Phases **01** (runnable service scaffold) and **02** (level content, schema and
`02_level_content_and_validation`. What runs today: the Python toolchain is locked validation) are **complete**; the next pending phase is
(`uv.lock`), the pytest harness covers unit, integration and Playwright e2e layers, `03_simulation_movement_and_interaction`. What runs today:
configuration is a single typed, validated `Settings` object (`DEBUGPY` support
included), and `uv run undercooked` starts a FastAPI server that serves the client - **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
shell at `/`, the browser assets from `app/static/`, and `GET /healthz` — which answers single typed, validated `Settings` object (`DEBUGPY` support included), and
`{"status":"ok","version":"0.1.0","rooms":0}` (`rooms` stays `0` until phase 05 owns a `uv run undercooked` starts a FastAPI server that serves the client shell at `/`,
supervisor) — with the security headers (CSP `default-src 'self'`) on every response. the browser assets from `app/static/`, and `GET /healthz` — which answers
The rules are tests and not promises: the no-external-asset rule is `{"status":"ok","version":"0.1.0","rooms":0}` (`rooms` stays `0` until phase 05 owns
`tests/unit/test_no_external_assets.py`, the documentation set is guarded by a supervisor) — with the security headers (CSP `default-src 'self'`) on every
`tests/unit/test_docs_index.py`, `bash .agents/validate.sh` is the gate the phase response.
pipeline runs after every task, and `docs/` holds the DESIGN / TESTING / DEPLOYMENT - **The content.** The recipe book and the first two campaign levels live in
documents. Lobby buttons answer "Not implemented until phase 05.": levels and their `levels/`; `sim/level.py` parses them into typed models, `sim/validation.py` runs
validator land in phase 02, the simulation in 03-04, rooms and the WebSocket protocol PLAN §8's named rules, and `create_app` loads + validates that bundle at startup and
in 05-06, the playable renderer in 07. Execute phases with the `phased-execution` skill **refuses to boot** broken content — the named-rule report goes to stderr and
`undercooked` exits `2` instead of serving a half-working kitchen. `GET /api/levels`,
`GET /api/levels/{id}` and `GET /api/config` answer from it, and
`undercooked-levels` gives authors the same gate from the command line
(`docs/LEVELS.md` is the schema reference).
- **The rules are tests, not promises.** The no-external-asset rule is
`tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is
`tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by
`tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
phase pipeline runs after every task.
What does not exist yet: the simulation (phases 03-04), rooms and the WebSocket
protocol (05-06 — the lobby buttons answer "Not implemented until phase 05.") and the
playable Canvas renderer (07). Execute phases with the `phased-execution` skill
(`auto-phase.sh`); it always takes the lowest-numbered directory still in (`auto-phase.sh`); it always takes the lowest-numbered directory still in
`.agents/phases/todo/`. `.agents/phases/todo/`.
@@ -56,14 +69,16 @@ uv run undercooked # http://127.0.0.1:8000 (shell page + /health
uv run undercooked --host 0.0.0.0 --port 8000 # e.g. to reach it from a phone uv run undercooked --host 0.0.0.0 --port 8000 # e.g. to reach it from a phone
uv run undercooked --reload # dev reload uv run undercooked --reload # dev reload
uv run undercooked --levels-dir levels # point at another content dir uv run undercooked --levels-dir levels # point at another content dir
curl -fsS http://127.0.0.1:8000/api/levels # the campaign cards the lobby will show
curl -fsS http://127.0.0.1:8000/api/config # client-safe settings + protocol version
``` ```
`python -m undercooked` is the same command (the container uses it). A flag `python -m undercooked` is the same command (the container uses it). A flag
always beats the environment, which beats the built-in default. There is no always beats the environment, which beats the built-in default. There is no
`/docs` or `/openapi.json`: FastAPI renders those pages from a public CDN, which `/docs` or `/openapi.json`: FastAPI renders those pages from a public CDN, which
this project forbids. `undercooked-levels validate` (check every level file loads this project forbids. The validation the server runs at startup (every level
and is solvable) is declared in `pyproject.toml` but becomes runnable in phase 02, file loads, and no rule of PLAN §8 is broken) is a command too —
when the content loader lands. `undercooked-levels`, see **Authoring content** below.
Debug the server with a real debugger attached (dev only - the listener binds Debug the server with a real debugger attached (dev only - the listener binds
`0.0.0.0`, so never enable it on a public host): `0.0.0.0`, so never enable it on a public host):
@@ -72,6 +87,27 @@ Debug the server with a real debugger attached (dev only - the listener binds
DEBUGPY=1 uv run undercooked # opens a debugpy listener on :5678, then serves as usual DEBUGPY=1 uv run undercooked # opens a debugpy listener on :5678, then serves as usual
``` ```
Authoring content:
```bash
uv run undercooked-levels list # the campaign, in campaign order
uv run undercooked-levels show 01_first_kitchen --grid # resolved level + reachability overlay
uv run undercooked-levels validate # the content gate; --json for scripts
uv run undercooked-levels new 03_riverboat # scaffold a level from the recipe book
uv run undercooked-levels axes # escalation-axis table (phase 11's curve)
```
`undercooked-levels` is a thin shell over the loader and the PLAN §8 rules
`create_app` runs at startup, so a green `validate` is a server that boots. It
finds content through the settings (`--dir` > `UNDERCOOKED_LEVELS_DIR` >
`levels`, plus `--recipes` > `UNDERCOOKED_RECIPES_PATH` > `<dir>/recipes.yaml`)
and never from the current directory, so it works from any path. Exit codes:
`0` ok, `1` a level breaks a rule, `2` nothing usable to work with (load
failure, unknown id, refused overwrite, usage error). The schema, every field,
the overlay trick and a "which rule did I just hit" table live in
[docs/LEVELS.md](docs/LEVELS.md); `tests/unit/test_content_not_hardcoded.py`
is what keeps levels and recipes out of the Python.
Run the checks: Run the checks:
```bash ```bash
@@ -91,8 +127,9 @@ running first, and no test ever listens on a routable interface.
`bash .agents/validate.sh` is the gate a phase task must pass before it moves to `bash .agents/validate.sh` is the gate a phase task must pass before it moves to
`.agents/phases/complete/`. It stops at the first failure, in this order: `.agents/phases/complete/`. It stops at the first failure, in this order:
`ruff check .`, `pyright`, `undercooked-levels validate` (skipped with a notice until `ruff check .`, `pyright`, `undercooked-levels validate` (unconditional: the same
phase 02 creates the loader), the **full** `pytest` suite with the 90 % coverage floor, loader and PLAN §8 rules `create_app` runs at startup, so a campaign that cannot
validate cannot commit), the **full** `pytest` suite with the 90 % coverage floor,
then repo-hygiene checks (nothing tracked that `.gitignore` excludes, no log drops or then repo-hygiene checks (nothing tracked that `.gitignore` excludes, no log drops or
key material committed). Run it from the repo root or from `.agents/`. key material committed). Run it from the repo root or from `.agents/`.
@@ -113,7 +150,10 @@ nothing else in the codebase touches `os.environ`. Precedence: `undercooked` CLI
flags, then exported environment variables, then a local `.env` (found from the flags, then exported environment variables, then a local `.env` (found from the
current directory upwards; never commit it), then the built-in defaults. A blank value counts as current directory upwards; never commit it), then the built-in defaults. A blank value counts as
unset. Invalid values (`UNDERCOOKED_TICK_HZ=99`) raise `ConfigError` at startup unset. Invalid values (`UNDERCOOKED_TICK_HZ=99`) raise `ConfigError` at startup
instead of half-configuring the server. `.env.example` and the table below list instead of half-configuring the server, and unusable content is refused the same
way — an unreadable `UNDERCOOKED_LEVELS_DIR` or a level that breaks a PLAN §8
rule prints the named-rule report and exits `2` (under `--reload` the reload
child reports and restarts instead; docs/LEVELS.md). `.env.example` and the table below list
every variable with its default; tests keep both in sync with `config.py`. every variable with its default; tests keep both in sync with `config.py`.
| Variable | Default | Meaning | | Variable | Default | Meaning |
@@ -125,6 +165,7 @@ every variable with its default; tests keep both in sync with `config.py`.
| `UNDERCOOKED_MAX_PLAYERS` | `4` | Players per room (1-4) | | `UNDERCOOKED_MAX_PLAYERS` | `4` | Players per room (1-4) |
| `UNDERCOOKED_MAX_ROOMS` | `200` | Concurrent rooms before new rooms are refused | | `UNDERCOOKED_MAX_ROOMS` | `200` | Concurrent rooms before new rooms are refused |
| `UNDERCOOKED_LEVELS_DIR` | `levels` | Directory of level YAML files (`~` is expanded) | | `UNDERCOOKED_LEVELS_DIR` | `levels` | Directory of level YAML files (`~` is expanded) |
| `UNDERCOOKED_RECIPES_PATH` | *(derived)* | Recipe book file; empty = `recipes.yaml` inside `UNDERCOOKED_LEVELS_DIR` (here: `levels/recipes.yaml`) |
| `UNDERCOOKED_STATIC_DIR` | *(derived)* | Directory served at `/static`; empty = `app/static/` beside the source (containers set it) | | `UNDERCOOKED_STATIC_DIR` | *(derived)* | Directory served at `/static`; empty = `app/static/` beside the source (containers set it) |
| `UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S` | `900` | Close empty/idle lobbies | | `UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S` | `900` | Close empty/idle lobbies |
| `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` | `45` | Drop a disconnected player's seat after this | | `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` | `45` | Drop a disconnected player's seat after this |
@@ -142,17 +183,34 @@ src/undercooked/ server: FastAPI app, rooms, WebSocket protocol, simulati
src/undercooked/__main__.py the `undercooked` command (CLI > env > default) src/undercooked/__main__.py the `undercooked` command (CLI > env > default)
src/undercooked/config.py typed env settings (the only `os.environ` reader) src/undercooked/config.py typed env settings (the only `os.environ` reader)
src/undercooked/debug.py DEBUGPY-gated debugger bootstrap src/undercooked/debug.py DEBUGPY-gated debugger bootstrap
src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static` src/undercooked/sim/ pure simulation kernel: entities (`model`), grid geometry
(`grid`), content schema + loader (`recipe`, `level`,
`content` — the YAML contract is docs/LEVELS.md), the
level validator (`validation` — PLAN §8 named rules;
rule table in docs/LEVELS.md), and the
A5 purity guard (`purity`, test-enforced). There is no
`step()` yet: the tick arrives with phase 03
src/undercooked/tools/levels.py the `undercooked-levels` author CLI — validate, list,
show [--grid], new, axes; same loader, same rules, no
hard-coded content list (docs/LEVELS.md "Authoring workflow")
src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`, mounts the content API
src/undercooked/server/routes_content.py `/api/levels`, `/api/levels/{id}`, `/api/config`,
plus the startup gate: broken content refuses startup
src/undercooked/server/headers.py the security-header middleware src/undercooked/server/headers.py the security-header middleware
.env.example every configuration variable, with its default .env.example every configuration variable, with its default
levels/ data-driven level definitions (YAML) + recipes (empty until 02) levels/ data-driven content (YAML): the recipe book (A8) and the
first two campaign levels (01_first_kitchen, 02_double_shift)
app/static/ browser client: the ES-module shell today; Canvas 2D renderer app/static/ browser client: the ES-module shell today; Canvas 2D renderer
and this repo's SVG art arrive with phase 07 and this repo's SVG art arrive with phase 07
tests/ unit/, integration/, sim/, e2e/ (one Playwright suite per story); tests/ unit/, integration/, e2e/ (one Playwright suite per story) and
fixtures/levels/ (test-isolated content dir); conftest.py sim/ (the pure kernel: grid, entities, purity guard, validation);
docs/ DESIGN.md, TESTING.md (how this project is verified), fixtures/levels/ is the content dir tests see (a recipe book and no
DEPLOYMENT.md; PROTOCOL / LEVELS / SIMULATION / CLIENT land with levels; `_bad/` holds the deliberately-broken campaign the `_`
their own phases (the index is PLAN.md §13) discovery rule ignores); conftest.py
docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract),
TESTING.md (how this project is verified), DEPLOYMENT.md;
PROTOCOL / SIMULATION / CLIENT land with their own phases
(the index is PLAN.md §13)
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan .agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
``` ```
+5
View File
@@ -37,6 +37,11 @@ Rules that hold for every deployment of this project:
template; the game has no secrets by design (anonymous, A6/A16). template; the game has no secrets by design (anonymous, A6/A16).
- Everything is in memory: a restart ends every round and erases every scoreboard. That is - Everything is in memory: a restart ends every round and erases every scoreboard. That is
a documented product decision, not an incident. a documented product decision, not an incident.
- **The campaign in the image is checked before the port opens.** `create_app` loads and
validates `UNDERCOOKED_LEVELS_DIR` at startup, so an image built around content that
breaks a PLAN §8 rule exits `2` with the named-rule report on stderr instead of serving
an empty lobby; with `restart: unless-stopped` that looks like a crash loop whose logs
are the diagnosis. `uv run undercooked-levels validate` reproduces it from the checkout.
## What this document will contain when it is filled in ## What this document will contain when it is filled in
+10 -4
View File
@@ -1,6 +1,6 @@
# DESIGN.md — architecture and the decisions behind it # DESIGN.md — architecture and the decisions behind it
**Status:** stub written by phase 01 · **Filled in by phase 09**, when the client, the **Status:** stub written by phase 01, module map refreshed by phase 02 · **Filled in by phase 09**, when the client, the
protocol and the first two-player end-to-end story make the prose verifiable. protocol and the first two-player end-to-end story make the prose verifiable.
## Purpose ## Purpose
@@ -21,15 +21,21 @@ all. Everything around that kernel — `server/` (FastAPI app, WebSocket protoco
supervisor, in-memory session records) and `app/static/` (vanilla ES modules, Canvas 2D) supervisor, in-memory session records) and `app/static/` (vanilla ES modules, Canvas 2D)
— exists only to move bytes between a player's hands and the simulation. — exists only to move bytes between a player's hands and the simulation.
## Module map (as it stands after phase 01) ## Module map (as it stands after phase 02)
| Path | Owns | Filled in by | | Path | Owns | Filled in by |
|---|---|---| |---|---|---|
| `src/undercooked/config.py` | the only read of the environment → typed, validated `Settings` | phase 01 (done) | | `src/undercooked/config.py` | the only read of the environment → typed, validated `Settings` | phase 01 (done) |
| `src/undercooked/debug.py` | `DEBUGPY`-gated debugger bootstrap (A18) | phase 01 (done) | | `src/undercooked/debug.py` | `DEBUGPY`-gated debugger bootstrap (A18) | phase 01 (done) |
| `src/undercooked/server/app.py` | app factory, `/healthz`, `/`, `/static`, startup logging | phase 01 (done) | | `src/undercooked/server/app.py` | app factory, `/healthz`, `/`, `/static`, startup logging, mounts the content router | phase 01 (done) |
| `src/undercooked/server/routes_content.py` | `/api/levels`, `/api/levels/{id}`, `/api/config` behind `extra="forbid"` response models, plus the startup gate that refuses broken content | phase 02 (done) |
| `src/undercooked/server/headers.py` | CSP + the four other security headers on every response | phase 01 (done) | | `src/undercooked/server/headers.py` | CSP + the four other security headers on every response | phase 01 (done) |
| `src/undercooked/sim/` | game truth: model, `step()`, interactions, scoring, hazards | phase 03–04 | | `src/undercooked/sim/model.py`, `grid.py` | the entity vocabulary (`Vec2`, items, stations, players, orders, hazards, `KitchenState`) and tile geometry/walkability | phase 02 (done) |
| `src/undercooked/sim/recipe.py`, `level.py`, `content.py` | the content contract (A8): recipe book, level schema + ASCII legend, discovery and campaign ordering | phase 02 (done); schema reference in `docs/LEVELS.md` |
| `src/undercooked/sim/validation.py` | PLAN §8's named playability rules and the escalation axes | phase 02 (done) |
| `src/undercooked/sim/purity.py` | the A5 guard: no I/O, asyncio, clock or FastAPI inside `sim/` | phase 02 (done) |
| `src/undercooked/tools/levels.py` | the `undercooked-levels` author CLI — a shell over the loader and the rules, never a second implementation | phase 02 (done) |
| `src/undercooked/sim/step.py` | game truth: the tick, interactions, scoring, hazards | phase 03–04 |
| `src/undercooked/server/room.py`, `rooms.py` | one match, its FSM and tick task; the registry that owns all of them | phase 05–06 | | `src/undercooked/server/room.py`, `rooms.py` | one match, its FSM and tick task; the registry that owns all of them | phase 05–06 |
| `src/undercooked/server/protocol.py` | versioned wire messages, whitelists, inbound validation | phase 06 | | `src/undercooked/server/protocol.py` | versioned wire messages, whitelists, inbound validation | phase 06 |
| `src/undercooked/server/sessions.py` | bounded in-memory progression records (A6: no database) | phase 05 | | `src/undercooked/server/sessions.py` | bounded in-memory progression records (A6: no database) | phase 05 |
+499
View File
@@ -0,0 +1,499 @@
# LEVELS.md — level & recipe YAML reference
**Status:** current as of phase 02 (schema, parser, loader, validator, and the
`undercooked-levels` author CLI). Phase 11 extends
this document with the campaign-wide difficulty-curve notes; until then every
field of every content model is documented here, and
`tests/unit/test_levels_doc.py` keeps the two in lockstep.
## Purpose
This document defines the data-driven content contract for Undercooked: the
recipe book, one YAML file per level, the ASCII-grid legend conventions, and
every schema field with its type, default, and meaning. Content authors write
files against this reference and the loader validates them at startup — the
Python source never enumerates levels or recipes, and any document that fails
these rules is a load error naming its file, never a silently-defaulted
guess.
The models live in `src/undercooked/sim/recipe.py` (book, recipes, steps,
ingredients), `src/undercooked/sim/level.py` (levels, tiles, orders, hazards)
and `src/undercooked/sim/content.py` (discovery and loading). All models are
strict (`extra="forbid"`) and revalidate on assignment: an unknown key is an
error, not a typo someone lives with.
## Files on disk
```text
levels/
recipes.yaml <- the recipe book (see "recipes.yaml" below)
01_first_kitchen.yaml <- one level per file
02_double_shift.yaml
_draft.yaml <- ignored: a leading underscore opts a file out
notes.md <- ignored: only .yaml / .yml are content
```
`undercooked.sim.content.load_content(levels_dir, recipes_path)` is the whole
loader. Its discovery rules:
| Rule | Behaviour |
|---|---|
| Level discovery | every `*.yaml` / `*.yml` file under `levels_dir`, at any depth |
| Drafts | files whose name — or any ancestor directory's name — starts with `_` are ignored |
| The recipe book | `recipes.yaml` / `recipes.yml` inside `levels_dir` is never treated as a level; the book path is passed separately |
| Ordering | the campaign order is `(order, id)` — never filename order |
| `by_id` | derived from the ordered tuple; cannot disagree with it |
| Duplicate ids | a load error listing every duplicated id |
| Unknown pool recipes | a level whose `orders.pool` names a recipe the book does not define fails the load |
| Error reporting | every file-level problem is aggregated into one `ContentError` naming each offending file |
Every problem found in one run is aggregated into a single `ContentError` whose
message is one line per offending file — no prefix, because the caller adds
its own (`undercooked-levels` prefixes `undercooked-levels:`, `create_app`
prefixes `undercooked: content failed to load from <dir>:`). It reads like:
```text
undercooked-levels: /…/levels/02_ghost.yaml: orders.pool names unknown recipes: ghost
/…/levels/03_wreck.yaml: <root>: Value error, ragged grid: row 1 is 10 chars, want 9; grid character 'Z' has no tiles: legend entry
```
## recipes.yaml — the recipe book
```yaml
ingredients: [tomato, onion, plate] # optional catalog of the campaign's items
recipes:
soup: # key must equal the recipe's id
id: soup
name: Tomato Soup
base_score: 40
ingredients:
- {kind: tomato, form: raw}
- {kind: onion, form: raw, count: 2}
steps:
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5.0}
```
### RecipeBook
| Field | Type | Default | Meaning |
|---|---|---|---|
| `ingredients` | `list[ItemKind]` | `[]` | Catalog of item kinds the campaign uses. Informational in phase 02; item respawning uses it from phase 03 on. |
| `recipes` | `map[id → Recipe]` | required, non-empty | The recipes. Each key must equal the recipe's own `id`. |
Methods (deterministic, side-effect free):
- `get(recipe_id)` → the `Recipe`; `KeyError` naming all known ids otherwise.
- `ids()` → declaration order, as a tuple.
- `required_station_kinds(recipe_id)` → `frozenset[StationKind]` of every
station the steps name. The `RECIPE_STATION_MISSING` rule consumes this;
nothing hard-codes "soup needs a stove".
- `required_item_kinds(recipe_id)` → every ingredient kind plus `PLATE` when
`requires_plating` is true.
- `min_seconds_to_complete(recipe_id)` → the theoretical minimum for a perfect
cook: the sum of all step `seconds` plus one flat handling allowance
(`HANDLING_S = 0.6` s per recipe, the constant in `sim/recipe.py`).
The `TARGET_IMPLAUSIBLE` rule uses this as the greedy per-order floor of
what a round can score (see "Validation rules").
### Recipe
| Field | Type | Default | Meaning |
|---|---|---|---|
| `id` | non-empty `str` | required | Stable identifier level order pools reference. |
| `name` | non-empty `str` | required | Display name. |
| `base_score` | `int ≥ 0` | required | Points for a perfect delivery before tips/combo (phase 04's quality, streak and time bonuses scale them). |
| `ingredients` | `list[Ingredient]`, ≥ 1 | required | What the dish consumes. |
| `steps` | `list[Step]`, ≥ 1 | required | The ordered procedure. |
| `requires_plating` | `bool` | `true` | Whether a plain dish needs the plating step before serving. |
| `tags` | `list[str]` | `[]` | Free-form labels for scoring/difficulty hints. |
### Ingredient
| Field | Type | Default | Meaning |
|---|---|---|---|
| `kind` | `ItemKind` | required | Which item (enum in `sim/model.py`; YAML uses the lowercase value). |
| `form` | `ItemForm` | required | Form required at the moment of use: `raw`, `chopped`, `cooking`, `cooked`, `burnt`, `plated`, `dirty`. |
| `count` | `int ≥ 1` | `1` | How many units the recipe consumes. |
### Step
| Field | Type | Default | Meaning |
|---|---|---|---|
| `kind` | `pick` \| `chop` \| `cook` \| `wash` \| `plate` \| `serve` | required | The verb. A closed vocabulary — anything else is a load error. Trashing is an interaction, not a recipe step, so it has no verb here. |
| `seconds` | `float ≥ 0` | `0.0` | How long the action takes when performed. `pick`/`serve` are instantaneous (`0.0`). |
| `station` | `StationKind` | required | Where the verb happens (`spawn_shelf`, `chop_board`, `stove`, `prep`, `serve`, `sink`, `trash`). |
## A level file
```yaml
id: 01_first_kitchen # convention: the file's stem (see Working conventions)
name: First Kitchen
order: 1 # campaign position
difficulty: 1 # 1..10; feeds DIFFICULTY_ORDER + TRAVEL_TOO_SHORT, tuned in phase 11
time_limit: 150 # seconds of round time, > 0
target_score: 45 # win threshold
blurb: Two cooks, one pot. # optional flavour text
grid: # ASCII map, all rows equal length
- "#########"
- "#S..C...#"
- "#.......#"
- "#..V....#"
- "#########"
tiles: # every grid char maps to exactly one TileSpec
".": {kind: floor}
"#": {kind: wall}
"S": {kind: floor, station: spawn_shelf, item: tomato}
"C": {kind: floor, station: chop_board}
"V": {kind: floor, station: serve}
spawns: [[1, 1]] # player spawns as [x, y] grid coords
orders:
pool: [soup]
concurrent: 1
spawn_interval: [16, 20]
deadline: 60
hazards: [] # optional, see HazardSpec
station_limits: {plates: 2, pots: 1} # optional, default 99 / 99
```
### LevelDef
| Field | Type | Default | Meaning |
|---|---|---|---|
| `id` | non-empty `str` | required | Unique across the campaign; duplicate ids fail `load_content`. |
| `name` | non-empty `str` | required | Display name. |
| `order` | `int ≥ 1` | required | Campaign position. Levels are sorted by `(order, id)`. |
| `difficulty` | `int` 1–10 | `1` | Author-declared difficulty. Feeds `DIFFICULTY_ORDER` (the campaign may not declare an easier level after a harder one) and switches on `TRAVEL_TOO_SHORT` from 3 upward; phase 11 tunes the curve against it. |
| `time_limit` | `float > 0` | required | Round length in seconds. |
| `target_score` | `int ≥ 1` | required | Score needed to win the round. |
| `grid` | `list[str]`, ≥ 1 row | required | The ASCII map. Every row the same length, at most 64×64 (`MAX_GRID`), every character present in `tiles`. |
| `tiles` | `map[char → TileSpec]`, ≥ 1 | required | The legend. Every legend character must be used by the grid — unused entries are typos. |
| `spawns` | `list[[x, y]]`, ≥ 1 | required | Player spawn coordinates, each inside the grid. |
| `orders` | `OrdersSpec` | required | The order engine configuration for this level. |
| `hazards` | `list[HazardSpec]` | `[]` | Placed and periodic hazards. |
| `station_limits` | `StationLimits` | `99 / 99` | Concurrent capacity of shared station resources. |
| `blurb` | `str` | `""` | Flavour text for the level card. |
| `escalation_notes` | `str` | `""` | Free-form note to the phase 11 difficulty-curve pass. |
Schema-level rules (enforced right here, at load):
- **rectangular grid** — differing row lengths are a load error naming the row;
- **legend completeness both ways** — no grid character without a legend
entry, no legend character the grid never uses;
- **spawn inside the grid**, at least one spawn;
- **exactly one serve legend character** in the tiles map — the validator
rejects levels where the win condition cannot be reached by definition
(the validator's flood-fill rules below go further and reject pockets no
cook can walk into);
- numeric sanity on every counter, interval, and limit listed above.
### TileSpec
| Field | Type | Default | Meaning |
|---|---|---|---|
| `kind` | `TileKind` (`floor` \| `counter` \| `wall` \| `void`) | required | Walkability class; only `floor` is walkable (`WALKABLE` in `sim/model.py`). |
| `station` | `StationKind` or `null` | `null` | The station installed on this tile, if any. |
| `item` | `ItemKind` or `null` | `null` | What this tile restocks — legal **only** on a `spawn_shelf` station tile, and **mandatory** there. |
| `id_prefix` | non-empty `str` or `null` | `null` | Replaces the station-kind stem in generated ids (see "Station ids"). Allowed only on station tiles. |
Stations sit on tiles of any `kind`; the schema allows it so hazards and
decorations can mount stations, but the `STATION_REACHABLE` rule demands a
walkable (`floor`) tile for every station players must touch.
### OrdersSpec
| Field | Type | Default | Meaning |
|---|---|---|---|
| `pool` | `list[str]`, ≥ 1 | required | Recipe ids this level draws orders from. Every entry must exist in the recipe book (`load_content` checks; the validator re-checks hand-built levels). |
| `concurrent` | `int ≥ 1` | required | Maximum simultaneously active orders. |
| `spawn_interval` | `[min, max]` seconds, both > 0 | required | Uniform range the round RNG draws from. `min ≤ max` is validated — reversed intervals are the classic misconfiguration. |
| `deadline` | `float > 0` | required | Seconds an order may live before it burns. |
| `penalty` | `int ≥ 0` | `10` | Points lost when an order burns out. |
### HazardSpec
One shape for placed and periodic hazards. Placement rules by `type`:
| `type` | Placement | Meaning |
|---|---|---|
| `fire` | `station` **or** `x`/`y` (exactly one) | Ignites that station or that cell. |
| `spill` | `station` **or** `x`/`y` (exactly one) | Grease puddle at that location. |
| `moving_obstacle` | neither; `path` + `period_s` instead | Travels the path back and forth on a loop. |
| `rush` | nowhere (level-wide) | A dining-rush surge; needs a positive `intensity`. |
| Field | Type | Default | Meaning |
|---|---|---|---|
| `type` | `HazardKind` (`fire` \| `spill` \| `moving_obstacle` \| `rush`) | required | Which hazard. |
| `station` | `str` or `null` | `null` | Station id to attach to (e.g. `stove_2`). Mutually exclusive with `x`/`y`. |
| `x` | `int ≥ 0` or `null` | `null` | Cell x. Must be given together with `y`. |
| `y` | `int ≥ 0` or `null` | `null` | Cell y. Must be given together with `x`. |
| `chance_per_s` | `float ≥ 0` or `null` | `null` | Periodic re-arm probability per second; `null` = placed once at round start. |
| `respawn_s` | `float > 0` or `null` | `null` | Delay before a cleared hazard can re-arm. |
| `path` | `list[[x, y]]`, ≥ 2 points, or `null` | `null` | Route for `moving_obstacle` only. |
| `period_s` | `float > 0` or `null` | `null` | Loop period for `moving_obstacle`; required when it has a `path`. |
| `intensity` | `float` (must be > 0 when `type: rush`) | `1.0` | Magnitude (`rush` surge strength; severity multiplier elsewhere). |
### StationLimits
| Field | Type | Default | Meaning |
|---|---|---|---|
| `plates` | `int ≥ 0` | `99` | Clean plates available at once. `0` loads and is reported by `STATION_LIMIT_ZERO` — the rule, not a schema error, so the report names it. |
| `pots` | `int ≥ 0` | `99` | Pots the stoves share. |
## Station ids and the built `Level`
`build_grid(spec)` walks the grid in reading order (row by row, left to
right) and assigns each station tile an id: `{id_prefix or station-kind
value}_{n}`, where `n` counts **per station kind** across the whole level.
Two legend characters of the same kind therefore share one counter — a level
with `T` (stove) and `t` (stove, `id_prefix: burner`) produces `stove_0` and
`burner_1`. The same walk yields the `Station` list and `build_grid`'s
`Grid.station_at` (cell index → station id), and `Grid.from_rows` re-checks
geometry one last time so the grid cannot disagree with the spec.
`Level.build(spec)` pairs the validated `LevelDef` with what the parser
produces — `grid` (the `sim/grid.py` `Grid`), `stations`, `spawns` (as
`(x, y)` tuples) and `station_items` (spawn-shelf id → restocked `ItemKind`)
— and delegates `id`, `order`, `difficulty`, `time_limit`, `target_score`,
`orders`, `hazards`, `station_limits`, `blurb` and `escalation_notes` to the
spec, so simulation code reads one object. The `ContentBundle` returned by
`load_content` holds the `recipe_book`, the campaign-ordered `levels`, and
the derived `by_id` map.
## Validation rules
`sim/validation.py` proves a built `Level` is playable before anyone joins it —
the PLAN §8 content gate. `validate_level(level, book, prev=None)` runs the
per-level rules (`prev` is the campaign predecessor and enables the escalation
check); `validate_levels(levels, book)` adds the bundle-wide rules over an
`(order, id)`-ordered list and `validate_bundle(bundle)` runs that over a
loaded bundle. Each hit is a `LevelViolation` naming a stable **rule id** with
a human message carrying coordinates and a machine-readable `detail` mapping.
`ValidationReport.render()` prints a deterministic, ANSI-free,
level-grouped report, and **warnings never fail a level** — `report.ok` reads
violations only.
| Rule | Fires when |
|---|---|
| `GRID_SHAPE` | the built `Grid` disagrees with the spec's ASCII row count or width (hand-built levels; `LevelDef` validation already rejects ragged grids) |
| `SPAWN_ON_FLOOR` | a spawn is not on a walkable floor tile |
| `SPAWN_COUNT` | the level has no player spawns |
| `STATION_REACHABLE` | a station cell cannot be walked to from any spawn |
| `SERVE_REACHABLE` | the same for the serve window specifically — no reachable window means no round can ever be won |
| `SPAWNS_MUTUALLY_REACHABLE` | a spawn cannot be walked to from the first spawn |
| `RECIPE_STATION_MISSING` | a pool recipe needs a station kind the level does not have within the reachable area |
| `RECIPE_INGREDIENT_UNAVAILABLE` | a pool recipe needs a `raw` ingredient no reachable spawn shelf produces (spawn shelves are currently the only item source the content model expresses) |
| `ORDER_POOL_UNKNOWN_RECIPE` | `orders.pool` names a recipe the recipe book does not define |
| `TARGET_IMPLAUSIBLE` | `target_score` exceeds the plausibility bound below — above 45% of the bound it is a **warning**, above 60% an error |
| `TIME_LIMIT_INVALID` | `time_limit` is under 30 s |
| `HAZARD_REF_UNKNOWN` | a hazard references a station id that does not exist |
| `HAZARD_ON_UNREACHABLE_TILE` | a hazard tile or `moving_obstacle` path point is off-grid, not walkable, or cut off from the cooks |
| `STATION_LIMIT_ZERO` | `station_limits.plates` or `.pots` is below 1 |
| `DUPLICATE_ID` | two levels in the bundle share an `id` (bundle rule) |
| `DIFFICULTY_ORDER` | a level raises fewer than two escalation axes over its predecessor, or the campaign's declared `difficulty` drops between consecutive levels (bundle rule) |
| `TRAVEL_TOO_SHORT` | from difficulty 3 up, the mean spawn→serve path is under 3 tiles — sprint-to-serve layouts are not campaigns |
**Target plausibility** (`theoretical_max_score`) is the greedy bound
`floor(time_limit / mean_complete) × mean_order_value` over the level's order
pool, where `mean_complete` is the recipe book's `min_seconds_to_complete()`
average plus a travel bound of `2 × mean spawn→serve tiles ÷ 3.5 tiles/s` —
one walk to the window and one back at max cook speed (PLAN §5). The bound is
deliberately generous: it rejects targets a perfect team could never hit, not
targets a sloppy one misses. Levels with an invalid time limit or no known
pool recipes return no bound — those conditions have their own rules.
**Escalation axes** (`escalation_axes`, every axis oriented *higher = harder*):
| Axis | Measure |
|---|---|
| `recipe_complexity` | number of distinct recipes in the order pool |
| `order_pressure` | `orders.concurrent` ÷ mean spawn interval |
| `time_budget` | `target_score` ÷ `time_limit` |
| `station_scarcity` | 1 ÷ station count |
| `layout_hostility` | blocking tiles ÷ total tiles |
| `hazard_density` | number of hazards |
| `travel_distance` | mean BFS tiles from a spawn to the nearest serve cell |
Reachability is a BFS flood fill over walkable tiles from every spawn.
`moving_obstacle` tiles are deliberately *not* subtracted from the reachable
set — movers move, so only their path points must be valid floor. When the
serve window is unreachable the travel metric falls back to Manhattan
distance so the numbers stay defined; the reachability rules report the break
itself. `undercooked-levels validate` prints this report for the levels
directory and exits `1` on any violation — see **Authoring workflow** below. The
same report stops the game server: `create_app` runs that exact pair at startup,
so `uv run undercooked` prints it, refuses to build an app around it and exits
`2` rather than open a lobby nobody can cook in.
## Working conventions
- `id` should equal the file's stem; the loader keys everything by `id`, and
the bundle rule `DUPLICATE_ID` refuses a bundle that reuses one.
- Two characters may share a `TileSpec` meaning only by writing it twice;
characters are single characters — a multi-character legend key can never
match a grid cell, so the load fails with `legend character 'VV' is never
used by the grid`, while a grid character with no entry at all fails with
`grid character 'Z' has no tiles: legend entry`.
- Spawn shelves are the only item source at schema level; deeper restocking
semantics belong to phase 03's simulation work.
- Everything strict: when a field is added to a model, `docs/LEVELS.md` must
gain a row in the same change — `tests/unit/test_levels_doc.py` fails if
the doc table and `model_fields` ever disagree.
## Authoring workflow
`undercooked-levels` is the author's half of this contract, and a thin shell
over the code above: `validate` is exactly `load_content` + `validate_bundle`
(the same pair `create_app` runs at startup), so green here means a server that
boots there. It ships with the project (`[project.scripts]` in
`pyproject.toml`) and never enumerates content itself — it prints whatever the
directory it was pointed at contains. `tests/unit/test_content_not_hardcoded.py`
holds the CLI, the loader and the content API to that rule.
```bash
uv run undercooked-levels new 03_riverboat # 1. scaffold a level, validate that file
uv run undercooked-levels list # 2. the campaign, in campaign order
uv run undercooked-levels show 03_riverboat --grid # 3. read the resolved level + overlay
uv run undercooked-levels validate # 4. the gate .agents/validate.sh runs
uv run undercooked-levels axes # 5. the escalation table phase 11 tunes
```
Every subcommand takes `--dir PATH` (levels) and `--recipes PATH` (the book),
which beat `UNDERCOOKED_LEVELS_DIR` / `UNDERCOOKED_RECIPES_PATH`, which beat the
defaults `levels` and `<levels>/recipes.yaml`. Path resolution is settings-driven,
never cwd-driven: a *relative* path with nothing under the current directory is
anchored to this checkout, so the commands above work from `/tmp` exactly as
they do from the repo root.
1. **`new ID [--order N]`** writes `<levels>/ID.yaml` — a valid, minimal kitchen
*generated from the recipe book*: one spawn shelf per raw ingredient of the
book's first dish, one station per kind that dish's steps name, and two
spawns on the floor row far enough from the window to clear
`TRAVEL_TOO_SHORT`. `--order` defaults to one past the highest in the
directory; `difficulty` starts one above the campaign's hardest. It refuses
to overwrite an existing level, and prints the single-level validation
report of what it wrote.
2. **Edit** the grid, the tunables and the prose. `id` should equal the file's
stem. Fill `escalation_notes` with the axes you moved: the campaign rule
(`DIFFICULTY_ORDER`) wants at least two axes up versus the level that
precedes yours by `order`, and a `difficulty` that never drops.
3. **`list` and `show ID [--grid]`** read the level back the way the server sees
it — campaign order and resolved geometry, not the text you typed. `show`
prints the tunables, the station and hazard counts, how much of the floor is
reachable, and each pool dish resolved against the book (or
`MISSING from the recipe book`).
4. **`validate`** prints one status line per level and then
`ValidationReport.render()`. Warnings print and stay green — a warning is a
tuning question, a violation is a broken level:
```text
99_broken_room 4 error(s) 11x6, 4 stations
4 error(s), 0 warning(s)
level 99_broken_room
ERROR RECIPE_STATION_MISSING: recipe 'soup' needs a reachable prep station; the level has none
ERROR RECIPE_STATION_MISSING: recipe 'soup' needs a reachable stove station; the level has none
ERROR SERVE_REACHABLE: serve station serve_0 at (8,1) is not reachable from any spawn
ERROR TARGET_IMPLAUSIBLE: target_score 999 is above 60% of the theoretical maximum 160; no one could ever reach it
```
5. **`axes`** prints the seven escalation axes per level in campaign order
(higher = harder), which is how you check a level really got harder instead
of only *feeling* harder:
```text
LEVEL ORDER recipe_complexity order_pressure time_budget station_scarcity layout_hostility hazard_density travel_distance
01_first_kitchen 1 1.0000 0.0556 0.3000 0.1429 0.3519 0.0000 7.5000
02_double_shift 2 2.0000 0.1429 0.6154 0.1250 0.4786 0.0000 5.5000
```
### The overlay debugging trick
`STATION_REACHABLE`, `SERVE_REACHABLE` and `HAZARD_ON_UNREACHABLE_TILE` all
come down to one question: which walkable floor can the cooks actually get to?
`show --grid` re-draws the authored map as terrain so you see the answer
instead of deducing it from a BFS you cannot see:
```bash
uv run undercooked-levels show 99_broken_room --grid --dir tests/fixtures/levels/_bad
```
```text
map (as authored) <- the file, exactly as written
###########
#T..C#..V.#
#....#....#
#.#########
#S........#
###########
reachability . reachable ? walkable but cut off # blocking
###########
#....#????#
#....#????#
#.#########
#.........#
###########
18/26 walkable tiles are reachable from the spawns
```
The `?` band is the sealed servery: walkable floor, and the wall at column 6
cuts it off from the spawn on row 4. Fix the wall, not the target score. Note
that a moving obstacle's `path` tiles stay `.` even when a mover currently
blocks them — movers move, so the validator only insists the path itself is
valid floor.
### Scripting it
`validate --json` is the machine-readable form of the same report — stable key
names, sorted hits, so a diff of two runs is only a diff of real changes:
```bash
uv run undercooked-levels validate --json | python -m json.tool
```
```json
{
"ok": false,
"counts": {"errors": 4, "warnings": 0},
"errors": [
{
"level": "99_broken_room",
"rule": "SERVE_REACHABLE",
"message": "serve station serve_0 at (8,1) is not reachable from any spawn",
"detail": {"station_id": "serve_0", "x": 8, "y": 1}
}
]
}
```
Exit codes are the whole scripting surface: `0` everything is fine, `1` the
content loaded and breaks at least one rule, `2` the tool could not do its job
(content would not load, an unknown level id, a file `new` refuses to overwrite,
or an argparse usage error). `.agents/validate.sh` runs `validate` unconditionally,
so a campaign that cannot validate cannot commit even when no test file changed.
### Which rule did I just hit?
The full semantics are in the **Validation rules** table above; this is the
"what do I type next" view of the same ids.
| Rule | Usually means |
|---|---|
| `GRID_SHAPE` | the row count/width you wrote and the built grid disagree — check for a ragged row or a stray character |
| `SPAWN_ON_FLOOR` | a `spawns:` coordinate lands on wall/counter — move it onto a `.` |
| `SPAWN_COUNT` | `spawns:` is empty — a kitchen nobody stands in |
| `STATION_REACHABLE` | a fixture sits behind a wall — `show --grid` and open a path |
| `SERVE_REACHABLE` | the window is unreachable, so the round cannot be won — same fix, and the level is unwinnable until then |
| `SPAWNS_MUTUALLY_REACHABLE` | the cooks start on islands — connect the spawn areas |
| `RECIPE_STATION_MISSING` | a pool dish needs a station the level lacks (or cannot reach) — add the fixture or trim the pool |
| `RECIPE_INGREDIENT_UNAVAILABLE` | a pool dish eats an item no reachable spawn shelf produces — add a shelf for it |
| `ORDER_POOL_UNKNOWN_RECIPE` | `orders.pool` names a dish `recipes.yaml` does not define — check the spelling |
| `TARGET_IMPLAUSIBLE` | `target_score` is out of reach of a perfect team — lower the target, lengthen `time_limit`, or raise the dish values |
| `TIME_LIMIT_INVALID` | `time_limit` under 30 s — a round that is not a round |
| `HAZARD_REF_UNKNOWN` | a hazard's `station:` id is not in the level — re-read the station ids the loader built |
| `HAZARD_ON_UNREACHABLE_TILE` | a hazard sits in a wall or a sealed pocket — move it where the cooks are |
| `STATION_LIMIT_ZERO` | `station_limits.plates`/`.pots` is 0, so no dish can ever be plated |
| `DUPLICATE_ID` | two files declare the same `id` — one of them is a copy-paste |
| `DIFFICULTY_ORDER` | the level moves fewer than two escalation axes up, or `difficulty` dropped — compare with `axes` |
| `TRAVEL_TOO_SHORT` | from difficulty 3, spawn-to-serve averages under 3 tiles — spread the kitchen out |
+12 -2
View File
@@ -1,6 +1,6 @@
# TESTING.md — how this project is verified # TESTING.md — how this project is verified
**Status:** current as of phase 01 · **Filled in by phase 14**, which re-runs every layer **Status:** current as of phase 02 · **Filled in by phase 14**, which re-runs every layer
below against the finished game and records the release-time suite inventory. below against the finished game and records the release-time suite inventory.
## Purpose ## Purpose
@@ -20,7 +20,7 @@ layer live in `tests/conftest.py`, and layer-specific fixtures live in that laye
| Directory | Contains | I/O allowed | | Directory | Contains | I/O allowed |
|---|---|---| |---|---|---|
| `tests/unit/` | pure logic with no I/O: config parsing, header values, CLI precedence, protocol/validator helpers, and the doc/content guards that keep README, `.env.example`, `PLAN.md` and the code agreeing | none (in-process `TestClient` requests are fine) | | `tests/unit/` | pure logic with no I/O: config parsing, header values, CLI precedence, protocol/validator helpers, and the doc/content guards that keep README, `.env.example`, `PLAN.md` and the code agreeing | none (in-process `TestClient` requests are fine) |
| `tests/sim/` | headless determinism and replay tests against the pure kernel (`tests/sim/test_replay_*.py`, golden state hashes) | none — the sim must stay callable without a server | | `tests/sim/` | tests of the pure kernel that need no server: grid geometry, the entity model, the A5 import-purity guard, and the level validator (phase 02) — plus the headless determinism/replay suites with golden state hashes (`tests/sim/test_replay_*.py`) that arrive with the tick in phase 03 | none — the sim must stay callable without a server |
| `tests/integration/` | multi-module, in-process tests: app boot through `create_app()`, HTTP routes, static serving, room/protocol behaviour with real WebSocket clients (room + protocol integration is PLAN §9 layer 4, and lives here) | in-process app, loopback sockets | | `tests/integration/` | multi-module, in-process tests: app boot through `create_app()`, HTTP routes, static serving, room/protocol behaviour with real WebSocket clients (room + protocol integration is PLAN §9 layer 4, and lives here) | in-process app, loopback sockets |
| `tests/e2e/` | one Playwright suite per user story, named `test_usNN_*.py`; multiplayer stories drive **two browser contexts** against one room | real browser, real socket | | `tests/e2e/` | one Playwright suite per user story, named `test_usNN_*.py`; multiplayer stories drive **two browser contexts** against one room | real browser, real socket |
| `tests/fixtures/` | fixture data only. `tests/fixtures/levels/` is the content dir every test sees — an autouse session fixture points `UNDERCOOKED_LEVELS_DIR` at it so no test can silently read the shipped `levels/` | none | | `tests/fixtures/` | fixture data only. `tests/fixtures/levels/` is the content dir every test sees — an autouse session fixture points `UNDERCOOKED_LEVELS_DIR` at it so no test can silently read the shipped `levels/` | none |
@@ -59,6 +59,16 @@ uv run pyright # types
bash .agents/validate.sh # the pipeline gate, in that order + hygiene bash .agents/validate.sh # the pipeline gate, in that order + hygiene
``` ```
The gate runs one check that is not a pytest command: `uv run undercooked-levels
validate` between pyright and pytest (docs/LEVELS.md **Authoring workflow**). It is
unconditional — a campaign that breaks a PLAN §8 rule stops the pipeline even when no
test file changed — and it is the same `load_content` + `validate_bundle` pair
`create_app()` runs at startup, so it can disagree with the server about nothing.
Content tests stay hermetic the same way: the autouse session fixture points
`UNDERCOOKED_LEVELS_DIR` at `tests/fixtures/levels/` (a recipe book and zero levels),
and a test that wants real levels builds one under `tmp_path` or names `levels/`
explicitly. `tests/unit/test_content_not_hardcoded.py` is the A8 lock on top of that.
Coverage is configured in `pyproject.toml`: `source = ["src/undercooked"]`, branch Coverage is configured in `pyproject.toml`: `source = ["src/undercooked"]`, branch
coverage on, `--cov-fail-under=90` (AGENTS.md A20 asks for > 90 % on new/changed code; the coverage on, `--cov-fail-under=90` (AGENTS.md A20 asks for > 90 % on new/changed code; the
floor is the mechanical half of that). floor is the mechanical half of that).
+45
View File
@@ -0,0 +1,45 @@
# levels/01_first_kitchen.yaml — campaign level 1, the tutorial kitchen.
# Schema: docs/LEVELS.md · validated at startup and by `undercooked-levels
# validate`. Every '#' is wall, every station stands on walkable floor.
id: 01_first_kitchen
name: First Kitchen
order: 1
difficulty: 1
time_limit: 150
target_score: 45
blurb: >-
Your first shift: grab a tomato and an onion from the shelves, chop them,
cook the soup, plate it, and run it to the serve window before the clock
runs out. One dish, one of everything, nowhere to bump into each other.
grid:
- "############"
- "#T..C...S..#"
- "#..........#"
- "#.P......X.#"
- "#..........#"
- "#..K....O..#"
- "#..........#"
- "#..........#"
- "############"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"O": {kind: floor, station: spawn_shelf, item: onion}
"C": {kind: floor, station: chop_board}
"S": {kind: floor, station: stove}
"P": {kind: floor, station: prep}
"X": {kind: floor, station: serve}
"K": {kind: floor, station: sink}
spawns: [[2, 7], [9, 7]]
orders:
pool: [soup]
concurrent: 1
spawn_interval: [16, 20]
deadline: 60
hazards: []
escalation_notes: >-
Tutorial baseline (PLAN §8 campaign plan): one recipe, one order at a time,
slowest spawn interval, longest time budget, open floor, no hazards. Every
later level moves at least two axes up from these numbers.
+49
View File
@@ -0,0 +1,49 @@
# levels/02_double_shift.yaml — campaign level 2: two dishes at once.
# Schema: docs/LEVELS.md · validated at startup and by `undercooked-levels
# validate`. The counter bands pinch the kitchen into three rooms joined by
# one narrow walk-through.
id: 02_double_shift
name: Double Shift
order: 2
difficulty: 2
time_limit: 130
target_score: 80
blurb: >-
Salads join the soup and the orders overlap now. Two counter bands pinch the
floor, so someone is always walking the long way through the gap — plan who
crosses and who stays on their side.
grid:
- "#############"
- "#T....C....O#"
- "#.....S.....#"
- "####=...=####"
- "#....P.X....#"
- "#...........#"
- "####=...=####"
- "#..K.....L..#"
- "#############"
tiles:
"#": {kind: wall}
".": {kind: floor}
"=": {kind: counter}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"O": {kind: floor, station: spawn_shelf, item: onion}
"L": {kind: floor, station: spawn_shelf, item: lettuce}
"C": {kind: floor, station: chop_board}
"S": {kind: floor, station: stove}
"P": {kind: floor, station: prep}
"X": {kind: floor, station: serve}
"K": {kind: floor, station: sink}
spawns: [[2, 2], [10, 5]]
orders:
pool: [soup, salad]
concurrent: 2
spawn_interval: [12, 16]
deadline: 52
hazards: []
escalation_notes: >-
Moves four escalation axes vs 01_first_kitchen (PLAN §8 requires two):
recipe_complexity 1 -> 2 (salad joins), order_pressure 0.056 -> 0.143
(2 concurrent at shorter intervals), time_budget 0.30 -> 0.62 target/s,
and layout_hostility 0.35 -> 0.48 blocking tiles with the counter pinch.
+80
View File
@@ -0,0 +1,80 @@
# levels/recipes.yaml — the campaign recipe book (PLAN §5/§8, A8).
#
# Every dish the game knows lives here; no Python enumerates recipes. The
# schema is `src/undercooked/sim/recipe.py` and the reference is
# docs/LEVELS.md — every model is strict (`extra="forbid"`), so a typo here
# fails the load loudly instead of silently defaulting.
#
# Step lists mirror PLAN §5's station pipelines: pick from the spawn shelf,
# chop (~0.9 s), cook on the stove, plate at the prep station, serve at the
# window. `seconds` feeds only the validator's greedy target-plausibility
# bound; the simulation (phase 04) re-enacts these steps per tick.
# Catalog of the item kinds the campaign's dishes consume (documentation of
# intent; spawn shelves are what actually put items on the floor).
ingredients: [tomato, onion, lettuce, dough, patty, rice, fish, plate]
recipes:
# tomato + onion -> chop -> pot on the stove -> plate
soup:
id: soup
name: Tomato Soup
base_score: 40
ingredients:
- {kind: tomato, form: raw}
- {kind: onion, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5.0}
- {kind: plate, station: prep, seconds: 0.4}
- {kind: serve, station: serve}
tags: [hot, soup]
# lettuce + tomato -> chop -> plate; the fast, no-fire dish
salad:
id: salad
name: Garden Salad
base_score: 30
ingredients:
- {kind: lettuce, form: raw}
- {kind: tomato, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: plate, station: prep, seconds: 0.4}
- {kind: serve, station: serve}
tags: [cold, quick]
# rice + onion straight onto the stove (PLAN §5: raw rice needs no board)
fried_rice:
id: fried_rice
name: Fried Rice
base_score: 50
ingredients:
- {kind: rice, form: raw}
- {kind: onion, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: cook, station: stove, seconds: 6.0}
- {kind: plate, station: prep, seconds: 0.4}
- {kind: serve, station: serve}
tags: [hot, grain]
# patty + dough + lettuce -> chop + pan on the stove -> plate. Defined now;
# later levels (phase 11) put it in an order pool.
burger:
id: burger
name: Chef's Burger
base_score: 70
ingredients:
- {kind: patty, form: raw}
- {kind: dough, form: raw}
- {kind: lettuce, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5.0}
- {kind: plate, station: prep, seconds: 0.5}
- {kind: serve, station: serve}
tags: [hot, stacked]
+15 -1
View File
@@ -9,7 +9,8 @@ exactly the same configuration as this process, and so an invalid ``--port`` is
rejected by the same validator as an invalid `UNDERCOOKED_PORT`. rejected by the same validator as an invalid `UNDERCOOKED_PORT`.
Exit codes: ``0`` after a clean shutdown, ``2`` for a configuration error Exit codes: ``0`` after a clean shutdown, ``2`` for a configuration error
(matching argparse's own usage-error code), anything else comes from uvicorn. (matching argparse's own usage-error code) *or* level content the startup gate
refused to build an app around, anything else comes from uvicorn.
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,6 +22,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from undercooked.config import ConfigError, Settings, apply_cli_overrides from undercooked.config import ConfigError, Settings, apply_cli_overrides
from undercooked.sim.recipe import ContentError
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
@@ -148,7 +150,19 @@ def main(argv: Sequence[str] | None = None) -> int:
print(f"undercooked: {exc}", file=sys.stderr) print(f"undercooked: {exc}", file=sys.stderr)
return EXIT_CONFIG_ERROR return EXIT_CONFIG_ERROR
try:
_serve(options) _serve(options)
except ContentError:
# Building the app is what reads the content, and `create_app` has already
# written the full named-rule report to stderr before raising. A developer
# gets that report plus one closing line, not a traceback: content nobody
# can cook is as much a startup error as a port nobody can bind.
print(
"undercooked: refusing to start — the report above names every broken "
"rule (`undercooked-levels validate` re-checks them)",
file=sys.stderr,
)
return EXIT_CONFIG_ERROR
return EXIT_OK return EXIT_OK
+8
View File
@@ -57,6 +57,7 @@ ENV_KEYS: Mapping[str, str] = {
"max_players": f"{ENV_PREFIX}MAX_PLAYERS", "max_players": f"{ENV_PREFIX}MAX_PLAYERS",
"max_rooms": f"{ENV_PREFIX}MAX_ROOMS", "max_rooms": f"{ENV_PREFIX}MAX_ROOMS",
"levels_dir": f"{ENV_PREFIX}LEVELS_DIR", "levels_dir": f"{ENV_PREFIX}LEVELS_DIR",
"recipes_path": f"{ENV_PREFIX}RECIPES_PATH",
"static_dir": f"{ENV_PREFIX}STATIC_DIR", "static_dir": f"{ENV_PREFIX}STATIC_DIR",
"lobby_idle_timeout_s": f"{ENV_PREFIX}LOBBY_IDLE_TIMEOUT_S", "lobby_idle_timeout_s": f"{ENV_PREFIX}LOBBY_IDLE_TIMEOUT_S",
"guest_idle_timeout_s": f"{ENV_PREFIX}GUEST_IDLE_TIMEOUT_S", "guest_idle_timeout_s": f"{ENV_PREFIX}GUEST_IDLE_TIMEOUT_S",
@@ -76,6 +77,9 @@ DEFAULT_SNAPSHOT_HZ = 20
DEFAULT_MAX_PLAYERS = 4 DEFAULT_MAX_PLAYERS = 4
DEFAULT_MAX_ROOMS = 200 DEFAULT_MAX_ROOMS = 200
DEFAULT_LEVELS_DIR = Path("levels") DEFAULT_LEVELS_DIR = Path("levels")
# `None` means "the recipe book beside the levels directory" (see
# `server.routes_content.resolve_recipes_path`), i.e. `levels/recipes.yaml`.
DEFAULT_RECIPES_PATH: Path | None = None
# `None` means "derive it from the installed package layout" (see # `None` means "derive it from the installed package layout" (see
# `server.app.STATIC_DIR`); containers set an explicit path instead. # `server.app.STATIC_DIR`); containers set an explicit path instead.
DEFAULT_STATIC_DIR: Path | None = None DEFAULT_STATIC_DIR: Path | None = None
@@ -119,6 +123,8 @@ class Settings:
max_players: seats per room (PLAN 2: 1-4). max_players: seats per room (PLAN 2: 1-4).
max_rooms: process-wide room cap before new rooms are refused. max_rooms: process-wide room cap before new rooms are refused.
levels_dir: directory of level YAML files (A8); ``~`` is expanded. levels_dir: directory of level YAML files (A8); ``~`` is expanded.
recipes_path: the recipe book file (A8); ``None`` resolves to
``levels_dir/recipes.yaml``, and ``~`` is expanded when set.
static_dir: directory served at ``/static``; ``None`` derives it from the static_dir: directory served at ``/static``; ``None`` derives it from the
package layout (``<repo>/app/static``), which is wrong inside a package layout (``<repo>/app/static``), which is wrong inside a
container image, so those set it explicitly. container image, so those set it explicitly.
@@ -140,6 +146,7 @@ class Settings:
max_players: int = DEFAULT_MAX_PLAYERS max_players: int = DEFAULT_MAX_PLAYERS
max_rooms: int = DEFAULT_MAX_ROOMS max_rooms: int = DEFAULT_MAX_ROOMS
levels_dir: Path = DEFAULT_LEVELS_DIR levels_dir: Path = DEFAULT_LEVELS_DIR
recipes_path: Path | None = DEFAULT_RECIPES_PATH
static_dir: Path | None = DEFAULT_STATIC_DIR static_dir: Path | None = DEFAULT_STATIC_DIR
lobby_idle_timeout_s: float = DEFAULT_LOBBY_IDLE_TIMEOUT_S lobby_idle_timeout_s: float = DEFAULT_LOBBY_IDLE_TIMEOUT_S
guest_idle_timeout_s: float = DEFAULT_GUEST_IDLE_TIMEOUT_S guest_idle_timeout_s: float = DEFAULT_GUEST_IDLE_TIMEOUT_S
@@ -286,6 +293,7 @@ def load_settings(env: Mapping[str, str] | None = None) -> Settings:
max_players=max_players, max_players=max_players,
max_rooms=_int(env, f"{prefix}MAX_ROOMS", DEFAULT_MAX_ROOMS, MIN_ROOMS), max_rooms=_int(env, f"{prefix}MAX_ROOMS", DEFAULT_MAX_ROOMS, MIN_ROOMS),
levels_dir=_path(env, f"{prefix}LEVELS_DIR", DEFAULT_LEVELS_DIR), levels_dir=_path(env, f"{prefix}LEVELS_DIR", DEFAULT_LEVELS_DIR),
recipes_path=_optional_path(env, f"{prefix}RECIPES_PATH"),
static_dir=_optional_path(env, f"{prefix}STATIC_DIR"), static_dir=_optional_path(env, f"{prefix}STATIC_DIR"),
lobby_idle_timeout_s=_float( lobby_idle_timeout_s=_float(
env, f"{prefix}LOBBY_IDLE_TIMEOUT_S", DEFAULT_LOBBY_IDLE_TIMEOUT_S, MIN_TIMEOUT_S env, f"{prefix}LOBBY_IDLE_TIMEOUT_S", DEFAULT_LOBBY_IDLE_TIMEOUT_S, MIN_TIMEOUT_S
+11
View File
@@ -8,6 +8,10 @@ is only:
* ``GET /healthz`` — liveness probe for containers and tests, * ``GET /healthz`` — liveness probe for containers and tests,
* ``GET /`` — the client shell from ``app/static/index.html``, * ``GET /`` — the client shell from ``app/static/index.html``,
* ``/static/*`` — the client's ES modules, CSS and art (never a CDN, A12), * ``/static/*`` — the client's ES modules, CSS and art (never a CDN, A12),
* ``/api/levels``, ``/api/levels/{id}``, ``/api/config`` — the content API
(:mod:`undercooked.server.routes_content`), fed by the bundle loaded once
here: content that will not load or fails PLAN §8 validation refuses
startup with the report on stderr instead of serving a half-working lobby,
* security headers on every response (:mod:`undercooked.server.headers`). * security headers on every response (:mod:`undercooked.server.headers`).
``create_app`` takes a :class:`~undercooked.config.Settings` as plain data, so a ``create_app`` takes a :class:`~undercooked.config.Settings` as plain data, so a
@@ -31,6 +35,7 @@ from undercooked import __version__
from undercooked.config import ConfigError, Settings, get_settings from undercooked.config import ConfigError, Settings, get_settings
from undercooked.debug import maybe_start_debugpy from undercooked.debug import maybe_start_debugpy
from undercooked.server.headers import SecurityHeadersMiddleware from undercooked.server.headers import SecurityHeadersMiddleware
from undercooked.server.routes_content import content_router, load_validated_content
__all__ = [ __all__ = [
"STATIC_DIR", "STATIC_DIR",
@@ -87,6 +92,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
Raises: Raises:
ConfigError: the resolved static directory does not exist — better to ConfigError: the resolved static directory does not exist — better to
fail at startup than to serve 500s for every asset. fail at startup than to serve 500s for every asset.
ContentError: the level/recipe content will not load, or a level fails
a PLAN §8 validation rule — the full report goes to stderr and no
app is built (a broken kitchen must not open as a lobby).
""" """
resolved = settings if settings is not None else get_settings() resolved = settings if settings is not None else get_settings()
static_dir = resolve_static_dir(resolved) static_dir = resolve_static_dir(resolved)
@@ -95,6 +103,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
f"static directory {static_dir} does not exist " f"static directory {static_dir} does not exist "
"(set UNDERCOOKED_STATIC_DIR to the directory holding index.html)" "(set UNDERCOOKED_STATIC_DIR to the directory holding index.html)"
) )
content = load_validated_content(resolved)
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]: async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
@@ -115,8 +124,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
openapi_url=None, openapi_url=None,
) )
application.state.settings = resolved application.state.settings = resolved
application.state.content = content # loaded + validated once, above
application.state.rooms = None # phase 05 installs the RoomSupervisor here application.state.rooms = None # phase 05 installs the RoomSupervisor here
application.add_middleware(SecurityHeadersMiddleware) application.add_middleware(SecurityHeadersMiddleware)
application.include_router(content_router)
@application.get("/healthz") @application.get("/healthz")
async def healthz() -> dict[str, object]: async def healthz() -> dict[str, object]:
+213
View File
@@ -0,0 +1,213 @@
"""The content HTTP surface: `/api/levels`, `/api/levels/{id}`, `/api/config`.
The client's level cards and lobby settings come from here — never from a
bundled JSON copy — so content edits need no client change (A8). Everything
is projected through the strict response models below, which exist to
*forgive* nothing: `extra="forbid"` means a field that leaks into a response
(a grid row, a station id, an internal spec) breaks `model_validate` in
`tests/integration/test_content_api.py` instead of quietly reaching the
browser. Layout, legend, station ids and spawn coordinates are internal and
never leave the server (PLAN §8, US-07).
`load_validated_content` is the startup half of the same contract:
`create_app` calls it once, stores the bundle on `app.state.content`, and a
content bug — a file that will not load or a level that violates a named
validation rule — prints the full report on stderr and raises
`ContentError`, so the app refuses to start rather than serve a half-working
lobby around a broken kitchen.
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from typing import Final, NoReturn
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, ConfigDict
from undercooked.config import Settings
from undercooked.sim.content import ContentBundle, load_content
from undercooked.sim.level import Level
from undercooked.sim.model import HazardKind
from undercooked.sim.recipe import ContentError, RecipeBook
from undercooked.sim.validation import ValidationReport, validate_bundle
__all__ = [
"PROTOCOL_VERSION",
"RECIPES_FILENAME",
"ClientConfig",
"HazardSummary",
"LevelDetail",
"LevelSummary",
"content_router",
"load_validated_content",
"resolve_recipes_path",
]
logger = logging.getLogger(__name__)
#: Wire protocol major this server speaks (PLAN §7/A14). Phase 06 moves this
#: constant to `server/protocol.py` and owns its bump policy; until then the
#: client-safe value is published here so `/api/config` can promise it.
PROTOCOL_VERSION: Final = 1
#: Default file name of the recipe book, resolved inside `levels_dir`.
RECIPES_FILENAME: Final = "recipes.yaml"
class LevelSummary(BaseModel):
"""One level card: everything a lobby may show, nothing it may not."""
model_config = ConfigDict(extra="forbid")
id: str
name: str
order: int
difficulty: int
blurb: str
target_score: int
time_limit: float
recipes: list[str]
class HazardSummary(BaseModel):
"""Only *that* a level carries a hazard kind — never where it sits."""
model_config = ConfigDict(extra="forbid")
type: HazardKind
class LevelDetail(LevelSummary):
"""A level card plus the hazard kinds it uses; layout stays server-side."""
hazards: list[HazardSummary]
class ClientConfig(BaseModel):
"""The client-safe settings subset — and nothing else (task 04)."""
model_config = ConfigDict(extra="forbid")
max_players: int
tick_hz: int
snapshot_hz: int
protocol_version: int
def _level_summary(level: Level, book: RecipeBook) -> LevelSummary:
"""Project one built level onto the public card shape."""
return LevelSummary(
id=level.id,
name=level.name,
order=level.order,
difficulty=level.difficulty,
blurb=level.blurb,
target_score=level.target_score,
time_limit=level.time_limit,
recipes=[book.get(recipe_id).name for recipe_id in level.orders.pool],
)
def _level_detail(level: Level, book: RecipeBook) -> LevelDetail:
"""The card plus hazard kinds — no positions, no station ids."""
return LevelDetail(
**_level_summary(level, book).model_dump(),
hazards=[HazardSummary(type=hazard.type) for hazard in level.hazards],
)
def resolve_recipes_path(settings: Settings) -> Path:
"""The recipe book: the explicit override, else `recipes.yaml` in `levels_dir`."""
if settings.recipes_path is not None:
return settings.recipes_path
return settings.levels_dir / RECIPES_FILENAME
def _render_report(report: ValidationReport) -> str:
"""The rendered validation report (errors *and* warnings, deterministic)."""
return report.render()
def _refuse(message: str) -> NoReturn:
"""Print the full report on stderr and refuse to start.
`create_app` callers (uvicorn, `python -m undercooked`, tests) see the
`ContentError`; the stderr half is what a human operator reads first, so
the named-rule report must be on screen even when the traceback is long.
"""
print(message, file=sys.stderr)
raise ContentError(message)
def load_validated_content(settings: Settings) -> ContentBundle:
"""Load and validate the whole campaign; any failure refuses startup.
Loading is file + schema errors aggregated by `load_content`; validation
is PLAN §8's named-rule gate (`validate_bundle`). Errors print their full
report to stderr and raise `ContentError`; warnings are logged level by
level and never stop the server — a tight target is a warning, a broken
kitchen is not.
"""
try:
bundle = load_content(settings.levels_dir, resolve_recipes_path(settings))
except ContentError as exc:
_refuse(f"undercooked: content failed to load from {settings.levels_dir}:\n{exc}")
report = validate_bundle(bundle)
for warning in report.warnings:
logger.warning(
"level %s: %s: %s", warning.level_id, warning.rule, warning.message
)
if not report.ok:
_refuse(f"undercooked: level content failed validation:\n{_render_report(report)}")
logger.info(
"content loaded: %d level(s), %d recipe(s) from %s",
len(bundle.levels),
len(bundle.recipe_book.recipes),
settings.levels_dir,
)
return bundle
def _bundle(request: Request) -> ContentBundle:
"""The bundle `create_app` refused to build a server without."""
bundle: ContentBundle = request.app.state.content
return bundle
content_router = APIRouter(tags=["content"])
@content_router.get("/api/levels", response_model=list[LevelSummary])
async def list_levels(request: Request) -> list[LevelSummary]:
"""Every shipped level, in campaign order, as strict level cards."""
bundle = _bundle(request)
return [_level_summary(level, bundle.recipe_book) for level in bundle.levels]
@content_router.get("/api/levels/{level_id}", response_model=LevelDetail)
async def get_level(request: Request, level_id: str) -> LevelDetail:
"""One level card plus its hazard kinds; 404 for an unknown id.
The id comes from the client and is only ever a lookup into the loaded
bundle — nothing about it is trusted or echoed beyond the 404 decision.
"""
bundle = _bundle(request)
level = bundle.by_id.get(level_id)
if level is None:
raise HTTPException(status_code=404, detail="unknown level")
return _level_detail(level, bundle.recipe_book)
@content_router.get("/api/config", response_model=ClientConfig)
async def get_config(request: Request) -> ClientConfig:
"""The four client-safe settings; no secret-, operator- or origin-named key."""
settings: Settings = request.app.state.settings
return ClientConfig(
max_players=settings.max_players,
tick_hz=settings.tick_hz,
snapshot_hz=settings.snapshot_hz,
protocol_version=PROTOCOL_VERSION,
)
+174
View File
@@ -0,0 +1,174 @@
"""The content loader: a directory of YAML files -> one validated `ContentBundle`.
Purity contract (PLAN.md A5, enforced by `undercooked.sim.purity`): standard
library plus `pydantic`/`PyYAML` and `undercooked.sim` imports only — no
asyncio, no sockets, no wall-clock, no FastAPI. Reading the level directory
is the load-time half of the content pipeline (A8): the shipped `levels/`
tree is the only source of levels and recipes, and nothing in Python
enumerates either.
Discovery rules (documented in `docs/LEVELS.md`):
* every `*.yaml` / `*.yml` file under `levels_dir`, at any depth, is a level;
* files whose name — or any ancestor directory's name — starts with `_` are
ignored (fixture overrides, drafts);
* `recipes.yaml` / `recipes.yml` is ignored because it is the recipe book,
which is passed to `load_content` as its own path;
* levels are campaign-ordered by `(order, id)` — ties break on id, never on
file-system order;
* every problem in the run — unreadable file, bad YAML, schema violation,
duplicate level id, an order pool naming an unknown recipe — is collected
and raised as one `ContentError`, not one per run.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Final
from pydantic import ValidationError
from undercooked.sim.grid import GridError
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.recipe import (
ContentError,
RecipeBook,
describe_validation_error,
load_recipe_book,
read_yaml_mapping,
)
__all__ = [
"LEVEL_FILE_SUFFIXES",
"ContentBundle",
"ContentError",
"load_content",
"load_level_file",
]
#: File extensions a level file may use (lower-cased comparison).
LEVEL_FILE_SUFFIXES: Final = frozenset({".yaml", ".yml"})
#: Drafts and fixture overrides opt out of loading by starting their name with this.
_IGNORED_PREFIX: Final = "_"
#: The recipe book lives beside the levels but is not itself a level file.
_RECIPE_BASENAMES: Final = frozenset({"recipes.yaml", "recipes.yml"})
@dataclass(frozen=True)
class ContentBundle:
"""Everything the game reads about content: the recipe book and the levels.
`levels` is in campaign order; `by_id` is derived from it and can never
disagree. Duplicate level ids are refused here as well as in
`load_content`, so a hand-built bundle cannot smuggle one through.
"""
recipe_book: RecipeBook
levels: tuple[Level, ...]
by_id: Mapping[str, Level] = field(init=False)
def __post_init__(self) -> None:
counts: dict[str, int] = {}
for level in self.levels:
counts[level.id] = counts.get(level.id, 0) + 1
duplicates = sorted(level_id for level_id, seen in counts.items() if seen > 1)
if duplicates:
msg = "duplicate level ids: " + ", ".join(duplicates)
raise ContentError(msg)
object.__setattr__(self, "by_id", {level.id: level for level in self.levels})
def load_level_file(path: Path | str) -> Level:
"""Parse, validate, and build the single level defined by one YAML file.
Raises:
ContentError: the file is unreadable, is not valid YAML, violates the
`LevelDef` schema (every field problem is named), or names tiles
that cannot build a grid.
"""
file = Path(path)
data = read_yaml_mapping(file)
try:
spec = LevelDef.model_validate(data)
except ValidationError as exc:
raise ContentError(describe_validation_error(exc, str(file))) from exc
try:
return Level.build(spec)
except GridError as exc:
raise ContentError(f"{file}: {exc}") from exc
def _level_files(levels_dir: Path) -> tuple[Path, ...]:
"""Every candidate level file under `levels_dir`, in stable sorted order."""
if not levels_dir.is_dir():
msg = f"levels directory {levels_dir} does not exist"
raise ContentError(msg)
candidates = (
path
for path in levels_dir.rglob("*")
if path.is_file()
and path.suffix.lower() in LEVEL_FILE_SUFFIXES
and not _is_draft(levels_dir, path)
and path.name not in _RECIPE_BASENAMES
)
return tuple(sorted(candidates, key=str))
def _is_draft(levels_dir: Path, path: Path) -> bool:
"""True when the file or any ancestor directory opts out with a `_` prefix."""
return any(
part.startswith(_IGNORED_PREFIX)
for part in path.relative_to(levels_dir).parts
)
def _unknown_pool_recipes(spec: LevelDef, book: RecipeBook) -> list[str]:
"""Order-pool ids the recipe book does not define (sorted, deduplicated)."""
return sorted({recipe_id for recipe_id in spec.orders.pool if recipe_id not in book.recipes})
def load_content(levels_dir: Path | str, recipes_path: Path | str) -> ContentBundle:
"""Load the recipe book and every level file into a campaign-ordered bundle.
Args:
levels_dir: directory holding `*.yaml` level files (searched
recursively); missing is a `ContentError`, empty is a legal empty
campaign (until phase 02 task 04 seeds it).
recipes_path: the recipes YAML (`levels/recipes.yaml` by convention),
passed explicitly so tests and tools can point elsewhere.
Raises:
ContentError: every failure found across the whole run — reading,
parsing, schema, unknown order-pool recipes, and duplicate level
ids — joined into one message, one per problem.
"""
book = load_recipe_book(recipes_path)
levels: list[Level] = []
errors: list[str] = []
for path in _level_files(Path(levels_dir)):
try:
level = load_level_file(path)
except ContentError as exc:
errors.append(str(exc))
continue
unknown = _unknown_pool_recipes(level.spec, book)
if unknown:
errors.append(f"{path}: orders.pool names unknown recipes: {', '.join(unknown)}")
continue
levels.append(level)
seen_ids: set[str] = set()
duplicates: set[str] = set()
for level in levels:
if level.id in seen_ids:
duplicates.add(level.id)
seen_ids.add(level.id)
if duplicates:
errors.append(f"duplicate level ids: {', '.join(sorted(duplicates))}")
if errors:
raise ContentError("\n".join(errors))
ordered = sorted(levels, key=lambda level: (level.order, level.id))
return ContentBundle(recipe_book=book, levels=tuple(ordered))
+146
View File
@@ -0,0 +1,146 @@
"""Grid geometry and walkability for the pure simulation kernel (PLAN §5/§8).
Purity contract (PLAN.md A5, enforced by `undercooked.sim.purity`): standard
library and `undercooked.sim` imports only — no I/O, no asyncio, no clock.
Cells are indexed row-major as ``y * width + x``; tiles are continuous-world
footprints, while player positions stay `Vec2` float values.
"""
from __future__ import annotations
from collections import deque
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Final
from undercooked.sim.model import WALKABLE, TileKind, Vec2
__all__ = ["MAX_GRID", "Grid", "GridError"]
#: Maximum grid width/height in tiles; larger maps are a validation error.
MAX_GRID: Final = 64
# Deterministic neighbour orders, as (dx, dy).
_DIRS4: Final = ((0, -1), (-1, 0), (1, 0), (0, 1)) # N, W, E, S
_DIRS8: Final = ((0, -1), (-1, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (1, 1), (0, 1))
class GridError(ValueError):
"""Raised for malformed grid content or out-of-range cell access."""
@dataclass(frozen=True)
class Grid:
"""Rectangular tile map: `tiles` is row-major, `station_at` maps cell -> station id."""
width: int
height: int
tiles: tuple[TileKind, ...]
station_at: Mapping[int, str] = field(default_factory=dict)
def __post_init__(self) -> None:
if not 0 < self.width <= MAX_GRID or not 0 < self.height <= MAX_GRID:
msg = f"grid must be 1..{MAX_GRID} tiles per side, got {self.width}x{self.height}"
raise GridError(msg)
if len(self.tiles) != self.width * self.height:
msg = f"expected {self.width * self.height} tiles, got {len(self.tiles)}"
raise GridError(msg)
@classmethod
def from_rows(cls, rows: Sequence[str], legend: Mapping[str, TileKind]) -> Grid:
"""Parse an ASCII map (PLAN §8 `grid:` + `tiles:` legend).
Raises:
GridError: no rows, ragged rows, unknown legend character, or a
size of 0 / beyond `MAX_GRID`.
"""
if len(rows) == 0:
msg = "grid has no rows"
raise GridError(msg)
width = len(rows[0])
tiles: list[TileKind] = []
for y, row in enumerate(rows):
if len(row) != width:
msg = f"ragged grid: row {y} is {len(row)} chars, expected {width}"
raise GridError(msg)
for x, char in enumerate(row):
kind = legend.get(char)
if kind is None:
msg = f"unknown legend char {char!r} at ({x},{y})"
raise GridError(msg)
tiles.append(kind)
return cls(width=width, height=len(rows), tiles=tuple(tiles))
def in_bounds(self, x: int, y: int) -> bool:
return 0 <= x < self.width and 0 <= y < self.height
def tile_at(self, x: int, y: int) -> TileKind | None:
"""Tile at (x, y), or None when the coordinate is outside the map."""
if not self.in_bounds(x, y):
return None
return self.tiles[y * self.width + x]
def is_walkable(self, x: int, y: int) -> bool:
"""True only for an in-bounds tile the cooks can stand on."""
tile = self.tile_at(x, y)
return tile is not None and tile in WALKABLE
def walkable_cells(self) -> tuple[int, ...]:
"""Walkable cell indices in deterministic row-major order."""
return tuple(index for index, tile in enumerate(self.tiles) if tile in WALKABLE)
def blocking_mask(self) -> tuple[bool, ...]:
"""Per-cell True where cooks cannot stand (wall, counter, void, off-map)."""
return tuple(tile not in WALKABLE for tile in self.tiles)
def neighbours(self, cell: int, four_way: bool = True) -> tuple[int, ...]:
"""In-bounds 4- (default) or 8-connected neighbours of `cell`.
Geometry only — walkability is the caller's filter (see `bfs_reachable`).
Raises GridError if `cell` is not a valid index.
"""
if not 0 <= cell < len(self.tiles):
msg = f"cell {cell} out of range for a {len(self.tiles)}-cell grid"
raise GridError(msg)
x, y = cell % self.width, cell // self.width
directions = _DIRS4 if four_way else _DIRS8
return tuple(
(y + dy) * self.width + (x + dx)
for dx, dy in directions
if self.in_bounds(x + dx, y + dy)
)
def bfs_reachable(
self, start: int, extra_blocked: frozenset[int] = frozenset()
) -> frozenset[int]:
"""Every walkable cell 4-connected to `start`, including `start` itself.
`extra_blocked` treats otherwise-walkable cells as impassable (mover
exclusion in the phase-02 validator). A start that is not walkable (or
is itself extra-blocked) reaches nothing; a start outside the map is a
GridError.
"""
if not 0 <= start < len(self.tiles):
msg = f"cell {start} out of range for a {len(self.tiles)}-cell grid"
raise GridError(msg)
if start in extra_blocked or self.tiles[start] not in WALKABLE:
return frozenset()
seen = {start}
queue: deque[int] = deque([start])
while queue:
cell = queue.popleft()
for neighbour in self.neighbours(cell):
if (
neighbour in seen
or neighbour in extra_blocked
or self.tiles[neighbour] not in WALKABLE
):
continue
seen.add(neighbour)
queue.append(neighbour)
return frozenset(seen)
@staticmethod
def manhattan(a: Vec2, b: Vec2) -> float:
"""Tile distance (PLAN §8 escalation axis 7)."""
return abs(a.x - b.x) + abs(a.y - b.y)
+354
View File
@@ -0,0 +1,354 @@
"""Level content: the YAML schema, the ASCII-grid legend, and the built `Level`.
Purity contract (PLAN.md A5, enforced by `undercooked.sim.purity`): standard
library plus `pydantic`/`PyYAML` and `undercooked.sim` imports only — no
asyncio, no sockets, no wall-clock, no FastAPI. These models mirror PLAN §8:
a level file *is* a `LevelDef`, and `build_grid`/`Level.build` turn it into
the grid geometry the simulation and the validator walk. The schema reference
authors write against is `docs/LEVELS.md`.
Legend conventions (all enforced by the validators below):
* every character used by the grid needs a `tiles:` entry, and every legend
entry must be used at least once — dead legend chars are a content bug;
* stations stand on walkable `floor` tiles (a station on wall/counter/void is
unreachable; task 03's rules reject it);
* exactly one legend character per level maps to the `serve` station;
* station ids are deterministic — `f"{kind.value}_{n}"` (or the legend's
`id_prefix` instead of the kind) with `n` counting up in reading order
(row-major) across every tile of that kind, so hazards can reference
`stove_2` from the file.
"""
from __future__ import annotations
from collections.abc import Iterator, Mapping
from dataclasses import dataclass, field
from pydantic import Field, model_validator
from undercooked.sim.grid import MAX_GRID, Grid
from undercooked.sim.model import HazardKind, ItemKind, Station, StationKind, TileKind
from undercooked.sim.recipe import StrictModel
__all__ = [
"HazardSpec",
"Level",
"LevelDef",
"OrdersSpec",
"StationLimits",
"TileSpec",
"build_grid",
"build_station_items",
"build_stations",
]
class TileSpec(StrictModel):
"""One `tiles:` legend entry: what a grid character *is*.
`kind` is the terrain; `station` (when set) puts a fixture on that tile;
`item` is what a spawn shelf replenishes; `id_prefix` replaces the station
kind in generated station ids for this character.
"""
kind: TileKind
station: StationKind | None = None
item: ItemKind | None = None
id_prefix: str | None = Field(default=None, min_length=1)
@model_validator(mode="after")
def _station_rules(self) -> TileSpec:
problems: list[str] = []
if self.item is not None and self.station is not StationKind.SPAWN_SHELF:
problems.append("only a spawn_shelf tile may name an item")
if self.station is StationKind.SPAWN_SHELF and self.item is None:
problems.append("a spawn_shelf tile must name the item it spawns")
if self.station is None and self.id_prefix is not None:
problems.append("id_prefix belongs to a station tile")
if problems:
raise ValueError("; ".join(problems))
return self
class OrdersSpec(StrictModel):
"""How orders arrive: what may be ordered and how fast (PLAN §8 `orders:`)."""
pool: list[str] = Field(min_length=1)
concurrent: int = Field(ge=1)
spawn_interval: tuple[float, float]
deadline: float = Field(gt=0)
penalty: int = Field(default=10, ge=0)
@model_validator(mode="after")
def _spawn_interval_ordered(self) -> OrdersSpec:
low, high = self.spawn_interval
problems: list[str] = []
if low <= 0:
problems.append(f"spawn_interval must start above 0 s, got {low}")
if low > high:
problems.append(f"spawn_interval start {low} s is greater than its end {high} s")
if problems:
raise ValueError("; ".join(problems))
return self
class HazardSpec(StrictModel):
"""One authored hazard (PLAN §8 `hazards:`), placed or path-walking.
Placement is `station` XOR `x`/`y`: never both, and the pair must come
together. `fire` and `spill` additionally need a location;
`moving_obstacle` needs a `path` of at least 2 points and a `period_s`;
`rush` is global and needs a positive `intensity`.
"""
type: HazardKind
x: int | None = None
y: int | None = None
station: str | None = None
chance_per_s: float | None = Field(default=None, ge=0.0)
respawn_s: float | None = Field(default=None, gt=0.0)
path: list[tuple[int, int]] | None = None
period_s: float | None = Field(default=None, gt=0.0)
intensity: float = 1.0
@model_validator(mode="after")
def _placement_rules(self) -> HazardSpec:
problems: list[str] = []
has_coords = self.x is not None and self.y is not None
if (self.x is None) != (self.y is None):
problems.append("x and y must be given together")
if self.station is not None and has_coords:
problems.append("a hazard takes either station or x/y, not both")
if self.type in (HazardKind.FIRE, HazardKind.SPILL) and not (
self.station is not None or has_coords
):
problems.append(f"a {self.type.value} hazard needs a station or x/y coordinates")
if self.type is HazardKind.MOVING_OBSTACLE:
if self.path is None or len(self.path) < 2:
problems.append("a moving_obstacle hazard needs a path of at least 2 points")
if self.period_s is None:
problems.append("a moving_obstacle hazard needs period_s")
if self.type is HazardKind.RUSH and self.intensity <= 0:
problems.append("a rush hazard needs a positive intensity")
if problems:
raise ValueError("; ".join(problems))
return self
class StationLimits(StrictModel):
"""How many of each shared prop may be out of the sink at once.
Defaults are effectively unlimited (99); a campaign level tightens them
(PLAN §8 escalation axis 4). Zero is schema-legal so the validator
(task 03's `STATION_LIMIT_ZERO`) reports it as a named rule, not a crash.
"""
plates: int = Field(default=99, ge=0)
pots: int = Field(default=99, ge=0)
class LevelDef(StrictModel):
"""One level file, exactly as authored (PLAN §8), before geometry is built.
Validation here is what makes a content file *well-formed*: rectangular
grid within `MAX_GRID`, complete legend both directions, at least one
spawn inside the map, one serve legend char, sane numbers. Playability
(reachability, solvability, target plausibility) is `sim/validation.py`.
"""
id: str = Field(min_length=1)
name: str = Field(min_length=1)
order: int = Field(ge=1)
time_limit: float = Field(gt=0)
target_score: int = Field(gt=0)
grid: list[str] = Field(min_length=1)
tiles: dict[str, TileSpec] = Field(min_length=1)
spawns: list[tuple[int, int]]
orders: OrdersSpec
hazards: list[HazardSpec] = Field(default_factory=list)
station_limits: StationLimits = Field(default_factory=StationLimits)
difficulty: int = Field(default=1, ge=1, le=10)
blurb: str = ""
escalation_notes: str = ""
@model_validator(mode="after")
def _grid_is_a_complete_rectangle(self) -> LevelDef:
problems: list[str] = []
width = len(self.grid[0])
if width == 0:
problems.append("grid rows must not be empty")
elif width > MAX_GRID or len(self.grid) > MAX_GRID:
problems.append(
f"grid must be at most {MAX_GRID}x{MAX_GRID}, got {width}x{len(self.grid)}"
)
for row_index, row in enumerate(self.grid):
if len(row) != width:
problems.append(f"ragged grid: row {row_index} is {len(row)} chars, want {width}")
used = set("".join(self.grid))
problems.extend(
f"grid character {char!r} has no tiles: legend entry"
for char in sorted(used - set(self.tiles))
)
problems.extend(
f"legend character {char!r} is never used by the grid"
for char in sorted(set(self.tiles) - used)
)
if problems:
raise ValueError("; ".join(problems))
return self
@model_validator(mode="after")
def _spawns_are_inside(self) -> LevelDef:
problems: list[str] = []
if not self.spawns:
problems.append("a level needs at least one spawn")
width = len(self.grid[0])
height = len(self.grid)
problems.extend(
f"spawn ({x},{y}) is outside the {width}x{height} grid"
for x, y in self.spawns
if not (0 <= x < width and 0 <= y < height)
)
if problems:
raise ValueError("; ".join(problems))
return self
@model_validator(mode="after")
def _exactly_one_serve(self) -> LevelDef:
serve_chars = sorted(
char for char, spec in self.tiles.items() if spec.station is StationKind.SERVE
)
if len(serve_chars) != 1:
msg = (
f"exactly one legend character must map to the serve station, "
f"found {len(serve_chars)}: {serve_chars}"
)
raise ValueError(msg)
return self
def _station_tiles(level: LevelDef) -> Iterator[tuple[int, Station, TileSpec]]:
"""Yield (cell index, Station, TileSpec) for every station tile in reading order.
The per-kind id counter runs across the whole level in reading order, so
ids are deterministic for a given file and stable under legend reordering.
"""
width = len(level.grid[0])
counters: dict[StationKind, int] = dict.fromkeys(StationKind, 0)
for y, row in enumerate(level.grid):
for x, char in enumerate(row):
spec = level.tiles[char]
if spec.station is None:
continue
kind = spec.station
number = counters[kind]
counters[kind] += 1
prefix = spec.id_prefix if spec.id_prefix is not None else kind.value
station = Station(id=f"{prefix}_{number}", kind=kind, x=float(x), y=float(y))
yield y * width + x, station, spec
def build_grid(level: LevelDef) -> Grid:
"""Legend + ASCII rows -> `Grid`, with `station_at` for every station cell.
The `LevelDef` validators already guarantee rectangularity and a complete
legend, so `Grid.from_rows` cannot fail here for a constructed model; it
stays the geometry's single parser (`GridError` remains possible by hand).
"""
legend = {char: spec.kind for char, spec in level.tiles.items()}
parsed = Grid.from_rows(level.grid, legend)
station_at = {cell: station.id for cell, station, _ in _station_tiles(level)}
return Grid(
width=parsed.width,
height=parsed.height,
tiles=parsed.tiles,
station_at=station_at,
)
def build_stations(level: LevelDef) -> tuple[Station, ...]:
"""Every station the legend places, in reading order, with deterministic ids."""
return tuple(station for _, station, _ in _station_tiles(level))
def build_station_items(level: LevelDef) -> dict[str, ItemKind]:
"""station id -> the item its spawn shelf replenishes (shelves only)."""
return {
station.id: spec.item
for _, station, spec in _station_tiles(level)
if spec.item is not None
}
@dataclass(frozen=True)
class Level:
"""A `LevelDef` with its geometry built: grid, stations, spawns.
`Level.build(spec)` is the only way content should become a `Level` — it
runs `build_grid`/`build_stations` so ids, `station_at`, and
`station_items` (spawn shelf -> item) agree with the file. The delegated
properties keep callers reading `level.target_score` instead of
`level.spec.target_score`.
"""
spec: LevelDef
grid: Grid
stations: tuple[Station, ...]
spawns: tuple[tuple[int, int], ...]
station_items: Mapping[str, ItemKind] = field(default_factory=dict)
@classmethod
def build(cls, spec: LevelDef) -> Level:
"""Build the geometry from a validated definition."""
return cls(
spec=spec,
grid=build_grid(spec),
stations=build_stations(spec),
spawns=tuple(spec.spawns),
station_items=build_station_items(spec),
)
@property
def id(self) -> str:
return self.spec.id
@property
def name(self) -> str:
return self.spec.name
@property
def order(self) -> int:
return self.spec.order
@property
def difficulty(self) -> int:
return self.spec.difficulty
@property
def time_limit(self) -> float:
return self.spec.time_limit
@property
def target_score(self) -> int:
return self.spec.target_score
@property
def orders(self) -> OrdersSpec:
return self.spec.orders
@property
def hazards(self) -> list[HazardSpec]:
return self.spec.hazards
@property
def station_limits(self) -> StationLimits:
return self.spec.station_limits
@property
def blurb(self) -> str:
return self.spec.blurb
@property
def escalation_notes(self) -> str:
return self.spec.escalation_notes
+408
View File
@@ -0,0 +1,408 @@
"""Core simulation data model — the vocabulary every later layer shares (PLAN §5).
Purity contract (PLAN.md A5, enforced by `undercooked.sim.purity`): this module
imports nothing outside the standard library and `undercooked.sim`. No FastAPI,
no asyncio, no sockets, no wall-clock, no I/O, and no RNG outside the round
seed. Every entity is a frozen dataclass; updates go through `with_*` helpers
(or `dataclasses.replace`) so state is never mutated in place and a replay of
the same seed + input script reproduces identical state bit-for-bit.
"""
from __future__ import annotations
import hashlib
import math
from collections.abc import Mapping
from dataclasses import dataclass, field, fields, is_dataclass, replace
from enum import StrEnum
from types import MappingProxyType
from typing import TYPE_CHECKING, Final
if TYPE_CHECKING: # imported for types only; avoids a runtime cycle with grid.py
from undercooked.sim.grid import Grid
__all__ = [
"OPPOSITE",
"WALKABLE",
"Event",
"EventKind",
"Facing",
"Hazard",
"HazardKind",
"Item",
"ItemForm",
"ItemKind",
"KitchenState",
"Order",
"Player",
"PlayerState",
"RoundResult",
"Station",
"StationKind",
"StationState",
"TileKind",
"Vec2",
]
@dataclass(frozen=True)
class Vec2:
"""A continuous point/offset in tile units (positions are not cell-snapped)."""
x: float = 0.0
y: float = 0.0
def __add__(self, other: Vec2) -> Vec2:
return Vec2(self.x + other.x, self.y + other.y)
def __sub__(self, other: Vec2) -> Vec2:
return Vec2(self.x - other.x, self.y - other.y)
def scale(self, factor: float) -> Vec2:
return Vec2(self.x * factor, self.y * factor)
@property
def length(self) -> float:
return math.hypot(self.x, self.y)
def normalized(self) -> Vec2:
"""Unit vector in the same direction; the zero vector normalizes to itself."""
length = self.length
if length == 0.0:
return Vec2(0.0, 0.0)
return self.scale(1.0 / length)
def quantized(self, digits: int = 2) -> Vec2:
"""Rounded copy — the wire encoder's canonical form (PLAN §4 snapshots)."""
return Vec2(round(self.x, digits), round(self.y, digits))
@property
def KEY(self) -> tuple[int, int]: # noqa: N802 - fixed spatial-key vocabulary
"""Spatial hash key at 1/64-tile resolution (bucketing, dedup)."""
return (round(self.x * 64), round(self.y * 64))
class Facing(StrEnum):
"""The four directions; row 0 is the top of the map, so UP decreases y."""
UP = "up"
RIGHT = "right"
DOWN = "down"
LEFT = "left"
def delta(self) -> Vec2:
"""Unit step for this direction in tile space."""
return _FACING_DELTA[self]
_FACING_DELTA: Final[Mapping[Facing, Vec2]] = MappingProxyType(
{
Facing.UP: Vec2(0.0, -1.0),
Facing.RIGHT: Vec2(1.0, 0.0),
Facing.DOWN: Vec2(0.0, 1.0),
Facing.LEFT: Vec2(-1.0, 0.0),
}
)
OPPOSITE: Final[Mapping[Facing, Facing]] = MappingProxyType(
{
Facing.UP: Facing.DOWN,
Facing.DOWN: Facing.UP,
Facing.LEFT: Facing.RIGHT,
Facing.RIGHT: Facing.LEFT,
}
)
class TileKind(StrEnum):
"""Terrain under a grid cell (ASCII legend characters arrive with task 02)."""
FLOOR = "floor"
COUNTER = "counter"
WALL = "wall"
VOID = "void"
WALKABLE: Final[frozenset[TileKind]] = frozenset({TileKind.FLOOR})
class StationKind(StrEnum):
"""Kitchen fixture types (PLAN §5 station pipelines)."""
SPAWN_SHELF = "spawn_shelf"
CHOP_BOARD = "chop_board"
STOVE = "stove"
PREP = "prep"
SERVE = "serve"
SINK = "sink"
TRASH = "trash"
class StationState(StrEnum):
"""Lifecycle of a station."""
IDLE = "idle"
WORKING = "working"
READY = "ready"
BUSY = "busy"
DIRTY = "dirty"
BLOCKED = "blocked"
class ItemKind(StrEnum):
"""Ingredient/dish types; recipe content decides which exist per level."""
TOMATO = "tomato"
ONION = "onion"
LETTUCE = "lettuce"
DOUGH = "dough"
PATTY = "patty"
RICE = "rice"
FISH = "fish"
PLATE = "plate"
class ItemForm(StrEnum):
"""Preparation stage of an item."""
RAW = "raw"
CHOPPED = "chopped"
COOKING = "cooking"
COOKED = "cooked"
BURNT = "burnt"
PLATED = "plated"
DIRTY = "dirty"
@dataclass(frozen=True)
class Item:
"""A carried or station-held ingredient/dish with value semantics."""
kind: ItemKind
form: ItemForm
quality: float = 1.0 # 1.0 -> 0.0 as it burns (PLAN §5)
ttl: float = 0.0 # seconds until the item rots; 0.0 = never
dish_id: int | None = None # recipe id when this item is a plated dish
def is_trash(self) -> bool:
"""Burnt items are unrecoverable and belong in the trash."""
return self.form is ItemForm.BURNT
def with_form(self, form: ItemForm) -> Item:
"""A new Item in `form`; never mutates this one (value semantics)."""
return replace(self, form=form)
@dataclass(frozen=True)
class Station:
"""One kitchen fixture at (x, y) with its own pipeline state."""
id: str
kind: StationKind
x: float = 0.0
y: float = 0.0
contents: tuple[Item, ...] = ()
state: StationState = StationState.IDLE
progress: float = 0.0 # 0.0 -> 1.0 while WORKING
duration: float = 0.0 # total seconds the current job takes
cooldown: float = 0.0 # seconds before the station can be used again
worker_id: str | None = None # player currently working this station
dirty: bool = False # needs the sink before reuse
class PlayerState(StrEnum):
"""What a player's cook is doing this tick."""
IDLE = "idle"
MOVING = "moving"
USING = "using"
STUNNED = "stunned"
DISCONNECTED = "disconnected"
@dataclass(frozen=True)
class Player:
"""One seat in the kitchen; `id` is the session token, `seat` is stable."""
id: str
pos: Vec2 = Vec2()
vel: Vec2 = Vec2()
facing: Facing = Facing.DOWN
carrying: Item | None = None
state: PlayerState = PlayerState.IDLE
stun: float = 0.0
score: int = 0
dishes_served: int = 0
connected: bool = True
seat: int = 0
@dataclass(frozen=True)
class Order:
"""A live customer order counting down to `deadline`."""
id: int
recipe_id: str
time_left: float = 0.0
deadline: float = 0.0
value: int = 0
class HazardKind(StrEnum):
"""Hazard families (PLAN §8 escalation axis 6)."""
FIRE = "fire"
SPILL = "spill"
MOVING_OBSTACLE = "moving_obstacle"
RUSH = "rush"
@dataclass(frozen=True)
class Hazard:
"""One active hazard instance on the floor or attached to a station."""
id: str
kind: HazardKind
pos: Vec2 = Vec2()
life: float = 0.0 # seconds remaining; 0.0 = permanent until removed
path: tuple[Vec2, ...] = () # waypoints for MOVING_OBSTACLE
period_s: float = 0.0 # traversal period for MOVING_OBSTACLE
station_id: str | None = None # station a FIRE burns on
chance_per_s: float = 0.0 # spawn/respawn probability for the spawner
class RoundResult(StrEnum):
"""Terminal outcome of a round."""
WON = "won"
LOST = "lost"
ABORTED = "aborted"
_FLOAT_HASH_DIGITS: Final = 6
def _canonical(value: object) -> str:
"""Deterministic repr used by `KitchenState.state_hash`.
Mapping keys are sorted and floats are quantized to `_FLOAT_HASH_DIGITS`
(folding -0.0 into 0.0), so cosmetic differences cannot change the hash
while any real divergence does. Raises TypeError on anything unhashable-by-
contract so a new field can never be silently skipped.
"""
if isinstance(value, bool):
out = f"b:{int(value)}"
elif isinstance(value, float):
rounded = round(value, _FLOAT_HASH_DIGITS) + 0.0 # +0.0 folds -0.0 to 0.0
out = f"f:{rounded:.{_FLOAT_HASH_DIGITS}f}"
elif isinstance(value, int):
out = f"i:{value}"
elif isinstance(value, str): # covers every StrEnum in this module
out = f"s:{value}"
elif value is None:
out = "n:"
elif isinstance(value, Mapping):
items = sorted(value.items(), key=lambda kv: (_canonical(kv[0]), _canonical(kv[1])))
out = "m:{" + ",".join(f"{_canonical(k)}={_canonical(v)}" for k, v in items) + "}"
elif isinstance(value, (tuple, list)):
out = "t:[" + ",".join(_canonical(item) for item in value) + "]"
elif is_dataclass(value) and not isinstance(value, type):
parts = ",".join(
f"{field.name}={_canonical(getattr(value, field.name))}"
for field in sorted(fields(value), key=lambda f: f.name)
)
out = f"d:{type(value).__name__}({parts})"
else:
msg = f"cannot canonicalise {type(value).__name__} for state_hash"
raise TypeError(msg)
return out
class EventKind(StrEnum):
"""Transient FX/log event kinds emitted by `step()` (PLAN §4 data flow)."""
CHOPPED = "chopped"
SERVED = "served"
BURNT = "burnt"
ORDER_MISSED = "order_missed"
FIRE_STARTED = "fire_started"
DENIED = "denied"
SCORE = "score"
STUNNED = "stunned"
WASHED = "washed"
TRASHED = "trashed"
TICK_SLOW = "tick_slow"
@dataclass(frozen=True)
class Event:
"""A one-shot event attached to a position; FX/log only, never game truth."""
kind: str
x: float = 0.0
y: float = 0.0
payload: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True)
class KitchenState:
"""Everything that is true about one round at one tick (PLAN §5).
Frozen: phases 03/04 advance the round by *returning* a new state from
`step()`; the `with_*` helpers keep every mutation in one obvious place.
"""
level_id: str
seed: int
grid: Grid
tick: int = 0
stations: tuple[Station, ...] = ()
players: tuple[Player, ...] = ()
orders: tuple[Order, ...] = ()
hazards: tuple[Hazard, ...] = ()
score: int = 0
target_score: int = 0
time_left: float = 0.0
streak: int = 0
result: RoundResult | None = None
counters: Mapping[str, int] = field(default_factory=dict)
rng_state: int = 0
def state_hash(self) -> str:
"""sha256 over the canonical form of every field (A21 replay guard).
Cosmetic differences — mapping insertion order, float repr noise,
-0.0 — never change the hash; any scored change does.
"""
return hashlib.sha256(_canonical(self).encode("utf-8")).hexdigest()
def with_tick(self, tick: int) -> KitchenState:
return replace(self, tick=tick)
def with_time_left(self, seconds: float) -> KitchenState:
return replace(self, time_left=seconds)
def with_score(self, score: int, streak: int | None = None) -> KitchenState:
"""New score, and optionally a new streak, in one atomic replacement."""
if streak is None:
return replace(self, score=score)
return replace(self, score=score, streak=streak)
def with_result(self, result: RoundResult | None) -> KitchenState:
return replace(self, result=result)
def with_stations(self, stations: tuple[Station, ...]) -> KitchenState:
return replace(self, stations=stations)
def with_players(self, players: tuple[Player, ...]) -> KitchenState:
return replace(self, players=players)
def with_orders(self, orders: tuple[Order, ...]) -> KitchenState:
return replace(self, orders=orders)
def with_hazards(self, hazards: tuple[Hazard, ...]) -> KitchenState:
return replace(self, hazards=hazards)
def with_counters(self, **updates: int) -> KitchenState:
"""Merge `updates` into a copy of the counters; the original is untouched."""
return replace(self, counters={**self.counters, **updates})
+57
View File
@@ -0,0 +1,57 @@
"""A5 purity guard: `sim/` imports nothing that touches the outside world.
`assert_pure_imports()` statically parses every module in this package (AST,
so the pytest process's own `asyncio`/`socket` imports cannot poison the
result) and fails on any import rooted in `FORBIDDEN_ROOTS`. This module's own
file reads are the guard's single exception — they only ever run from tests.
"""
from __future__ import annotations
import ast
from pathlib import Path
from typing import Final
__all__ = ["FORBIDDEN_ROOTS", "PurityError", "assert_pure_imports", "find_violations"]
#: Root package names no `sim` module may import (PLAN.md A5).
FORBIDDEN_ROOTS: Final[frozenset[str]] = frozenset(
{"asyncio", "fastapi", "socket", "time", "uvicorn"}
)
SIM_DIR: Final[Path] = Path(__file__).resolve().parent
class PurityError(RuntimeError):
"""Raised when a `sim` module breaks the purity contract."""
def _forbidden_roots_in(tree: ast.Module) -> set[str]:
"""Forbidden import roots appearing anywhere in the module's import statements."""
found: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
found.update(alias.name.split(".")[0] for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
found.add(node.module.split(".")[0])
return found & FORBIDDEN_ROOTS
def find_violations(package_dir: Path = SIM_DIR) -> list[str]:
"""`'<file>: imports <root>'` lines for every forbidden import in the package."""
violations: list[str] = []
for path in sorted(package_dir.rglob("*.py")):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
violations.extend(
f"{path.relative_to(package_dir)}: imports {root}"
for root in sorted(_forbidden_roots_in(tree))
)
return violations
def assert_pure_imports(package_dir: Path = SIM_DIR) -> None:
"""Raise `PurityError` listing every forbidden import found in the package."""
violations = find_violations(package_dir)
if violations:
msg = "sim/ breaks the A5 purity contract: " + "; ".join(violations)
raise PurityError(msg)
+204
View File
@@ -0,0 +1,204 @@
"""Recipe content: ingredients, steps, and the recipe book (PLAN §5/§8, A8).
Purity contract (PLAN.md A5, enforced by `undercooked.sim.purity`): standard
library plus `pydantic`/`PyYAML` and `undercooked.sim` imports only — no
asyncio, no sockets, no wall-clock, no FastAPI. Parsing content files is this
pipeline's whole job: it happens once at load time and never during `step()`.
Every model here is strict — unknown keys are errors (`extra="forbid"`) and
assignment re-validates — so a typo in `levels/recipes.yaml` fails loudly at
startup instead of silently defaulting. `load_recipe_book` turns the YAML file
into a validated `RecipeBook`; `min_seconds_to_complete` is the greedy solver
bound used by the target-plausibility validator (task 03) and phase 11's curve
work. The schema reference players of this file write against is
`docs/LEVELS.md`.
"""
from __future__ import annotations
from pathlib import Path
from typing import Final, Literal
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from undercooked.sim.model import ItemForm, ItemKind, StationKind
__all__ = [
"HANDLING_S",
"ContentError",
"Ingredient",
"Recipe",
"RecipeBook",
"describe_validation_error",
"load_recipe_book",
"read_yaml_mapping",
]
#: Seconds of handling (pick up, put down, plate transfer) added once to every
#: dish by `RecipeBook.min_seconds_to_complete` — the solver's floor for work
#: the authored step list does not itself time.
HANDLING_S: Final = 0.6
class ContentError(ValueError):
"""Content that cannot load: missing file, bad YAML, schema violation, duplicates.
Raised by the whole content pipeline (`recipe.py`, `level.py`,
`content.py`). Its message lists *every* problem found in the run, never
just the first, because an author fixing content one error per cycle is a
slow author.
"""
class StrictModel(BaseModel):
"""Base for every content model: no unknown keys, revalidate on assignment."""
model_config = ConfigDict(extra="forbid", validate_assignment=True)
class Ingredient(StrictModel):
"""One ingredient a recipe consumes: which item, in which form, how many."""
kind: ItemKind
form: ItemForm
count: int = Field(default=1, ge=1)
class Step(StrictModel):
"""One authored action of a recipe (PLAN §5 station pipelines).
`kind` is the interaction, `station` where it happens, and `seconds` the
work time it costs (0.0 for instant interactions like `pick`/`serve`).
"""
kind: Literal["pick", "chop", "cook", "plate", "serve", "wash"]
station: StationKind
seconds: float = Field(default=0.0, ge=0.0)
class Recipe(StrictModel):
"""A dish: what it eats, how it is made, and what it is worth."""
id: str = Field(min_length=1)
name: str = Field(min_length=1)
base_score: int = Field(ge=0)
ingredients: list[Ingredient] = Field(min_length=1)
steps: list[Step] = Field(min_length=1)
requires_plating: bool = True
tags: list[str] = Field(default_factory=list)
class RecipeBook(StrictModel):
"""The parsed `levels/recipes.yaml`: every recipe, keyed by id.
The mapping key and `Recipe.id` must agree — the key is what level order
pools reference, and a silent mismatch would be a level nobody can cook.
`ingredients` is the optional top-level catalog of item kinds the content
uses; it documents intent and lets tooling catch unknown kinds early.
"""
recipes: dict[str, Recipe]
ingredients: list[ItemKind] = Field(default_factory=list)
@model_validator(mode="after")
def _keys_match_ids(self) -> RecipeBook:
mismatched = sorted(
f"{key!r} names recipe {recipe.id!r}"
for key, recipe in self.recipes.items()
if recipe.id != key
)
if mismatched:
msg = "recipes mapping keys must equal each recipe's id: " + "; ".join(mismatched)
raise ValueError(msg)
return self
def get(self, recipe_id: str) -> Recipe:
"""The recipe with this id.
Raises:
KeyError: the id is not in the book (level pools must only name
recipes that exist; this is where that becomes loud).
"""
try:
return self.recipes[recipe_id]
except KeyError:
known = ", ".join(sorted(self.recipes))
msg = f"unknown recipe {recipe_id!r} (known: {known})"
raise KeyError(msg) from None
def ids(self) -> tuple[str, ...]:
"""Every recipe id, in file (declaration) order."""
return tuple(self.recipes)
def required_station_kinds(self, recipe_id: str) -> frozenset[StationKind]:
"""Every station kind the recipe's steps must touch, deduplicated."""
return frozenset(step.station for step in self.get(recipe_id).steps)
def required_item_kinds(self, recipe_id: str) -> frozenset[ItemKind]:
"""Every item kind the dish consumes: its ingredients, plus `PLATE`
when `requires_plating` — a plated dish is not completable without one.
"""
recipe = self.get(recipe_id)
kinds = frozenset(ingredient.kind for ingredient in recipe.ingredients)
if recipe.requires_plating:
kinds |= {ItemKind.PLATE}
return kinds
def min_seconds_to_complete(self, recipe_id: str) -> float:
"""Greedy lower bound on cooking the dish: summed step seconds + `HANDLING_S`.
Never a promise (travel is the validator's job, task 03); it is the
per-order floor that keeps `target_score` plausibility checks honest.
"""
recipe = self.get(recipe_id)
return sum(step.seconds for step in recipe.steps) + HANDLING_S
def describe_validation_error(exc: ValidationError, source: str) -> str:
"""Flatten a pydantic error into one `<source>: <field.path>: <message>` per problem."""
parts: list[str] = []
for problem in exc.errors():
location = ".".join(str(part) for part in problem["loc"]) or "<root>"
parts.append(f"{source}: {location}: {problem['msg']}")
return "; ".join(parts)
def read_yaml_mapping(path: Path | str) -> dict[str, object]:
"""Read a content file as a mapping; every failure is a `ContentError`.
An empty file reads as `{}` so the model reports its own
"field required" errors against the same source path.
"""
file = Path(path)
try:
text = file.read_text(encoding="utf-8")
except OSError as exc:
reason = exc.strerror or str(exc)
msg = f"{file}: cannot read content file: {reason}"
raise ContentError(msg) from exc
try:
data = yaml.safe_load(text)
except yaml.YAMLError as exc:
msg = f"{file}: invalid YAML: {exc}"
raise ContentError(msg) from exc
if data is None:
return {}
if not isinstance(data, dict):
msg = f"{file}: expected a YAML mapping at the top level, got {type(data).__name__}"
raise ContentError(msg)
return data
def load_recipe_book(path: Path | str) -> RecipeBook:
"""Load and validate `levels/recipes.yaml` (or any replacement book).
Raises:
ContentError: the file is missing, is not valid YAML, is not a mapping,
or violates the models above — with every field problem named.
"""
data = read_yaml_mapping(path)
try:
return RecipeBook.model_validate(data)
except ValidationError as exc:
raise ContentError(describe_validation_error(exc, str(path))) from exc
+713
View File
@@ -0,0 +1,713 @@
"""Level validation: prove a level is playable before anyone plays it (PLAN §8).
Purity contract (PLAN.md A5, enforced by `undercooked.sim.purity`): standard
library and `undercooked.sim` imports only — no I/O, no asyncio, no clock.
The validator consumes built `Level`s plus the `RecipeBook` and reports a
structured list of **named rules**, so `undercooked-levels validate` (task 05)
can print exactly which rule a level failed instead of a stack trace.
Design notes:
* every error names its `RuleId` constant and carries a human message with
coordinates; `detail` holds the machine-readable companion of the same fact;
* **warnings never fail a level** — `ValidationReport.ok` reads violations
only, and the tight-target band is a warning;
* reachability is a flood fill (BFS) over walkable tiles from every spawn;
`moving_obstacle` hazards are excluded from the blocked set because movers
are dynamic — only their path tiles must be valid floor;
* spawn shelves are currently the only item source the content model can
express, so "a station that yields it" means exactly a reachable shelf; if
a future station yields ingredients, extend `reachable` production here and
no rule id changes.
"""
from __future__ import annotations
from collections import deque
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from math import floor
from typing import Final
from undercooked.sim.content import ContentBundle
from undercooked.sim.grid import Grid
from undercooked.sim.level import Level
from undercooked.sim.model import ItemForm, StationKind
from undercooked.sim.recipe import RecipeBook
__all__ = [
"AXIS_EPS",
"AXIS_NAMES",
"MIN_ESCALATED_AXES",
"MIN_TIME_LIMIT_S",
"MIN_TRAVEL_TILES",
"TARGET_REJECT_FRACTION",
"TARGET_WARN_FRACTION",
"TRAVEL_MIN_DIFFICULTY",
"TRAVEL_SPEED_TILES_PER_S",
"TRIP_TRAVERSALS",
"LevelViolation",
"RuleId",
"ValidationReport",
"escalation_axes",
"reachable_cells",
"theoretical_max_score",
"validate_bundle",
"validate_level",
"validate_levels",
]
#: Shortest shippable round; anything below is a misconfiguration, not a sprint.
MIN_TIME_LIMIT_S: Final = 30.0
#: `TRAVEL_TOO_SHORT` applies from this declared difficulty upward…
TRAVEL_MIN_DIFFICULTY: Final = 3
#: …and requires the mean spawn→serve path to be at least this many tiles.
MIN_TRAVEL_TILES: Final = 3.0
#: Max cook speed in tiles/s (PLAN §5) — converts travel tiles to seconds.
TRAVEL_SPEED_TILES_PER_S: Final = 3.5
#: One order costs a walk to the serve window and the walk back.
TRIP_TRAVERSALS: Final = 2.0
#: Above this fraction of the theoretical maximum, a target is rejected…
TARGET_REJECT_FRACTION: Final = 0.6
#: …and above this (but under the reject line) it warns: tight but fair.
TARGET_WARN_FRACTION: Final = 0.45
#: A campaign level must move at least this many escalation axes vs its predecessor.
MIN_ESCALATED_AXES: Final = 2
#: Float noise below this never counts as "an axis moved".
AXIS_EPS: Final = 1e-9
#: The escalation-axis names in `escalation_axes`' key order — the column order
#: `undercooked-levels axes` prints, so the table and the rule share one list.
AXIS_NAMES: Final = (
"recipe_complexity",
"order_pressure",
"time_budget",
"station_scarcity",
"layout_hostility",
"hazard_density",
"travel_distance",
)
class RuleId:
"""Named validation rules (PLAN §8's gate, one constant per rule).
Values are stable documentation strings — `render()` prints them and the
`undercooked-levels` contract reports on them — so never renumber or
reuse one; add a new constant instead.
"""
GRID_SHAPE: Final = "GRID_SHAPE"
SPAWN_ON_FLOOR: Final = "SPAWN_ON_FLOOR"
SPAWN_COUNT: Final = "SPAWN_COUNT"
STATION_REACHABLE: Final = "STATION_REACHABLE"
SERVE_REACHABLE: Final = "SERVE_REACHABLE"
SPAWNS_MUTUALLY_REACHABLE: Final = "SPAWNS_MUTUALLY_REACHABLE"
RECIPE_STATION_MISSING: Final = "RECIPE_STATION_MISSING"
RECIPE_INGREDIENT_UNAVAILABLE: Final = "RECIPE_INGREDIENT_UNAVAILABLE"
ORDER_POOL_UNKNOWN_RECIPE: Final = "ORDER_POOL_UNKNOWN_RECIPE"
TARGET_IMPLAUSIBLE: Final = "TARGET_IMPLAUSIBLE"
TIME_LIMIT_INVALID: Final = "TIME_LIMIT_INVALID"
HAZARD_REF_UNKNOWN: Final = "HAZARD_REF_UNKNOWN"
HAZARD_ON_UNREACHABLE_TILE: Final = "HAZARD_ON_UNREACHABLE_TILE"
STATION_LIMIT_ZERO: Final = "STATION_LIMIT_ZERO"
DUPLICATE_ID: Final = "DUPLICATE_ID"
DIFFICULTY_ORDER: Final = "DIFFICULTY_ORDER"
TRAVEL_TOO_SHORT: Final = "TRAVEL_TOO_SHORT"
#: Every rule id, in the order the checks run (the report renders sorted).
ALL: Final = (
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,
)
@dataclass(frozen=True)
class LevelViolation:
"""One rule hit: a stable `rule` id, the level it refers to, and prose.
`message` is the human half (coordinates included where the rule has a
position); `detail` is the machine half for tooling (task 05's CLI).
"""
rule: str # one of the RuleId constants
level_id: str
message: str
detail: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True)
class ValidationReport:
"""Errors and warnings for one level or a whole bundle.
Warnings never fail: `ok` reads `violations` only. `render()` is
deterministic (grouped by level, sorted by rule and message, ANSI-free)
so CLI output and tests can pin it.
"""
violations: tuple[LevelViolation, ...] = ()
warnings: tuple[LevelViolation, ...] = ()
@property
def ok(self) -> bool:
"""True when nothing violated a rule (warnings are allowed)."""
return not self.violations
def errors_for(self, level_id: str) -> tuple[LevelViolation, ...]:
"""The violations belonging to one level id, in render order."""
return tuple(v for v in self._sorted(self.violations) if v.level_id == level_id)
def render(self) -> str:
"""Plain text grouped by level: errors first, then warnings."""
if self.ok and not self.warnings:
return "content OK: no violations"
lines = [
f"{len(self.violations)} error(s), {len(self.warnings)} warning(s)",
]
error_groups: dict[str, list[LevelViolation]] = {}
for violation in self._sorted(self.violations):
error_groups.setdefault(violation.level_id, []).append(violation)
warning_groups: dict[str, list[LevelViolation]] = {}
for warning in self._sorted(self.warnings):
warning_groups.setdefault(warning.level_id, []).append(warning)
for level_id, items in error_groups.items():
lines.append(f"level {level_id}")
lines.extend(f" ERROR {v.rule}: {v.message}" for v in items)
for level_id, items in warning_groups.items():
lines.append(f"level {level_id} (warnings)")
lines.extend(f" WARN {w.rule}: {w.message}" for w in items)
return "\n".join(lines)
@staticmethod
def _sorted(items: Sequence[LevelViolation]) -> tuple[LevelViolation, ...]:
return tuple(sorted(items, key=lambda item: (item.rule, item.message)))
def _add(
into: list[LevelViolation],
rule: str,
level: Level,
message: str,
**detail: object,
) -> None:
into.append(LevelViolation(rule=rule, level_id=level.id, message=message, detail=dict(detail)))
# --- geometry helpers -----------------------------------------------------------
def _cell(grid: Grid, x: int, y: int) -> int:
return y * grid.width + x
def reachable_cells(level: Level) -> frozenset[int]:
"""Every walkable cell reachable (4-connected) from *any* spawn.
Movers are dynamic and never block this set; only terrain does. Public
because `undercooked-levels show --grid` renders exactly this set as the
reachability overlay authors debug a broken layout with.
"""
grid = level.grid
cells: set[int] = set()
for x, y in level.spawns:
if grid.is_walkable(x, y):
cells |= grid.bfs_reachable(_cell(grid, x, y))
return frozenset(cells)
def _serve_cells(level: Level) -> frozenset[int]:
grid = level.grid
return frozenset(
_cell(grid, int(station.x), int(station.y))
for station in level.stations
if station.kind is StationKind.SERVE
and grid.in_bounds(int(station.x), int(station.y))
)
def _bfs_distance(grid: Grid, start: int, targets: frozenset[int]) -> int | None:
"""Shortest walkable 4-path length from `start` to any target, or None.
`start` must be a valid cell index (walkable or not); unwalkable targets
are never "reached", which is what lets the caller fall back to a
straight-line estimate for broken geometry.
"""
if start in targets:
return 0
depths: dict[int, int] = {start: 0}
queue: deque[int] = deque([start])
while queue:
cell = queue.popleft()
depth = depths[cell]
for neighbour in grid.neighbours(cell):
if neighbour in depths or not grid.is_walkable(
neighbour % grid.width, neighbour // grid.width
):
continue
if neighbour in targets:
return depth + 1
depths[neighbour] = depth + 1
queue.append(neighbour)
return None
def _travel_tiles(level: Level) -> tuple[float, ...]:
"""Per-spawn tile distance to the nearest serve cell.
BFS over walkable tiles; when the serve window is unreachable from a spawn
(the reachability rules report that separately) the straight-line
Manhattan distance is used so the metric stays defined. Empty when the
level has no serve cell or no spawns.
"""
grid = level.grid
targets = _serve_cells(level)
if not targets or not level.spawns:
return ()
distances: list[float] = []
for x, y in level.spawns:
found: int | None = None
if grid.is_walkable(x, y):
found = _bfs_distance(grid, _cell(grid, x, y), targets)
if found is None:
found = min(
abs(x - cell % grid.width) + abs(y - cell // grid.width) for cell in targets
)
distances.append(float(found))
return tuple(distances)
def _mean_travel_tiles(level: Level) -> float:
tiles = _travel_tiles(level)
return sum(tiles) / len(tiles) if tiles else 0.0
# --- escalation axes (PLAN §8, higher = harder) ----------------------------------
def escalation_axes(level: Level) -> dict[str, float]:
"""The seven escalation axes as numbers, each oriented *higher = harder*.
Purely geometric/author-declared content: no recipe book, so
`recipe_complexity` counts distinct pool entries, not chain length —
enough for the "≥ 2 axes moved" comparison and the phase-11 curve pass.
"""
orders = level.orders
low, high = orders.spawn_interval
mean_interval = (low + high) / 2.0
blocking = sum(level.grid.blocking_mask())
# Key order is `AXIS_NAMES`; a test pins the two against each other.
return {
"recipe_complexity": float(len(set(orders.pool))),
"order_pressure": orders.concurrent / mean_interval,
"time_budget": level.target_score / level.time_limit,
"station_scarcity": 1.0 / max(len(level.stations), 1),
"layout_hostility": blocking / (level.grid.width * level.grid.height),
"hazard_density": float(len(level.hazards)),
"travel_distance": _mean_travel_tiles(level),
}
def theoretical_max_score(level: Level, book: RecipeBook) -> float | None:
"""Greedy plausibility bound for the level's target (PLAN §8).
`floor(time_limit / mean_complete) * mean_order_value` where
`mean_complete` is the recipe book's per-dish floor averaged over the
order pool plus a `travel_bound` of `TRIP_TRAVERSALS` traversals of the
mean spawn→serve distance at max cook speed (walk to the window, walk
back). Returns None when no bound exists — unknown pool recipes or a time
limit below `MIN_TIME_LIMIT_S` — because the dedicated rules report those.
"""
if level.time_limit < MIN_TIME_LIMIT_S:
return None
pool = [recipe_id for recipe_id in level.orders.pool if recipe_id in book.recipes]
if not pool:
return None
travel_bound = TRIP_TRAVERSALS * _mean_travel_tiles(level) / TRAVEL_SPEED_TILES_PER_S
mean_complete = sum(book.min_seconds_to_complete(r) for r in pool) / len(pool) + travel_bound
max_orders = floor(level.time_limit / mean_complete)
mean_value = sum(book.get(recipe_id).base_score for recipe_id in pool) / len(pool)
return float(max_orders) * mean_value
# --- per-rule checks --------------------------------------------------------------
def _check_grid_shape(level: Level, into: list[LevelViolation]) -> None:
"""GRID_SHAPE: the built geometry must agree with the spec's ASCII rows."""
rows, grid = level.spec.grid, level.grid
problems: list[str] = []
if len(rows) != grid.height:
problems.append(f"spec has {len(rows)} row(s), geometry has {grid.height}")
width = len(rows[0]) if rows else 0
if width != grid.width:
problems.append(f"spec rows are {width} char(s) wide, geometry is {grid.width}")
if problems:
_add(into, RuleId.GRID_SHAPE, level, "; ".join(problems))
def _check_spawns(level: Level, into: list[LevelViolation]) -> None:
grid = level.grid
if not level.spawns:
_add(into, RuleId.SPAWN_COUNT, level, "level has no player spawns")
return
for x, y in level.spawns:
if not grid.is_walkable(x, y):
_add(
into,
RuleId.SPAWN_ON_FLOOR,
level,
f"spawn ({x},{y}) is not on a walkable floor tile",
x=x,
y=y,
)
if len(level.spawns) < 2 or not all(grid.is_walkable(x, y) for x, y in level.spawns):
return # SPAWN_ON_FLOOR already owns a broken spawn
anchor_x, anchor_y = level.spawns[0]
from_anchor = grid.bfs_reachable(_cell(grid, anchor_x, anchor_y))
for x, y in level.spawns[1:]:
if _cell(grid, x, y) not in from_anchor:
_add(
into,
RuleId.SPAWNS_MUTUALLY_REACHABLE,
level,
f"spawn ({x},{y}) cannot be reached from spawn ({anchor_x},{anchor_y})",
x=x,
y=y,
)
def _check_stations(
level: Level, reachable: frozenset[int], into: list[LevelViolation]
) -> tuple[set[str], set[str]]:
"""Every station cell must sit in the spawn-reachable set.
Returns what the recipes may rely on: the values of the reachable station
*kinds*, and the values of the items reachable spawn shelves produce.
"""
grid = level.grid
reachable_kinds: set[str] = set()
shelf_items: set[str] = set()
for station in level.stations:
x, y = int(station.x), int(station.y)
if _cell(grid, x, y) not in reachable:
rule = (
RuleId.SERVE_REACHABLE
if station.kind is StationKind.SERVE
else RuleId.STATION_REACHABLE
)
_add(
into,
rule,
level,
f"{station.kind.value} station {station.id} at ({x},{y}) is not reachable "
f"from any spawn",
x=x,
y=y,
station_id=station.id,
)
continue
reachable_kinds.add(station.kind.value)
item = level.station_items.get(station.id)
if item is not None:
shelf_items.add(item.value)
return reachable_kinds, shelf_items
def _check_order_pool(
level: Level,
book: RecipeBook,
reachable_kinds: set[str],
shelf_items: set[str],
into: list[LevelViolation],
) -> None:
"""Pool recipes exist, are cookable on reachable stations, from reachable goods."""
for recipe_id in sorted({r for r in level.orders.pool if r not in book.recipes}):
_add(
into,
RuleId.ORDER_POOL_UNKNOWN_RECIPE,
level,
f"orders.pool names recipe {recipe_id!r}, which the recipe book does not define",
recipe=recipe_id,
)
for recipe_id in dict.fromkeys(level.orders.pool):
if recipe_id not in book.recipes:
continue
for kind_name in sorted(
kind.value
for kind in book.required_station_kinds(recipe_id)
if kind.value not in reachable_kinds
):
_add(
into,
RuleId.RECIPE_STATION_MISSING,
level,
f"recipe {recipe_id!r} needs a reachable {kind_name} station; the level has none",
recipe=recipe_id,
station=kind_name,
)
recipe = book.get(recipe_id)
raw_kinds = {
ingredient.kind.value
for ingredient in recipe.ingredients
if ingredient.form is ItemForm.RAW
}
for item_name in sorted(raw_kinds - shelf_items):
_add(
into,
RuleId.RECIPE_INGREDIENT_UNAVAILABLE,
level,
f"recipe {recipe_id!r} needs raw {item_name} but no reachable spawn shelf "
f"produces it",
recipe=recipe_id,
item=item_name,
)
def _check_target(
level: Level,
book: RecipeBook,
errors: list[LevelViolation],
warnings: list[LevelViolation],
) -> None:
cap = theoretical_max_score(level, book)
if cap is None:
return
if level.target_score > TARGET_REJECT_FRACTION * cap:
_add(
errors,
RuleId.TARGET_IMPLAUSIBLE,
level,
f"target_score {level.target_score} is above "
f"{TARGET_REJECT_FRACTION:.0%} of the theoretical maximum {cap:.0f}; "
f"no one could ever reach it",
target=level.target_score,
theoretical_max=cap,
)
elif level.target_score > TARGET_WARN_FRACTION * cap:
_add(
warnings,
RuleId.TARGET_IMPLAUSIBLE,
level,
f"target_score {level.target_score} is above "
f"{TARGET_WARN_FRACTION:.0%} of the theoretical maximum {cap:.0f}; "
f"winnable but tight",
target=level.target_score,
theoretical_max=cap,
)
def _check_time_limit(level: Level, into: list[LevelViolation]) -> None:
if level.time_limit < MIN_TIME_LIMIT_S:
_add(
into,
RuleId.TIME_LIMIT_INVALID,
level,
f"time_limit {level.time_limit:g}s is below the {MIN_TIME_LIMIT_S:g}s minimum",
time_limit=level.time_limit,
)
def _check_hazard_tile(
level: Level,
x: int,
y: int,
reachable: frozenset[int],
into: list[LevelViolation],
*,
label: str,
) -> None:
grid = level.grid
if not grid.in_bounds(x, y):
_add(
into,
RuleId.HAZARD_ON_UNREACHABLE_TILE,
level,
f"hazard {label} at ({x},{y}) is outside the {grid.width}x{grid.height} grid",
x=x,
y=y,
)
elif _cell(grid, x, y) not in reachable:
_add(
into,
RuleId.HAZARD_ON_UNREACHABLE_TILE,
level,
f"hazard {label} at ({x},{y}) is not reachable from any spawn "
f"(not a walkable tile or cut off from the cooks)",
x=x,
y=y,
)
def _check_hazards(level: Level, reachable: frozenset[int], into: list[LevelViolation]) -> None:
station_ids = {station.id for station in level.stations}
for index, hazard in enumerate(level.hazards):
if hazard.station is not None and hazard.station not in station_ids:
_add(
into,
RuleId.HAZARD_REF_UNKNOWN,
level,
f"hazard {index} ({hazard.type.value}) references unknown "
f"station {hazard.station!r}",
hazard=index,
station=hazard.station,
)
if hazard.x is not None and hazard.y is not None:
_check_hazard_tile(level, hazard.x, hazard.y, reachable, into, label=str(index))
for point_index, (x, y) in enumerate(hazard.path or ()):
_check_hazard_tile(
level,
x,
y,
reachable,
into,
label=f"{index} path point {point_index}",
)
def _check_station_limits(level: Level, into: list[LevelViolation]) -> None:
limits = level.station_limits
for name, value in (("plates", limits.plates), ("pots", limits.pots)):
if value < 1:
_add(
into,
RuleId.STATION_LIMIT_ZERO,
level,
f"station_limits.{name} must be at least 1, got {value}",
limit=name,
value=value,
)
def _check_travel(level: Level, into: list[LevelViolation]) -> None:
if level.difficulty < TRAVEL_MIN_DIFFICULTY:
return
tiles = _mean_travel_tiles(level)
if tiles < MIN_TRAVEL_TILES:
_add(
into,
RuleId.TRAVEL_TOO_SHORT,
level,
f"mean spawn-to-serve travel is {tiles:.1f} tiles, below the "
f"{MIN_TRAVEL_TILES:g}-tile minimum for difficulty {level.difficulty}",
travel_tiles=tiles,
)
def _check_escalation(level: Level, prev: Level, into: list[LevelViolation]) -> None:
axes = escalation_axes(level)
before = escalation_axes(prev)
moved = sorted(name for name, value in axes.items() if value > before[name] + AXIS_EPS)
if len(moved) < MIN_ESCALATED_AXES:
moved_text = ", ".join(moved) if moved else "none"
_add(
into,
RuleId.DIFFICULTY_ORDER,
level,
f"only {len(moved)} escalation axis moves up vs {prev.id} "
f"({moved_text}); at least {MIN_ESCALATED_AXES} are required",
moved=tuple(moved),
)
# --- public entry points -----------------------------------------------------------
def validate_level(
level: Level, book: RecipeBook, prev: Level | None = None
) -> ValidationReport:
"""Run every per-level rule against one built level.
`prev` (the campaign predecessor) additionally enables the
`DIFFICULTY_ORDER` escalation check; without it a level stands alone.
"""
errors: list[LevelViolation] = []
warnings: list[LevelViolation] = []
_check_grid_shape(level, errors)
reachable = reachable_cells(level)
_check_spawns(level, errors)
reachable_kinds, shelf_items = _check_stations(level, reachable, errors)
_check_order_pool(level, book, reachable_kinds, shelf_items, errors)
_check_time_limit(level, errors)
_check_target(level, book, errors, warnings)
_check_hazards(level, reachable, errors)
_check_station_limits(level, errors)
_check_travel(level, errors)
if prev is not None:
_check_escalation(level, prev, errors)
return ValidationReport(violations=tuple(errors), warnings=tuple(warnings))
def validate_levels(levels: Sequence[Level], book: RecipeBook) -> ValidationReport:
"""Every level plus the bundle-wide rules, in the campaign order given.
Pass levels `(order, id)`-ordered (what `ContentBundle.levels` already
is): consecutive pairs get the `DIFFICULTY_ORDER` escalation check, and
the campaign's declared `difficulty` must never drop. Duplicate ids are
reported here even though `load_content` refuses them earlier, so a
hand-assembled level list cannot smuggle one through.
"""
violations: list[LevelViolation] = []
warnings: list[LevelViolation] = []
counts: dict[str, int] = {}
for level in levels:
counts[level.id] = counts.get(level.id, 0) + 1
for level_id, seen in sorted(counts.items()):
if seen > 1:
violations.append(
LevelViolation(
rule=RuleId.DUPLICATE_ID,
level_id=level_id,
message=(
f"level id {level_id!r} appears {seen} times in the bundle; "
f"ids must be unique"
),
detail={"count": seen},
)
)
previous: Level | None = None
for level in levels:
report = validate_level(level, book, previous)
violations.extend(report.violations)
warnings.extend(report.warnings)
if previous is not None and level.difficulty < previous.difficulty:
violations.append(
LevelViolation(
rule=RuleId.DIFFICULTY_ORDER,
level_id=level.id,
message=(
f"difficulty {level.difficulty} drops below {previous.id}'s "
f"{previous.difficulty}; the campaign must never get easier"
),
detail={"previous_id": previous.id},
)
)
previous = level
return ValidationReport(violations=tuple(violations), warnings=tuple(warnings))
def validate_bundle(bundle: ContentBundle) -> ValidationReport:
"""Per-level validation plus the bundle rules, in campaign order."""
return validate_levels(bundle.levels, bundle.recipe_book)
+696
View File
@@ -0,0 +1,696 @@
"""``undercooked-levels`` — the author-facing content CLI (PLAN §8, A8).
Everything here is a thin shell over the *same* code the server runs at startup:
`sim.content.load_content` (YAML -> campaign-ordered levels), `sim.validation`
(PLAN §8's named rules) and `server.routes_content.resolve_recipes_path` (the
recipe book's location). There is no second implementation of the content
contract, so a level that validates here validates in `create_app` — and the
tool never enumerates levels or recipes either: it prints whatever the loaded
bundle contains.
Subcommands (``--help`` for the full text):
``validate``
one line per level, then `ValidationReport.render()` (or `--json`), exit 1
on any violation. This is the gate `.agents/validate.sh` runs.
``list``
one line per level: order, id, difficulty, target, time, order pool.
``show ID``
the resolved level summary; ``--grid`` adds the ASCII map plus a
reachability overlay (``.`` reachable, ``?`` unreachable, ``#`` blocking).
``new ID``
writes a scaffolded level (built from the recipe book, never from a
hard-coded template kitchen) and validates that one file.
``axes``
the escalation-axis table for the whole campaign (feeds phase 11).
Exit codes: ``0`` success, ``1`` content violates at least one rule, ``2`` the
tool could not do its job (content would not load, an unknown level id, a file
`new` refuses to overwrite). argparse's own usage errors are ``2`` too.
Path resolution is `Settings`-driven, never cwd-driven: `--dir` beats
`UNDERCOOKED_LEVELS_DIR`, which beats the built-in ``levels`` — and a *relative*
path that does not exist under the current directory is anchored to this
checkout, so the command works from `/tmp` exactly as it does from the repo root
(the same trick `server.app.STATIC_DIR` plays for the browser assets).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
import textwrap
from collections import Counter
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import replace
from pathlib import Path
from typing import Final
from undercooked.config import Settings, load_settings
from undercooked.server.routes_content import resolve_recipes_path
from undercooked.sim.content import (
ContentBundle,
ContentError,
load_content,
load_level_file,
)
from undercooked.sim.level import Level
from undercooked.sim.model import ItemForm, ItemKind, StationKind
from undercooked.sim.recipe import RecipeBook
from undercooked.sim.validation import (
AXIS_NAMES,
LevelViolation,
ValidationReport,
escalation_axes,
reachable_cells,
validate_bundle,
validate_level,
)
__all__ = [
"EXIT_OK",
"EXIT_UNUSABLE",
"EXIT_VIOLATIONS",
"build_parser",
"main",
"render_starter_yaml",
"report_json",
"resolve_content_paths",
]
EXIT_OK: Final = 0
#: Content exists but breaks a PLAN §8 rule (`validate`, `new`).
EXIT_VIOLATIONS: Final = 1
#: The tool could not act: load failure, unknown id, refused overwrite.
EXIT_UNUSABLE: Final = 2
#: This checkout's root (`src/undercooked/tools/levels.py` -> repo root), the
#: anchor for a relative content path when the current directory has none.
CHECKOUT_ROOT: Final = Path(__file__).resolve().parents[3]
#: A new level id is a filename-safe slug: `NN_name` by campaign convention.
LEVEL_ID_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9_]*$")
#: Column width of the `axes` table.
AXIS_WIDTH: Final = 20
#: Tunables of a scaffolded level: deliberately gentle, so the first thing an
#: author sees is their own content error and not a plausibility complaint.
STARTER_TIME_LIMIT_S: Final = 150
STARTER_TARGET_SCORE: Final = 60
STARTER_CONCURRENT: Final = 1
STARTER_SPAWN_INTERVAL: Final = (12.0, 16.0)
STARTER_DEADLINE_S: Final = 60
STARTER_HEIGHT: Final = 7
#: Grid letters the scaffold prefers per item kind (shelves stand on row 1).
_ITEM_CHARS: Final[Mapping[str, str]] = {
ItemKind.TOMATO.value: "T",
ItemKind.ONION.value: "O",
ItemKind.LETTUCE.value: "L",
ItemKind.RICE.value: "R",
ItemKind.FISH.value: "F",
ItemKind.DOUGH.value: "H",
ItemKind.PATTY.value: "Y",
ItemKind.PLATE.value: "E",
}
#: Grid letters the scaffold prefers per station kind (fixtures stand on row 4).
_STATION_CHARS: Final[Mapping[str, str]] = {
StationKind.CHOP_BOARD.value: "C",
StationKind.STOVE.value: "S",
StationKind.PREP.value: "P",
StationKind.SERVE.value: "X",
StationKind.SINK.value: "K",
StationKind.TRASH.value: "Z",
}
#: Fallback letters, used when a preferred one is already taken.
_SPARE_CHARS: Final = "ABDGIJMNQUVW"
class _CliError(Exception):
"""A failure the CLI reports as one stderr line plus a fixed exit code."""
def __init__(self, message: str, code: int = EXIT_UNUSABLE) -> None:
super().__init__(message)
self.code = code
# --- paths ---------------------------------------------------------------------
def _anchor(path: Path) -> Path:
"""Make a content path absolute without trusting the current directory.
Absolute wins; else the current directory if the path is real there; else
this checkout (so the documented `undercooked-levels validate` works from
anywhere); else the path unchanged, so the error names what was configured.
"""
expanded = path.expanduser()
if expanded.is_absolute():
return expanded
from_cwd = Path.cwd() / expanded
if from_cwd.exists():
return from_cwd
anchored = CHECKOUT_ROOT / expanded
return anchored if anchored.exists() else expanded
def resolve_content_paths(
settings: Settings,
levels_override: Path | None = None,
recipes_override: Path | None = None,
) -> tuple[Path, Path]:
"""Resolve ``(levels_dir, recipes_path)``: flag > environment > derived.
The derived half is `server.routes_content.resolve_recipes_path` — the same
resolution `create_app` uses — so the CLI and the server can never look for
`recipes.yaml` in two different places.
"""
levels_dir = _anchor(levels_override if levels_override is not None else settings.levels_dir)
recipes_path = resolve_recipes_path(replace(settings, levels_dir=levels_dir))
if recipes_override is not None:
recipes_path = _anchor(recipes_override)
return levels_dir, recipes_path
def _load_bundle(levels_dir: Path, recipes_path: Path) -> ContentBundle:
"""`load_content` with the load failure turned into a reported exit 2."""
try:
return load_content(levels_dir, recipes_path)
except ContentError as exc:
raise _CliError(str(exc)) from exc
def _bundle_for(args: argparse.Namespace) -> ContentBundle:
levels_dir, recipes_path = resolve_content_paths(load_settings(), args.dir, args.recipes)
return _load_bundle(levels_dir, recipes_path)
# --- shared rendering -----------------------------------------------------------
def _sorted_items(items: Sequence[LevelViolation]) -> tuple[LevelViolation, ...]:
"""Deterministic order for JSON output (the text report sorts in `render`)."""
return tuple(sorted(items, key=lambda item: (item.level_id, item.rule, item.message)))
def _field(label: str, values: Sequence[str]) -> Iterator[str]:
"""One aligned ``label value`` line per value (labels are 17 columns wide)."""
for index, value in enumerate(values):
yield f"{label:<18}{value}" if index == 0 else f"{'':<18}{value}"
def _wrapped(text: str, *, placeholder: str) -> list[str]:
"""Author prose, wrapped; an empty field says so instead of printing nothing."""
return textwrap.wrap(text, width=76) or [placeholder]
def _station_summary(level: Level) -> str:
counts = Counter(station.kind.value for station in level.stations)
return ", ".join(f"{kind} x{counts[kind]}" for kind in sorted(counts)) or "none"
def _hazard_summary(level: Level) -> str:
counts = Counter(hazard.type.value for hazard in level.hazards)
return ", ".join(f"{kind} x{counts[kind]}" for kind in sorted(counts)) or "none"
def _recipe_line(book: RecipeBook, recipe_id: str) -> str:
"""One order-pool entry resolved against the book (or its absence)."""
recipe = book.recipes.get(recipe_id)
if recipe is None:
return f"{recipe_id}: MISSING from the recipe book"
kinds = ", ".join(sorted(kind.value for kind in book.required_station_kinds(recipe_id)))
return f"{recipe_id}: {recipe.name}, {recipe.base_score} pts, needs {kinds}"
def describe_level(level: Level, book: RecipeBook) -> list[str]:
"""The `show` summary: the resolved level, one labelled line per fact."""
orders = level.orders
low, high = orders.spawn_interval
reachable = len(reachable_cells(level))
walkable = len(level.grid.walkable_cells())
limits = level.station_limits
facts: list[tuple[str, list[str]]] = [
("order", [str(level.order)]),
("difficulty", [str(level.difficulty)]),
("time_limit", [f"{level.time_limit:g} s"]),
("target_score", [str(level.target_score)]),
("grid", [f"{level.grid.width}x{level.grid.height}"]),
("spawns", [", ".join(f"({x}, {y})" for x, y in level.spawns) or "none"]),
("stations", [_station_summary(level)]),
(
"orders",
[
f"pool={','.join(orders.pool)} concurrent={orders.concurrent} "
f"interval={low:g}-{high:g}s deadline={orders.deadline:g}s "
f"penalty={orders.penalty}"
],
),
("limits", [f"plates={limits.plates} pots={limits.pots}"]),
("hazards", [_hazard_summary(level)]),
(
"reachability",
[f"{reachable}/{walkable} walkable tiles reachable from {len(level.spawns)} spawn(s)"],
),
("recipes", [_recipe_line(book, recipe_id) for recipe_id in dict.fromkeys(orders.pool)]),
("blurb", _wrapped(level.blurb, placeholder="(none)")),
("escalation_notes", _wrapped(level.escalation_notes, placeholder="(none)")),
]
lines = [f"{level.id} - {level.name}"]
for label, values in facts:
lines.extend(_field(label, values))
return lines
def reachability_overlay(level: Level) -> list[str]:
"""The grid re-drawn as terrain: ``.``, ``?`` (walkable, unreachable), ``#``."""
grid = level.grid
reachable = reachable_cells(level)
return [
"".join(
"#"
if not grid.is_walkable(x, y)
else "."
if y * grid.width + x in reachable
else "?"
for x in range(grid.width)
)
for y in range(grid.height)
]
def _grid_lines(level: Level) -> list[str]:
"""The authored ASCII map plus the overlay under it."""
overlay = reachability_overlay(level)
reachable = sum(row.count(".") for row in overlay)
walkable = sum(row.count(c) for row in overlay for c in ".?")
return [
"",
"map (as authored)",
*(f" {row}" for row in level.spec.grid),
"",
"reachability . reachable ? walkable but cut off # blocking",
*(f" {row}" for row in overlay),
"",
f" {reachable}/{walkable} walkable tiles are reachable from the spawns",
]
# --- validate -------------------------------------------------------------------
def report_json(report: ValidationReport, bundle: ContentBundle) -> dict[str, object]:
"""The machine-readable report: `errors`/`warnings` lists plus per level.
Key names are part of the tool's contract with scripts
(`undercooked-levels validate --json | python -m json.tool`): `errors` and
`warnings` are the rule hits themselves — `"errors": []` means green — and
`counts` keeps the totals readable without counting list entries.
"""
violations = _sorted_items(report.violations)
warnings = _sorted_items(report.warnings)
return {
"ok": report.ok,
"counts": {"errors": len(violations), "warnings": len(warnings)},
"levels": [
{
"id": level.id,
"order": level.order,
"difficulty": level.difficulty,
"errors": len(report.errors_for(level.id)),
"warnings": sum(1 for w in report.warnings if w.level_id == level.id),
}
for level in bundle.levels
],
"errors": [_violation_json(violation) for violation in violations],
"warnings": [_violation_json(warning) for warning in warnings],
}
def _violation_json(violation: LevelViolation) -> dict[str, object]:
"""One rule hit as stable JSON (tuples in `detail` become arrays)."""
return {
"level": violation.level_id,
"rule": violation.rule,
"message": violation.message,
"detail": dict(violation.detail),
}
def _level_status(level: Level, report: ValidationReport) -> str:
errors = len(report.errors_for(level.id))
warnings = sum(1 for warning in report.warnings if warning.level_id == level.id)
verdict = "ok" if not errors else f"{errors} error(s)"
if warnings:
verdict += f", {warnings} warning(s)"
geometry = f"{level.grid.width}x{level.grid.height}, {len(level.stations)} stations"
return f"{level.id:<28} {verdict:<24} {geometry}"
def _cmd_validate(args: argparse.Namespace) -> int:
bundle = _bundle_for(args)
report = validate_bundle(bundle)
if args.json:
print(json.dumps(report_json(report, bundle), indent=2, sort_keys=True))
else:
for level in bundle.levels:
print(_level_status(level, report))
print(report.render())
return EXIT_OK if report.ok else EXIT_VIOLATIONS
# --- list -----------------------------------------------------------------------
LIST_HEADER: Final = f"{'ORDER':>5} {'ID':<28}{'DIFF':>4} {'TARGET':>6} {'TIME':>6} RECIPES"
def _cmd_list(args: argparse.Namespace) -> int:
bundle = _bundle_for(args)
print(LIST_HEADER)
for level in bundle.levels:
print(
f"{level.order:>5} {level.id:<28}{level.difficulty:>4} "
f"{level.target_score:>6} {level.time_limit:>6.1f} {', '.join(level.orders.pool)}"
)
return EXIT_OK
# --- show -----------------------------------------------------------------------
def _cmd_show(args: argparse.Namespace) -> int:
bundle = _bundle_for(args)
level = bundle.by_id.get(args.id)
if level is None:
known = ", ".join(sorted(bundle.by_id)) or "(no levels loaded)"
raise _CliError(f"no level {args.id!r} in this campaign; known ids: {known}")
print("\n".join(describe_level(level, bundle.recipe_book)))
if args.grid:
print("\n".join(_grid_lines(level)))
return EXIT_OK
# --- axes -----------------------------------------------------------------------
def _axes_header() -> str:
return f"{'LEVEL':<28}{'ORDER':>6}" + "".join(f"{name:>{AXIS_WIDTH}}" for name in AXIS_NAMES)
def _axes_row(level: Level) -> str:
axes = escalation_axes(level)
cells = "".join(f"{axes[name]:>{AXIS_WIDTH}.4f}" for name in AXIS_NAMES)
return f"{level.id:<28}{level.order:>6}{cells}"
def _cmd_axes(args: argparse.Namespace) -> int:
bundle = _bundle_for(args)
print(_axes_header())
for level in bundle.levels:
print(_axes_row(level))
return EXIT_OK
# --- new ------------------------------------------------------------------------
def _char_for(preferred: str | None, used: set[str]) -> str:
"""The preferred grid letter when it is free, else the first free spare."""
if preferred is not None and preferred not in used:
return preferred
return next(char for char in _SPARE_CHARS if char not in used)
def _assign_chars(keys: Sequence[str], table: Mapping[str, str], used: set[str]) -> dict[str, str]:
"""One unique grid character per key, deterministic in the key order given."""
assigned: dict[str, str] = {}
for key in keys:
char = _char_for(table.get(key), used)
used.add(char)
assigned[key] = char
return assigned
def _display_name(level_id: str) -> str:
"""`07_riverboat` -> `Riverboat`; the author edits it immediately anyway."""
return level_id.split("_", 1)[-1].replace("_", " ").title()
def _starter_content(
book: RecipeBook,
) -> tuple[str, list[ItemKind], list[StationKind]]:
"""(recipe id, raw ingredients to shelf, station kinds to place) for the scaffold.
The recipe book decides the template kitchen — the first dish it defines and
exactly the stations that dish's steps name — because nothing in Python may
enumerate content (A8).
"""
recipe_ids = book.ids()
if not recipe_ids:
raise _CliError("the recipe book defines no recipes, so there is nothing to scaffold")
recipe_id = recipe_ids[0]
recipe = book.get(recipe_id)
items = list(dict.fromkeys(
ingredient.kind
for ingredient in recipe.ingredients
if ingredient.form is ItemForm.RAW
))
required = book.required_station_kinds(recipe_id) | {StationKind.SERVE}
kinds = sorted(required - {StationKind.SPAWN_SHELF}, key=lambda kind: kind.value)
return recipe_id, items, kinds
def render_starter_yaml(*, level_id: str, order: int, difficulty: int, book: RecipeBook) -> str:
"""The `new` template: a valid, minimal kitchen for the book's first dish.
Geometry is generated, not pasted: one spawn shelf per raw ingredient on
row 1, one station per required kind on row 4, and two spawns on the bottom
row far enough from the serve window to clear the `TRAVEL_TOO_SHORT` floor.
"""
recipe_id, items, kinds = _starter_content(book)
used = {"#", "."}
shelf_chars = _assign_chars([item.value for item in items], _ITEM_CHARS, used)
station_chars = _assign_chars([kind.value for kind in kinds], _STATION_CHARS, used)
width = max(13, 2 + 2 * max(len(items), len(kinds), 1))
rows = [["."] * width for _ in range(STARTER_HEIGHT)]
for index in range(width):
rows[0][index] = rows[STARTER_HEIGHT - 1][index] = "#"
for index, item in enumerate(items):
rows[1][2 + 2 * index] = shelf_chars[item.value]
for index, kind in enumerate(kinds):
rows[4][2 + 2 * index] = station_chars[kind.value]
spawns = [(1, STARTER_HEIGHT - 2), (width - 2, STARTER_HEIGHT - 2)]
tiles: dict[str, dict[str, str]] = {"#": {"kind": "wall"}, ".": {"kind": "floor"}}
for item in items:
tiles[shelf_chars[item.value]] = {
"kind": "floor",
"station": StationKind.SPAWN_SHELF.value,
"item": item.value,
}
for kind in kinds:
tiles[station_chars[kind.value]] = {"kind": "floor", "station": kind.value}
low, high = STARTER_SPAWN_INTERVAL
return "\n".join([
f"# levels/{level_id}.yaml — scaffolded by `undercooked-levels new`.",
"# Schema, every field and every rule: docs/LEVELS.md.",
"# Next steps: edit the grid and the prose, then re-run",
"# uv run undercooked-levels validate --dir <this directory>",
"# uv run undercooked-levels axes --dir <this directory>",
"# The campaign rule (DIFFICULTY_ORDER) wants >= 2 escalation axes up",
"# versus the previous level, and a difficulty that never drops.",
"",
f"id: {level_id}",
f"name: {_display_name(level_id)}",
f"order: {order}",
f"difficulty: {difficulty}",
f"time_limit: {STARTER_TIME_LIMIT_S}",
f"target_score: {STARTER_TARGET_SCORE}",
"blurb: >-",
f" A scaffolded kitchen around one dish ({recipe_id}). Rewrite this",
" paragraph for the level a player will actually read.",
"grid:",
*(f' - "{"".join(row)}"' for row in rows),
"tiles:",
*(f' "{char}": {json.dumps(spec)}' for char, spec in tiles.items()),
f"spawns: [{', '.join(f'[{x}, {y}]' for x, y in spawns)}]",
"orders:",
f" pool: [{recipe_id}]",
f" concurrent: {STARTER_CONCURRENT}",
f" spawn_interval: [{low:g}, {high:g}]",
f" deadline: {STARTER_DEADLINE_S}",
"hazards: []",
"escalation_notes: >-",
" TODO: name the axes this level moves up versus its predecessor.",
"",
])
def _new_level_id(candidate: str) -> str:
if not LEVEL_ID_PATTERN.match(candidate):
raise _CliError(
f"level id {candidate!r} is not a filename-safe slug "
"(lowercase letters, digits and underscores, starting with a letter or digit)"
)
return candidate
def _cmd_new(args: argparse.Namespace) -> int:
settings = load_settings()
levels_dir, recipes_path = resolve_content_paths(settings, args.dir, args.recipes)
level_id = _new_level_id(args.id)
if args.order is not None and args.order < 1:
raise _CliError(f"--order must be at least 1, got {args.order}")
bundle = _load_bundle(levels_dir, recipes_path)
target = levels_dir / f"{level_id}.yaml"
if target.exists():
raise _CliError(f"{target} already exists; refusing to overwrite an author's level")
orders = [level.order for level in bundle.levels]
difficulties = [level.difficulty for level in bundle.levels]
order = args.order if args.order is not None else max(orders, default=0) + 1
difficulty = min(10, max(difficulties, default=0) + 1)
text = render_starter_yaml(
level_id=level_id,
order=order,
difficulty=difficulty,
book=bundle.recipe_book,
)
try:
target.write_text(text, encoding="utf-8")
except OSError as exc:
reason = exc.strerror or str(exc)
raise _CliError(f"cannot write {target}: {reason}") from exc
print(f"wrote {target}")
try:
level = load_level_file(target)
except ContentError as exc:
raise _CliError(str(exc)) from exc
# The new file on its own: the campaign's escalation rule needs neighbours,
# and a scaffolded level is not tuned against them yet by definition.
report = validate_level(level, bundle.recipe_book)
print(report.render())
return EXIT_OK if report.ok else EXIT_VIOLATIONS
# --- entry point -----------------------------------------------------------------
def _add_content_flags(parser: argparse.ArgumentParser) -> None:
"""The `--dir` / `--recipes` pair every subcommand needs to find content."""
parser.add_argument(
"--dir",
type=Path,
default=None,
metavar="PATH",
help="levels directory (default: $UNDERCOOKED_LEVELS_DIR, else levels/)",
)
parser.add_argument(
"--recipes",
type=Path,
default=None,
metavar="PATH",
help="recipe book (default: $UNDERCOOKED_RECIPES_PATH, else <dir>/recipes.yaml)",
)
def build_parser() -> argparse.ArgumentParser:
"""The subcommand surface of `undercooked-levels`."""
parser = argparse.ArgumentParser(
prog="undercooked-levels",
description=(
"Inspect and validate Undercooked level content: the same loader and "
"the same PLAN §8 rules the server runs at startup."
),
epilog=(
"exit codes: 0 ok, 1 a level breaks a validation rule, "
"2 nothing usable to work with (load failure, unknown id, refused overwrite)"
),
)
subcommands = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
validate = subcommands.add_parser(
"validate",
help="validate every level and print the named-rule report",
description="Validate the campaign: one line per level, then the rule report.",
)
_add_content_flags(validate)
validate.add_argument(
"--json",
action="store_true",
help="emit a machine-readable report instead of text",
)
validate.set_defaults(handler=_cmd_validate)
listing = subcommands.add_parser(
"list",
help="one line per level, in campaign order",
description="List the campaign: order, id, difficulty, target, time, order pool.",
)
_add_content_flags(listing)
listing.set_defaults(handler=_cmd_list)
show = subcommands.add_parser(
"show",
help="resolved summary of one level",
description="Show one resolved level; --grid adds the ASCII map and overlay.",
)
show.add_argument("id", help="level id to inspect (see the `list` subcommand)")
show.add_argument(
"--grid",
action="store_true",
help="print the ASCII map plus the reachability overlay",
)
_add_content_flags(show)
show.set_defaults(handler=_cmd_show)
new = subcommands.add_parser(
"new",
help="scaffold a starter level into the levels directory",
description="Write a starter level (from the recipe book) and validate that file.",
)
new.add_argument("id", help="new level id, e.g. 03_riverboat")
new.add_argument(
"--order",
type=int,
default=None,
metavar="N",
help="campaign order (default: one past the highest in the directory)",
)
_add_content_flags(new)
new.set_defaults(handler=_cmd_new)
axes = subcommands.add_parser(
"axes",
help="escalation-axis table for the whole campaign",
description="Print every level's escalation axes (higher = harder), campaign order.",
)
_add_content_flags(axes)
axes.set_defaults(handler=_cmd_axes)
return parser
def main(argv: Sequence[str] | None = None) -> int:
"""Entry point of the `undercooked-levels` console script."""
try:
args = build_parser().parse_args(argv)
except SystemExit as exc: # usage error -> 2, `--help` -> 0
return int(exc.code or EXIT_OK)
handler = args.handler
try:
return int(handler(args))
except _CliError as exc:
print(f"undercooked-levels: {exc}", file=sys.stderr)
return exc.code
# `python -m undercooked.tools.levels` runs this; coverage measures the parent
# process only (tests/unit/test_levels_cli.py drives `main` directly).
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())
+6 -3
View File
@@ -42,10 +42,13 @@ def _as_env_value(value: object) -> str:
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
def isolated_levels_dir() -> Iterator[None]: def isolated_levels_dir() -> Iterator[None]:
"""Point `UNDERCOOKED_LEVELS_DIR` at an empty fixture dir for every test. """Point `UNDERCOOKED_LEVELS_DIR` at this level-free fixture dir for every test.
No test may read the real `levels/` content implicitly; the shipped No test may read the real `levels/` content implicitly. Since phase 02
content pipeline only arrives with phase 02. task 04 `create_app()` also needs a recipe book, and the dir carries a
committed minimal `recipes.yaml` (zero levels — a legal campaign) so
fixture-isolated apps still boot; tests that need real levels build one
under `tmp_path` or point explicitly at `levels/`.
""" """
FIXTURE_LEVELS_DIR.mkdir(parents=True, exist_ok=True) FIXTURE_LEVELS_DIR.mkdir(parents=True, exist_ok=True)
previous = os.environ.get("UNDERCOOKED_LEVELS_DIR") previous = os.environ.get("UNDERCOOKED_LEVELS_DIR")
+46
View File
@@ -0,0 +1,46 @@
# A deliberately broken level for `undercooked-levels validate` (phase 02 task 05).
#
# It *loads* (the schema is satisfied) and then breaks three PLAN §8 rules, so
# the CLI's exit-1 path, its named-rule report and the `show --grid` "?" overlay
# all have a stable input to be tested against:
#
# * the right-hand box (x6..x9, y1..y2) is sealed on all four sides, so the
# serve window inside it is cut off from the spawn -> SERVE_REACHABLE, and
# those same cells show up as `?` in `show --grid`;
# * the recipe's steps name a stove this kitchen does not have
# -> RECIPE_STATION_MISSING;
# * target_score 999 is far past 60 % of the theoretical maximum
# -> TARGET_IMPLAUSIBLE.
#
# The `_bad` directory name opts out of content discovery (`_is_draft`), so this
# campaign is only loaded when something points `--dir` straight at it — the
# server and the rest of the test suite never see it.
id: 99_broken_room
name: Broken Room
order: 1
difficulty: 5
time_limit: 60
target_score: 999
blurb: A kitchen with a sealed-off servery, the fixture for the validator itself.
grid:
- "###########"
- "#T..C#..V.#"
- "#....#....#"
- "#.#########"
- "#S........#"
- "###########"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"C": {kind: floor, station: chop_board}
"V": {kind: floor, station: serve}
"S": {kind: floor, station: spawn_shelf, item: onion}
spawns: [[1, 4]]
orders:
pool: [soup]
concurrent: 1
spawn_interval: [12, 16]
deadline: 60
hazards: []
escalation_notes: Fixture only, never shipped and never part of a real campaign.
+23
View File
@@ -0,0 +1,23 @@
# The recipe book for the `_bad` fixture campaign (phase 02 task 05).
#
# `undercooked-levels validate --dir tests/fixtures/levels/_bad` needs a book of
# its own so the fixture stays self-contained: the tests assert on the rule hits
# of `99_broken_room.yaml`, and those numbers depend on this book, not on the
# shipped `levels/recipes.yaml`. One dish, `soup`, whose steps name a
# `chop_board`, a `stove` and a `prep` station — the fixture kitchen omits the
# stove on purpose.
ingredients: [tomato, onion]
recipes:
soup:
id: soup
name: Tomato Soup
base_score: 40
ingredients:
- {kind: tomato, form: raw}
- {kind: onion, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5.0}
- {kind: plate, station: prep, seconds: 0.4}
+18
View File
@@ -0,0 +1,18 @@
# tests/fixtures/levels/recipes.yaml — the minimal book for the test-isolated
# levels directory (see tests/conftest.py `isolated_levels_dir`).
#
# A campaign with zero levels is legal (docs/LEVELS.md), but every
# `create_app()` still needs a recipe book to load. Tests that want actual
# levels build their own under `tmp_path`; the shipped campaign lives in
# `levels/` and is covered by tests/unit/test_shipped_content.py.
ingredients: [tomato]
recipes:
tomato_soup:
id: tomato_soup
name: Test Soup
base_score: 10
ingredients:
- {kind: tomato, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: serve, station: serve}
+234
View File
@@ -0,0 +1,234 @@
"""Content API contract over in-process HTTP (task 04).
`/api/levels`, `/api/levels/{id}` and `/api/config` are the only channels the
client uses for content and settings. These tests build the real app against
the shipped ``levels/`` campaign and assert the exact wire shape: shipped
ids in campaign order, responses that validate against the strict response
models (which are ``extra="forbid"``, so a leaked field is a failure, not a
silence), 404 for unknown ids, and the four-key client-config subset with no
secret-, operator- or origin-named key. A content bug must crash startup
(see ``tests/unit/test_content_startup.py``), never a request.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
from typing import Any
import pytest
from fastapi.testclient import TestClient
from undercooked.config import Settings
from undercooked.server.app import create_app
from undercooked.server.routes_content import (
PROTOCOL_VERSION,
ClientConfig,
LevelDetail,
LevelSummary,
)
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
SHIPPED_IDS = ["01_first_kitchen", "02_double_shift"]
#: Keys that must never appear anywhere in a content response. Layout and
#: internals stay server-side (PLAN §8); operator settings stay in config.py.
FORBIDDEN_KEYS = {
"grid",
"tiles",
"legend",
"spawns",
"spawn",
"stations",
"station",
"station_limits",
"orders",
"pool",
"steps",
"secret",
"password",
"origins",
}
@pytest.fixture(scope="module")
def campaign_client() -> Iterator[TestClient]:
with TestClient(create_app(Settings(levels_dir=SHIPPED_LEVELS))) as test_client:
yield test_client
def walk_keys(node: Any) -> set[str]:
"""Every JSON object key at any depth of a parsed response body."""
if isinstance(node, dict):
return set(node) | {k for child in node.values() for k in walk_keys(child)}
if isinstance(node, list):
return {k for child in node for k in walk_keys(child)}
return set()
# --- GET /api/levels -----------------------------------------------------------
def test_lists_the_shipped_levels_in_campaign_order(campaign_client: TestClient) -> None:
response = campaign_client.get("/api/levels")
assert response.status_code == 200
body = response.json()
assert [level["id"] for level in body] == SHIPPED_IDS
assert [level["order"] for level in body] == [1, 2]
def test_level_cards_match_the_response_model_exactly(
campaign_client: TestClient,
) -> None:
body = campaign_client.get("/api/levels").json()
for card in body:
# extra="forbid" on LevelSummary: an unexpected key fails this, and
# the explicit key-set check catches a *missing* field too.
LevelSummary.model_validate(card)
assert set(card) == set(LevelSummary.model_fields)
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_level_card_exposes_the_expected_fields(
campaign_client: TestClient, level_id: str
) -> None:
card = next(
level for level in campaign_client.get("/api/levels").json()
if level["id"] == level_id
)
assert isinstance(card["name"], str) and card["name"]
assert isinstance(card["blurb"], str) and len(card["blurb"]) >= 20
assert isinstance(card["difficulty"], int) and 1 <= card["difficulty"] <= 10
assert isinstance(card["target_score"], int)
assert isinstance(card["time_limit"], (int, float))
# Recipes exist — the ids the client may later send back in `join.ready`.
assert card["recipes"]
known_recipe_names = {"Tomato Soup", "Garden Salad", "Fried Rice", "Chef's Burger"}
assert set(card["recipes"]) <= known_recipe_names
# --- GET /api/levels/{id} -------------------------------------------------------
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_level_detail_matches_the_response_model(
campaign_client: TestClient, level_id: str
) -> None:
response = campaign_client.get(f"/api/levels/{level_id}")
assert response.status_code == 200
detail = response.json()
LevelDetail.model_validate(detail)
assert set(detail) == set(LevelSummary.model_fields) | {"hazards"}
assert detail["id"] == level_id
def test_level_detail_hazards_are_type_only(campaign_client: TestClient) -> None:
detail = campaign_client.get("/api/levels/02_double_shift").json()
# Neither shipped level carries a hazard; when one does, each entry is
# exactly {"type": ...} — kind only, never a position (US-07).
assert detail["hazards"] == []
def test_unknown_level_is_404(campaign_client: TestClient) -> None:
response = campaign_client.get("/api/levels/99_ghost_kitchen")
assert response.status_code == 404
def test_path_style_level_ids_cannot_reach_the_filesystem(
campaign_client: TestClient,
) -> None:
# Raw ".." would be normalised away by the HTTP client before it leaves;
# the encoded forms are what a hostile browser can actually send.
for hostile in ["..%2F..%2Fetc%2Fpasswd", "%2e%2e/levels", "%2e%2e"]:
response = campaign_client.get(f"/api/levels/{hostile}")
assert response.status_code == 404, hostile
# --- no internals leak ----------------------------------------------------------
@pytest.mark.parametrize(
"path",
[
"/api/levels",
"/api/levels/01_first_kitchen",
"/api/levels/02_double_shift",
"/api/config",
],
)
def test_no_internal_or_secret_keys_any_in_response(
campaign_client: TestClient, path: str
) -> None:
response = campaign_client.get(path)
assert response.status_code == 200
leaked = walk_keys(response.json()) & FORBIDDEN_KEYS
assert not leaked, f"{path} leaked keys: {sorted(leaked)}"
def test_no_grid_rows_leak_into_any_content_response(
campaign_client: TestClient,
) -> None:
for path in [
"/api/levels",
"/api/levels/01_first_kitchen",
"/api/levels/02_double_shift",
"/api/config",
]:
body = campaign_client.get(path).text
assert "####" not in body, f"{path} appears to carry a raw grid row"
# --- GET /api/config --------------------------------------------------------------
def test_config_exposes_exactly_the_client_safe_fields(
campaign_client: TestClient,
) -> None:
response = campaign_client.get("/api/config")
assert response.status_code == 200
body = response.json()
ClientConfig.model_validate(body)
assert set(body) == {"max_players", "tick_hz", "snapshot_hz", "protocol_version"}
assert body == {
"max_players": 4,
"tick_hz": 20,
"snapshot_hz": 20,
"protocol_version": PROTOCOL_VERSION,
}
def test_config_has_no_secret_operator_or_origin_named_key(
campaign_client: TestClient,
) -> None:
body = campaign_client.get("/api/config").json()
for key in body:
lowered = key.lower()
assert not any(
marker in lowered
for marker in ("secret", "password", "origins", "token", "key")
), key
def test_config_reflects_the_settings_built_into_this_app() -> None:
settings = Settings(levels_dir=SHIPPED_LEVELS, max_players=2, tick_hz=20, snapshot_hz=10)
with TestClient(create_app(settings)) as test_client:
body = test_client.get("/api/config").json()
assert body == {
"max_players": 2,
"tick_hz": 20,
"snapshot_hz": 10,
"protocol_version": PROTOCOL_VERSION,
}
# --- the test-isolated campaign ----------------------------------------------------
def test_the_env_isolated_app_starts_with_zero_levels(client: TestClient) -> None:
# The session's isolated levels dir holds only a recipe book (A8 allows
# a zero-level campaign); the app must still boot and answer cleanly.
response = client.get("/api/levels")
assert response.status_code == 200
assert response.json() == []
assert client.get("/api/levels/01_first_kitchen").status_code == 404
+207
View File
@@ -0,0 +1,207 @@
"""Unit tests for `undercooked.sim.grid` (phase 02 task 01).
Geometry, walkability, BFS around walls, and every `GridError` the ASCII
parser must refuse: ragged rows, unknown legend chars, empty/oversized maps.
"""
from __future__ import annotations
import pytest
from undercooked.sim.grid import MAX_GRID, Grid, GridError
from undercooked.sim.model import TileKind, Vec2
pytestmark = pytest.mark.unit
LEGEND = {
".": TileKind.FLOOR,
"#": TileKind.WALL,
"C": TileKind.COUNTER,
"o": TileKind.VOID,
}
def make_grid(rows: list[str]) -> Grid:
return Grid.from_rows(rows, LEGEND)
# --- Grid.from_rows --------------------------------------------------------------
def test_from_rows_lays_tiles_out_row_major() -> None:
grid = make_grid(["..#", "#.o", "C#."])
assert (grid.width, grid.height) == (3, 3)
assert grid.tiles == (
TileKind.FLOOR,
TileKind.FLOOR,
TileKind.WALL,
TileKind.WALL,
TileKind.FLOOR,
TileKind.VOID,
TileKind.COUNTER,
TileKind.WALL,
TileKind.FLOOR,
)
assert dict(grid.station_at) == {}
def test_from_rows_rejects_no_rows() -> None:
with pytest.raises(GridError, match="no rows"):
make_grid([])
def test_from_rows_rejects_ragged_rows() -> None:
with pytest.raises(GridError, match="ragged grid: row 1"):
make_grid(["...", ".."])
def test_from_rows_rejects_unknown_legend_char() -> None:
with pytest.raises(GridError, match=r"unknown legend char '@' at \(2,0\)"):
make_grid(["..@"])
def test_from_rows_rejects_empty_row() -> None:
with pytest.raises(GridError, match=r"1\.\.64"):
make_grid([""])
def test_from_rows_rejects_oversized_map() -> None:
with pytest.raises(GridError, match=r"1\.\.64"):
make_grid(["." * (MAX_GRID + 1)])
@pytest.mark.parametrize(
("width", "height", "tiles"),
[
(0, 1, ()),
(1, 0, ()),
(MAX_GRID + 1, 1, (TileKind.FLOOR,) * (MAX_GRID + 1)),
(2, 2, (TileKind.FLOOR,) * 3), # length mismatch
],
)
def test_direct_construction_validates_dimensions(
width: int, height: int, tiles: tuple[TileKind, ...]
) -> None:
with pytest.raises(GridError):
Grid(width=width, height=height, tiles=tiles)
# --- geometry ---------------------------------------------------------------------
@pytest.fixture
def grid() -> Grid:
return make_grid(["..#", "#.o", "C#."])
@pytest.mark.parametrize(
("x", "y", "expected"),
[(0, 0, True), (2, 2, True), (-1, 0, False), (0, -1, False), (3, 0, False), (0, 3, False)],
)
def test_in_bounds(grid: Grid, x: int, y: int, expected: bool) -> None:
assert grid.in_bounds(x, y) is expected
def test_tile_at_in_and_out_of_bounds(grid: Grid) -> None:
assert grid.tile_at(0, 0) is TileKind.FLOOR
assert grid.tile_at(2, 0) is TileKind.WALL
assert grid.tile_at(1, 1) is TileKind.FLOOR
assert grid.tile_at(-1, 0) is None
assert grid.tile_at(3, 2) is None
def test_is_walkable(grid: Grid) -> None:
assert grid.is_walkable(0, 0) is True
assert grid.is_walkable(1, 2) is False # wall
assert grid.is_walkable(2, 1) is False # void
assert grid.is_walkable(0, 2) is False # counter
assert grid.is_walkable(9, 9) is False # off-map
def test_walkable_cells_is_row_major(grid: Grid) -> None:
assert grid.walkable_cells() == (0, 1, 4, 8)
def test_blocking_mask_covers_every_cell(grid: Grid) -> None:
assert grid.blocking_mask() == (
False,
False,
True,
True,
False,
True,
True,
True,
False,
)
# --- neighbours ---------------------------------------------------------------------
def test_neighbours_four_way_order_is_north_west_east_south() -> None:
grid = make_grid(["...", "...", "..."])
assert grid.neighbours(4) == (1, 3, 5, 7)
def test_neighbours_eight_way_is_complete_around_a_center() -> None:
grid = make_grid(["...", "...", "..."])
assert grid.neighbours(4, four_way=False) == (1, 0, 2, 3, 5, 6, 8, 7)
def test_neighbours_are_clipped_to_the_map() -> None:
grid = make_grid(["..", ".."])
assert grid.neighbours(0) == (1, 2)
assert set(grid.neighbours(0, four_way=False)) == {1, 2, 3}
def test_neighbours_reject_out_of_range_cells() -> None:
grid = make_grid([".."])
with pytest.raises(GridError, match="out of range"):
grid.neighbours(2)
# --- bfs_reachable ---------------------------------------------------------------------
def test_bfs_finds_the_whole_open_region() -> None:
grid = make_grid(["...", "..."])
assert grid.bfs_reachable(0) == frozenset(range(6))
def test_bfs_walks_around_a_wall_column() -> None:
grid = make_grid([".##.", "....", ".##."])
# every floor cell is connected through the middle row
assert grid.bfs_reachable(0) == frozenset({0, 3, 4, 5, 6, 7, 8, 11})
def test_bfs_cannot_cross_a_full_wall() -> None:
grid = make_grid(["...###", "...###"])
assert grid.bfs_reachable(0) == frozenset({0, 1, 2, 6, 7, 8})
def test_bfs_honours_extra_blocked_cells() -> None:
grid = make_grid(["..."])
assert grid.bfs_reachable(0) == frozenset({0, 1, 2})
assert grid.bfs_reachable(0, extra_blocked=frozenset({1})) == frozenset({0})
assert grid.bfs_reachable(0, extra_blocked=frozenset({0})) == frozenset()
def test_bfs_from_a_non_walkable_start_reaches_nothing() -> None:
grid = make_grid([".#"])
assert grid.bfs_reachable(1) == frozenset()
def test_bfs_rejects_an_out_of_range_start() -> None:
grid = make_grid([".."])
with pytest.raises(GridError, match="out of range"):
grid.bfs_reachable(2)
# --- manhattan --------------------------------------------------------------------------
def test_manhattan_distance() -> None:
assert Grid.manhattan(Vec2(1.0, 1.0), Vec2(4.0, 5.0)) == 7.0
assert Grid.manhattan(Vec2(-2.5, 0.25), Vec2(0.5, 0.25)) == 3.0
assert Grid.manhattan(Vec2(2.0, 2.0), Vec2(2.0, 2.0)) == 0.0
+431
View File
@@ -0,0 +1,431 @@
"""Unit tests for the pure simulation data model (phase 02 task 01, PLAN §5).
Covers the enum vocabulary, `Vec2` geometry, the frozen entities and their
value-semantics helpers, and the `state_hash` contract the replay tests
(A21) will rely on: scored changes change the hash, cosmetic ones never do.
"""
from __future__ import annotations
from dataclasses import replace
from typing import Any
import pytest
from undercooked.sim.grid import Grid
from undercooked.sim.model import (
OPPOSITE,
WALKABLE,
Event,
EventKind,
Facing,
Hazard,
HazardKind,
Item,
ItemForm,
ItemKind,
KitchenState,
Order,
Player,
PlayerState,
RoundResult,
Station,
StationKind,
StationState,
TileKind,
Vec2,
_canonical,
)
pytestmark = pytest.mark.unit
@pytest.fixture
def state() -> KitchenState:
grid = Grid(width=3, height=2, tiles=(TileKind.FLOOR,) * 6)
return KitchenState(level_id="01_test", seed=42, grid=grid, target_score=40, time_left=90.0)
# --- Facing -------------------------------------------------------------------
@pytest.mark.parametrize(
("facing", "delta"),
[
(Facing.UP, Vec2(0.0, -1.0)),
(Facing.RIGHT, Vec2(1.0, 0.0)),
(Facing.DOWN, Vec2(0.0, 1.0)),
(Facing.LEFT, Vec2(-1.0, 0.0)),
],
)
def test_facing_delta_is_a_unit_step(facing: Facing, delta: Vec2) -> None:
assert facing.delta() == delta
def test_facing_wire_values() -> None:
assert [f.value for f in Facing] == ["up", "right", "down", "left"]
def test_opposite_pairs_and_is_involutive() -> None:
assert len(OPPOSITE) == 4
for facing in Facing:
assert OPPOSITE[OPPOSITE[facing]] is facing
# --- Vec2 ---------------------------------------------------------------------
def test_vec2_defaults_to_zero() -> None:
assert Vec2() == Vec2(0.0, 0.0)
def test_vec2_add_and_sub() -> None:
a, b = Vec2(2.0, 3.0), Vec2(0.5, -1.0)
assert a + b == Vec2(2.5, 2.0)
assert a - b == Vec2(1.5, 4.0)
def test_vec2_scale() -> None:
assert Vec2(1.5, -2.0).scale(2.0) == Vec2(3.0, -4.0)
@pytest.mark.parametrize(
("vec", "expected"),
[(Vec2(3.0, 4.0), 5.0), (Vec2(0.0, 0.0), 0.0), (Vec2(-1.0, 0.0), 1.0)],
)
def test_vec2_length(vec: Vec2, expected: float) -> None:
assert vec.length == pytest.approx(expected)
def test_vec2_normalized() -> None:
unit = Vec2(3.0, 4.0).normalized()
assert unit.x == pytest.approx(0.6)
assert unit.y == pytest.approx(0.8)
assert unit.length == pytest.approx(1.0)
def test_vec2_zero_normalizes_to_zero_without_division_error() -> None:
assert Vec2(0.0, 0.0).normalized() == Vec2(0.0, 0.0)
def test_vec2_quantized() -> None:
assert Vec2(1.23456, -2.98765).quantized() == Vec2(1.23, -2.99)
assert Vec2(1.23456, -2.98765).quantized(digits=1) == Vec2(1.2, -3.0)
@pytest.mark.parametrize(
("vec", "key"),
[
(Vec2(0.0, 0.0), (0, 0)),
(Vec2(1.5, 2.25), (96, 144)),
(Vec2(0.02, 0.01), (1, 1)),
(Vec2(-0.03, -0.007), (-2, 0)),
],
)
def test_vec2_key_is_64th_tile_resolution(vec: Vec2, key: tuple[int, int]) -> None:
assert key == vec.KEY
# --- TileKind / WALKABLE -------------------------------------------------------
def test_only_floor_is_walkable() -> None:
assert frozenset({TileKind.FLOOR}) == WALKABLE
assert {t.value for t in TileKind} == {"floor", "counter", "wall", "void"}
# --- Item ----------------------------------------------------------------------
def test_item_defaults() -> None:
item = Item(kind=ItemKind.TOMATO, form=ItemForm.RAW)
assert (item.quality, item.ttl, item.dish_id) == (1.0, 0.0, None)
def test_item_is_trash_only_when_burnt() -> None:
for form in ItemForm:
item = Item(kind=ItemKind.PATTY, form=form)
assert item.is_trash() is (form is ItemForm.BURNT)
def test_item_with_form_returns_a_new_instance() -> None:
raw = Item(kind=ItemKind.ONION, form=ItemForm.RAW, quality=0.5)
chopped = raw.with_form(ItemForm.CHOPPED)
assert chopped is not raw
assert chopped.form is ItemForm.CHOPPED
assert chopped.kind is ItemKind.ONION and chopped.quality == 0.5
assert raw.form is ItemForm.RAW # value semantics: the original never changes
# --- Station / Player / Order / Hazard ------------------------------------------
def test_station_defaults() -> None:
station = Station(id="stove_1", kind=StationKind.STOVE, x=3.0, y=2.0)
assert (station.contents, station.state) == ((), StationState.IDLE)
assert (station.progress, station.duration, station.cooldown) == (0.0, 0.0, 0.0)
assert station.worker_id is None and station.dirty is False
def test_station_kinds_and_states_match_the_plan_vocabulary() -> None:
assert {k.value for k in StationKind} == {
"spawn_shelf",
"chop_board",
"stove",
"prep",
"serve",
"sink",
"trash",
}
assert {s.value for s in StationState} == {
"idle",
"working",
"ready",
"busy",
"dirty",
"blocked",
}
def test_player_defaults() -> None:
player = Player(id="tok")
assert player.pos == Vec2(0.0, 0.0) and player.vel == Vec2(0.0, 0.0)
assert player.facing is Facing.DOWN and player.carrying is None
assert player.state is PlayerState.IDLE
assert (player.stun, player.score, player.dishes_served) == (0.0, 0, 0)
assert player.connected is True and player.seat == 0
def test_player_state_vocabulary() -> None:
assert {s.value for s in PlayerState} == {"idle", "moving", "using", "stunned", "disconnected"}
def test_order_defaults() -> None:
order = Order(id=17, recipe_id="soup")
assert (order.time_left, order.deadline, order.value) == (0.0, 0.0, 0)
def test_hazard_defaults() -> None:
hazard = Hazard(id="hz1", kind=HazardKind.FIRE)
assert hazard.pos == Vec2(0.0, 0.0)
assert hazard.life == 0.0 and hazard.path == () and hazard.period_s == 0.0
assert hazard.station_id is None and hazard.chance_per_s == 0.0
def test_hazard_and_result_vocabularies() -> None:
assert {k.value for k in HazardKind} == {"fire", "spill", "moving_obstacle", "rush"}
assert {r.value for r in RoundResult} == {"won", "lost", "aborted"}
def test_item_kind_and_form_vocabularies() -> None:
assert {k.value for k in ItemKind} == {
"tomato",
"onion",
"lettuce",
"dough",
"patty",
"rice",
"fish",
"plate",
}
assert {f.value for f in ItemForm} == {
"raw",
"chopped",
"cooking",
"cooked",
"burnt",
"plated",
"dirty",
}
# --- Event / EventKind -----------------------------------------------------------
@pytest.mark.parametrize(
("kind", "value"),
[
(EventKind.CHOPPED, "chopped"),
(EventKind.SERVED, "served"),
(EventKind.BURNT, "burnt"),
(EventKind.ORDER_MISSED, "order_missed"),
(EventKind.FIRE_STARTED, "fire_started"),
(EventKind.DENIED, "denied"),
(EventKind.SCORE, "score"),
(EventKind.STUNNED, "stunned"),
(EventKind.WASHED, "washed"),
(EventKind.TRASHED, "trashed"),
(EventKind.TICK_SLOW, "tick_slow"),
],
)
def test_event_kind_constants(kind: EventKind, value: str) -> None:
assert kind == value
def test_event_defaults() -> None:
event = Event(kind=EventKind.SERVED)
assert event.kind == "served"
assert (event.x, event.y) == (0.0, 0.0)
assert dict(event.payload) == {}
def test_event_carries_a_payload() -> None:
event = Event(kind=EventKind.SCORE, x=9.0, y=1.0, payload={"points": 40})
assert event.payload["points"] == 40
# --- KitchenState.state_hash -------------------------------------------------------
def test_state_hash_is_stable_hex(state: KitchenState) -> None:
first = state.state_hash()
assert first == state.state_hash()
assert len(first) == 64
assert all(c in "0123456789abcdef" for c in first)
@pytest.mark.parametrize(
"mutation",
[
lambda s: replace(s, score=s.score + 1),
lambda s: replace(s, target_score=s.target_score + 10),
lambda s: replace(s, tick=s.tick + 1),
lambda s: replace(s, time_left=s.time_left + 0.01),
lambda s: replace(s, streak=s.streak + 1),
lambda s: replace(s, result=RoundResult.WON),
lambda s: replace(s, seed=s.seed + 1),
lambda s: replace(s, level_id="02_other"),
lambda s: replace(s, rng_state=s.rng_state ^ 1),
lambda s: replace(s, counters={"served": 1}),
lambda s: replace(s, players=(Player(id="p1"),)),
lambda s: replace(s, stations=(Station(id="s1", kind=StationKind.STOVE),)),
lambda s: replace(s, orders=(Order(id=1, recipe_id="soup"),)),
lambda s: replace(s, hazards=(Hazard(id="h1", kind=HazardKind.FIRE),)),
lambda s: replace(
s, grid=Grid(width=3, height=2, tiles=(TileKind.FLOOR,) * 5 + (TileKind.WALL,))
),
],
ids=[
"score",
"target_score",
"tick",
"time_left",
"streak",
"result",
"seed",
"level_id",
"rng_state",
"counters",
"players",
"stations",
"orders",
"hazards",
"grid",
],
)
def test_state_hash_changes_for_scored_fields(
state: KitchenState, mutation: Any
) -> None:
assert mutation(state).state_hash() != state.state_hash()
def test_state_hash_ignores_counter_insertion_order() -> None:
grid = Grid(width=1, height=1, tiles=(TileKind.FLOOR,))
left = KitchenState("01_a", 1, grid, counters={"served": 2, "missed": 1})
right = KitchenState("01_a", 1, grid, counters={"missed": 1, "served": 2})
assert left.state_hash() == right.state_hash()
def test_state_hash_ignores_station_at_insertion_order() -> None:
tiles = (TileKind.FLOOR,) * 6
grid_a = Grid(width=3, height=2, tiles=tiles, station_at={0: "shelf", 5: "serve"})
grid_b = Grid(width=3, height=2, tiles=tiles, station_at={5: "serve", 0: "shelf"})
assert (
KitchenState("01_a", 1, grid_a).state_hash()
== KitchenState("01_a", 1, grid_b).state_hash()
)
def test_state_hash_ignores_float_repr_noise(state: KitchenState) -> None:
# cosmetic float noise (< 1e-6) is below the canonical quantization...
assert replace(state, time_left=90.0 + 1e-9).state_hash() == state.state_hash()
assert replace(state, time_left=-0.0).state_hash() == replace(
state, time_left=0.0
).state_hash()
# ...but a real difference above it always changes the hash
assert replace(state, time_left=90.000001).state_hash() != state.state_hash()
# --- KitchenState.with_* helpers -----------------------------------------------------
def test_with_tick_and_time_left_return_new_instances(state: KitchenState) -> None:
advanced = state.with_tick(7).with_time_left(83.5)
assert advanced.tick == 7 and advanced.time_left == 83.5
assert advanced is not state
assert state.tick == 0 and state.time_left == 90.0
def test_with_score_optionally_sets_streak(state: KitchenState) -> None:
scored = state.with_score(40)
assert scored.score == 40 and scored.streak == state.streak
with_streak = state.with_score(40, streak=3)
assert with_streak.score == 40 and with_streak.streak == 3
assert state.score == 0
@pytest.mark.parametrize("result", [RoundResult.WON, RoundResult.LOST, None])
def test_with_result(state: KitchenState, result: RoundResult | None) -> None:
updated = state.with_result(result)
assert updated.result is result
assert state.result is None
def test_with_entity_collections(state: KitchenState) -> None:
stations = (Station(id="s1", kind=StationKind.CHOP_BOARD),)
players = (Player(id="p1", seat=0),)
orders = (Order(id=1, recipe_id="soup"),)
hazards = (Hazard(id="h1", kind=HazardKind.SPILL),)
updated = (
state.with_stations(stations)
.with_players(players)
.with_orders(orders)
.with_hazards(hazards)
)
assert updated.stations == stations
assert updated.players == players
assert updated.orders == orders
assert updated.hazards == hazards
assert state.stations == () and state.players == ()
assert state.orders == () and state.hazards == ()
def test_with_counters_merges_without_mutating(state: KitchenState) -> None:
merged = state.with_counters(served=1).with_counters(served=2, missed=1)
assert dict(merged.counters) == {"served": 2, "missed": 1}
assert dict(state.counters) == {}
# --- _canonical ------------------------------------------------------------------------
def test_canonical_scalars_are_typed_tokens() -> None:
assert _canonical(None) == "n:"
assert _canonical(True) == "b:1"
assert _canonical(1) == "i:1" # bool is checked before int
assert _canonical("served") == "s:served"
assert _canonical(Facing.UP) == "s:up" # StrEnums canonicalise by value
assert _canonical(2.5) == "f:2.500000"
def test_canonical_sorts_mapping_keys() -> None:
assert _canonical({"b": 1, "a": 2}) == _canonical({"a": 2, "b": 1})
def test_canonical_renders_dataclasses_field_name_sorted() -> None:
assert _canonical(Vec2(1.0, 2.0)) == "d:Vec2(x=f:1.000000,y=f:2.000000)"
def test_canonical_rejects_unknown_types_so_fields_cannot_hide() -> None:
with pytest.raises(TypeError, match="cannot canonicalise"):
_canonical(object())
+81
View File
@@ -0,0 +1,81 @@
"""Unit tests for the A5 purity guard (phase 02 task 01).
Proves the shipped `sim/` package imports nothing forbidden, and — just as
important — that the guard actually catches a violation when one exists.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from undercooked.sim.purity import (
FORBIDDEN_ROOTS,
PurityError,
assert_pure_imports,
find_violations,
)
pytestmark = pytest.mark.unit
def test_forbidden_roots_are_the_plan_a5_list() -> None:
assert frozenset({"asyncio", "fastapi", "socket", "time", "uvicorn"}) == FORBIDDEN_ROOTS
def test_shipped_sim_package_is_pure() -> None:
assert find_violations() == []
assert_pure_imports() # must not raise
def test_clean_module_with_stdlib_sibling_and_relative_imports(tmp_path: Path) -> None:
(tmp_path / "ok.py").write_text(
"import math\n"
"from hashlib import sha256\n"
"from undercooked.sim.model import Vec2\n"
"from . import model\n",
encoding="utf-8",
)
assert find_violations(tmp_path) == []
def test_comments_and_strings_are_not_imports(tmp_path: Path) -> None:
(tmp_path / "prose.py").write_text(
'# we must never "import asyncio" here\nDOC = "import time is forbidden"\n',
encoding="utf-8",
)
assert find_violations(tmp_path) == []
@pytest.mark.parametrize(
("statement", "root"),
[
("import asyncio", "asyncio"),
("import time", "time"),
("from time import monotonic", "time"),
("import socket", "socket"),
("from socket import socket", "socket"),
("from fastapi import FastAPI", "fastapi"),
("import uvicorn", "uvicorn"),
("import uvicorn.protocols", "uvicorn"),
],
)
def test_find_violations_detects_each_forbidden_import(
tmp_path: Path, statement: str, root: str
) -> None:
(tmp_path / "bad.py").write_text(f"{statement}\n", encoding="utf-8")
assert find_violations(tmp_path) == [f"bad.py: imports {root}"]
def test_find_violations_scans_subpackages(tmp_path: Path) -> None:
sub = tmp_path / "deep"
sub.mkdir()
(sub / "leak.py").write_text("import asyncio\n", encoding="utf-8")
assert find_violations(tmp_path) == ["deep/leak.py: imports asyncio"]
def test_assert_pure_imports_raises_with_the_violation_list(tmp_path: Path) -> None:
(tmp_path / "bad.py").write_text("import time\nimport socket\n", encoding="utf-8")
with pytest.raises(PurityError, match=r"bad\.py: imports socket"):
assert_pure_imports(tmp_path)
+490
View File
@@ -0,0 +1,490 @@
"""Unit tests for `undercooked.sim.validation` (phase 02 task 03).
One failing case per rule id (asserted against `RuleId.ALL`) plus the passing
counterpart, a planted-unreachable-station exactness test, the bundle rules,
and the escalation/target math. Every fixture level is built by `make_level`
so a rule's failing case is one readable override away.
"""
from __future__ import annotations
import copy
from collections.abc import Callable
from dataclasses import replace
from typing import Any
import pytest
from undercooked.sim.content import ContentBundle
from undercooked.sim.grid import Grid
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import TileKind
from undercooked.sim.recipe import RecipeBook
from undercooked.sim.validation import (
TARGET_WARN_FRACTION,
RuleId,
escalation_axes,
theoretical_max_score,
validate_bundle,
validate_level,
validate_levels,
)
pytestmark = pytest.mark.unit
# --- fixtures ---------------------------------------------------------------------
RECIPES: dict[str, dict[str, Any]] = {
"soup": {
"id": "soup",
"name": "Tomato Soup",
"base_score": 40,
"ingredients": [{"kind": "tomato", "form": "raw"}, {"kind": "onion", "form": "raw"}],
"steps": [
{"kind": "chop", "station": "chop_board", "seconds": 0.9},
{"kind": "cook", "station": "stove", "seconds": 5.0},
{"kind": "plate", "station": "prep", "seconds": 0.5},
{"kind": "serve", "station": "serve"},
],
},
"salad": {
"id": "salad",
"name": "Garden Salad",
"base_score": 30,
"ingredients": [{"kind": "lettuce", "form": "chopped"}],
"steps": [
{"kind": "plate", "station": "prep", "seconds": 0.4},
{"kind": "serve", "station": "serve"},
],
},
}
CLEAN_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#......V..#",
"###########",
]
CLEAN_TILES: dict[str, dict[str, str]] = {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"O": {"kind": "floor", "station": "spawn_shelf", "item": "onion"},
"C": {"kind": "floor", "station": "chop_board"},
"X": {"kind": "floor", "station": "stove"},
"P": {"kind": "floor", "station": "prep"},
"V": {"kind": "floor", "station": "serve"},
}
#: The clean kitchen grown by two rows: a sealed 3-cell pocket at (7..9, 7).
POCKET_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#......V..#",
"#....######",
"#.....#..K#",
"###########",
]
#: Same pocket, empty floor inside — only the second spawn lives down there.
POCKET_FLOOR_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#......V..#",
"#....######",
"#.....#...#",
"###########",
]
#: The serve window itself sealed inside the pocket (nothing else serves).
SERVE_POCKET_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#....######",
"#.....#...#",
"#.....#..V#",
"###########",
]
NO_STOVE_GRID = [
"###########",
"#S.O....C.#",
"#.........#",
"#......P..#",
"#.........#",
"#......V..#",
"###########",
]
NO_ONION_GRID = [
"###########",
"#S......C.#",
"#.........#",
"#..X...P..#",
"#.........#",
"#......V..#",
"###########",
]
TINY_GRID = ["#####", "#SV.#", "#####"]
TINY_TILES: dict[str, dict[str, str]] = {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"V": {"kind": "floor", "station": "serve"},
}
ORDERS: dict[str, Any] = {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
}
def level_data(**overrides: Any) -> dict[str, Any]:
data: dict[str, Any] = {
"id": "01_clean",
"name": "Clean Kitchen",
"order": 1,
"time_limit": 150,
"target_score": 45,
"difficulty": 1,
"grid": copy.deepcopy(CLEAN_GRID),
"tiles": copy.deepcopy(CLEAN_TILES),
"spawns": [[1, 1]],
"orders": copy.deepcopy(ORDERS),
}
data.update(overrides)
return data
def make_level(**overrides: Any) -> Level:
"""Build the clean fixture level with the given `LevelDef` overrides."""
return Level.build(LevelDef.model_validate(level_data(**overrides)))
def make_book(*recipe_ids: str) -> RecipeBook:
ids = recipe_ids or ("soup",)
return RecipeBook.model_validate(
{"recipes": {recipe_id: copy.deepcopy(RECIPES[recipe_id]) for recipe_id in ids}}
)
# --- the clean level is silent ------------------------------------------------------
def test_clean_level_reports_no_errors_or_warnings() -> None:
report = validate_level(make_level(), make_book())
assert report.ok
assert report.violations == ()
assert report.warnings == ()
assert report.render() == "content OK: no violations"
# --- failing cases, one per rule id ---------------------------------------------------
Case = Callable[[], tuple[Level, RecipeBook, Level | None]]
def _pocket_level() -> Level:
tiles = copy.deepcopy(CLEAN_TILES)
tiles["K"] = {"kind": "floor", "station": "sink"}
return make_level(grid=POCKET_GRID, tiles=tiles)
def _grid_shape_height() -> tuple[Level, RecipeBook, Level | None]:
level = make_level()
shrunk = Grid(
width=level.grid.width,
height=level.grid.height - 1,
tiles=level.grid.tiles[: level.grid.width * (level.grid.height - 1)],
)
return replace(level, grid=shrunk), make_book(), None
def _spawn_on_floor() -> tuple[Level, RecipeBook, Level | None]:
return make_level(spawns=[[0, 0]]), make_book(), None
def _spawn_count() -> tuple[Level, RecipeBook, Level | None]:
return replace(make_level(), spawns=()), make_book(), None
def _station_reachable() -> tuple[Level, RecipeBook, Level | None]:
return _pocket_level(), make_book(), None
def _serve_reachable() -> tuple[Level, RecipeBook, Level | None]:
return make_level(grid=SERVE_POCKET_GRID), make_book(), None
def _spawns_mutual() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(grid=POCKET_FLOOR_GRID, spawns=[[1, 1], [9, 7], [5, 4]])
return level, make_book(), None
def _recipe_station_missing() -> tuple[Level, RecipeBook, Level | None]:
tiles = {c: spec for c, spec in copy.deepcopy(CLEAN_TILES).items() if c != "X"}
return make_level(grid=NO_STOVE_GRID, tiles=tiles), make_book(), None
def _recipe_ingredient_missing() -> tuple[Level, RecipeBook, Level | None]:
tiles = {c: spec for c, spec in copy.deepcopy(CLEAN_TILES).items() if c != "O"}
return make_level(grid=NO_ONION_GRID, tiles=tiles), make_book(), None
def _pool_unknown() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(orders=dict(ORDERS, pool=["soup", "ghost"]))
return level, make_book(), None
def _target_too_high() -> tuple[Level, RecipeBook, Level | None]:
book = make_book()
cap = theoretical_max_score(make_level(), book)
assert cap is not None
return make_level(target_score=int(cap * 2)), book, None
def _time_limit() -> tuple[Level, RecipeBook, Level | None]:
return make_level(time_limit=10), make_book(), None
def _hazard_ref() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(hazards=[{"type": "fire", "station": "stove_9"}])
return level, make_book(), None
def _hazard_tile() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(hazards=[{"type": "spill", "x": 0, "y": 0}])
return level, make_book(), None
def _limit_zero() -> tuple[Level, RecipeBook, Level | None]:
return make_level(station_limits={"plates": 0, "pots": 1}), make_book(), None
def _difficulty_order() -> tuple[Level, RecipeBook, Level | None]:
return make_level(), make_book(), make_level()
def _travel_short() -> tuple[Level, RecipeBook, Level | None]:
level = make_level(difficulty=3, grid=TINY_GRID, tiles=TINY_TILES)
return level, make_book(), None
FAILING: dict[str, Case] = {
RuleId.GRID_SHAPE: _grid_shape_height,
RuleId.SPAWN_ON_FLOOR: _spawn_on_floor,
RuleId.SPAWN_COUNT: _spawn_count,
RuleId.STATION_REACHABLE: _station_reachable,
RuleId.SERVE_REACHABLE: _serve_reachable,
RuleId.SPAWNS_MUTUALLY_REACHABLE: _spawns_mutual,
RuleId.RECIPE_STATION_MISSING: _recipe_station_missing,
RuleId.RECIPE_INGREDIENT_UNAVAILABLE: _recipe_ingredient_missing,
RuleId.ORDER_POOL_UNKNOWN_RECIPE: _pool_unknown,
RuleId.TARGET_IMPLAUSIBLE: _target_too_high,
RuleId.TIME_LIMIT_INVALID: _time_limit,
RuleId.HAZARD_REF_UNKNOWN: _hazard_ref,
RuleId.HAZARD_ON_UNREACHABLE_TILE: _hazard_tile,
RuleId.STATION_LIMIT_ZERO: _limit_zero,
RuleId.DIFFICULTY_ORDER: _difficulty_order,
RuleId.TRAVEL_TOO_SHORT: _travel_short,
}
def test_failing_cases_cover_every_rule_id() -> None:
"""Every rule id has a failing case here; DUPLICATE_ID is a bundle rule."""
assert set(FAILING) | {RuleId.DUPLICATE_ID} == set(RuleId.ALL)
assert len(FAILING) + 1 == len(RuleId.ALL)
@pytest.mark.parametrize("rule", sorted(FAILING))
def test_rule_fires_with_a_human_message(rule: str) -> None:
level, book, prev = FAILING[rule]()
report = validate_level(level, book, prev)
assert not report.ok
fired = [violation for violation in report.violations if violation.rule == rule]
assert fired, f"{rule} did not fire:\n{report.render()}"
def test_planted_unreachable_station_reports_exactly_station_reachable() -> None:
report = validate_level(_pocket_level(), make_book())
assert len(report.violations) == 1, report.render()
violation = report.violations[0]
assert violation.rule == RuleId.STATION_REACHABLE
assert "(9,7)" in violation.message
assert dict(violation.detail) == {"x": 9, "y": 7, "station_id": "sink_0"}
assert report.errors_for("01_clean") == (violation,)
assert report.errors_for("nope") == ()
# --- passing counterparts beyond the clean level ---------------------------------------
def test_grid_shape_also_flags_a_width_disagreement() -> None:
level = make_level()
wider = Grid(
width=level.grid.width + 1,
height=level.grid.height,
tiles=level.grid.tiles + (TileKind.WALL,) * level.grid.height,
)
report = validate_level(replace(level, grid=wider), make_book())
fired = [v.rule for v in report.violations]
assert RuleId.GRID_SHAPE in fired
def test_spawn_on_the_serve_window_travels_zero_tiles() -> None:
assert escalation_axes(make_level(spawns=[[7, 5]]))["travel_distance"] == 0.0
def test_non_raw_ingredients_are_not_shelf_checked() -> None:
level = make_level(orders=dict(ORDERS, pool=["salad"]))
report = validate_level(level, make_book("salad"))
assert report.ok, report.render()
def test_tight_target_warns_without_failing() -> None:
cap = theoretical_max_score(make_level(), make_book())
assert cap is not None
tight = make_level(target_score=int(TARGET_WARN_FRACTION * cap) + 10)
report = validate_level(tight, make_book())
assert report.ok # warnings never fail
assert [warning.rule for warning in report.warnings] == [RuleId.TARGET_IMPLAUSIBLE]
def test_two_axes_moved_satisfies_difficulty_order() -> None:
prev = make_level()
nxt = make_level(
order=2,
hazards=[{"type": "spill", "x": 5, "y": 2}],
orders=dict(ORDERS, concurrent=2),
)
report = validate_level(nxt, make_book(), prev)
assert report.ok, report.render()
def test_high_difficulty_level_with_real_travel_is_clean() -> None:
report = validate_level(make_level(difficulty=3), make_book())
assert report.ok, report.render()
def test_hazard_path_point_off_the_floor_fires() -> None:
level = make_level(
hazards=[{"type": "moving_obstacle", "path": [[1, 1], [0, 0]], "period_s": 8.0}]
)
fired = [
violation.message
for violation in validate_level(level, make_book()).violations
if violation.rule == RuleId.HAZARD_ON_UNREACHABLE_TILE
]
assert len(fired) == 1
assert "path point 1" in fired[0]
def test_hazard_outside_the_grid_fires() -> None:
level = make_level(hazards=[{"type": "spill", "x": 99, "y": 99}])
fired = [
violation.message
for violation in validate_level(level, make_book()).violations
if violation.rule == RuleId.HAZARD_ON_UNREACHABLE_TILE
]
assert len(fired) == 1
assert "outside the 11x7 grid" in fired[0] # clean fixture is 11x7
# --- bundle-level rules -------------------------------------------------------------
def test_duplicate_id_is_a_bundle_violation() -> None:
first = make_level()
second = make_level(order=2) # forgets to rename: same id again
report = validate_levels([first, second], make_book())
dupes = [violation for violation in report.violations if violation.rule == RuleId.DUPLICATE_ID]
assert len(dupes) == 1
assert dupes[0].level_id == "01_clean"
assert dupes[0].detail["count"] == 2
def test_validate_bundle_accepts_a_clean_two_level_campaign() -> None:
first = make_level()
second = make_level(
id="02_later",
order=2,
difficulty=2,
hazards=[{"type": "spill", "x": 5, "y": 2}],
orders=dict(ORDERS, concurrent=2),
)
bundle = ContentBundle(recipe_book=make_book(), levels=(first, second))
report = validate_bundle(bundle)
assert report.ok and not report.warnings, report.render()
def test_validate_bundle_refuses_a_difficulty_drop() -> None:
first = make_level(difficulty=2)
second = make_level(id="02_later", order=2, difficulty=1)
bundle = ContentBundle(recipe_book=make_book(), levels=(first, second))
report = validate_bundle(bundle)
drops = [
violation
for violation in report.violations
if violation.rule == RuleId.DIFFICULTY_ORDER and "drops" in violation.message
]
assert len(drops) == 1
assert drops[0].level_id == "02_later"
# --- the bound itself ---------------------------------------------------------------
def test_theoretical_max_score_needs_known_recipes_and_valid_time() -> None:
book = make_book()
ghost = make_level(orders=dict(ORDERS, pool=["ghost"]))
assert theoretical_max_score(ghost, book) is None
assert theoretical_max_score(make_level(time_limit=10), book) is None
def test_theoretical_max_score_counts_orders_the_time_allows() -> None:
book = make_book()
level = make_level()
cap = theoretical_max_score(level, book)
assert cap is not None
# 150 s / (7.0 s soup + 2*10 tiles / 3.5 tiles/s travel) -> 11 orders x 40 pts.
assert cap == 11 * 40
def test_escalation_axes_are_named_and_higher_is_harder() -> None:
level = make_level()
axes = escalation_axes(level)
assert set(axes) == {
"recipe_complexity",
"order_pressure",
"time_budget",
"station_scarcity",
"layout_hostility",
"hazard_density",
"travel_distance",
}
assert axes["hazard_density"] == 0.0
assert axes["travel_distance"] == 10.0
harder = make_level(
hazards=[{"type": "spill", "x": 5, "y": 2}],
orders=dict(ORDERS, concurrent=2),
)
harder_axes = escalation_axes(harder)
assert harder_axes["hazard_density"] > axes["hazard_density"]
assert harder_axes["order_pressure"] > axes["order_pressure"]
+81
View File
@@ -0,0 +1,81 @@
"""`ValidationReport.render()` is deterministic, rule-naming, and bounded.
The CLI (task 05) prints this text verbatim and the content-gate criterion
greps it for rule names, so the render must be stable (same report, same
bytes — input order cannot leak in), mention every rule id it holds, stay
free of ANSI escapes, and not explode into a wall of text for a broken level.
"""
from __future__ import annotations
import pytest
from undercooked.sim.validation import LevelViolation, RuleId, ValidationReport
pytestmark = pytest.mark.unit
def _violation(rule: str, level_id: str, message: str) -> LevelViolation:
return LevelViolation(rule=rule, level_id=level_id, message=message)
def _ten_violation_report() -> ValidationReport:
errors = tuple(
_violation(rule, f"level_{index % 2}", f"synthetic {rule} message {index}")
for index, rule in enumerate(RuleId.ALL[:10])
)
warnings = (_violation(RuleId.TARGET_IMPLAUSIBLE, "level_0", "tight but fair"),)
return ValidationReport(violations=errors, warnings=warnings)
def test_render_is_deterministic_across_runs_and_input_order() -> None:
report = _ten_violation_report()
assert report.render() == report.render()
shuffled = ValidationReport(
violations=tuple(reversed(report.violations)),
warnings=report.warnings,
)
assert shuffled.render() == report.render()
def test_render_names_every_rule_level_and_severity_without_ansi() -> None:
rendered = _ten_violation_report().render()
for violation in _ten_violation_report().violations:
assert violation.rule in rendered
assert violation.message in rendered
assert "level_0" in rendered and "level_1" in rendered
assert "ERROR" in rendered and "WARN" in rendered
assert "\x1b" not in rendered
def test_render_stays_under_40_lines_for_a_10_violation_level() -> None:
assert len(_ten_violation_report().render().splitlines()) <= 40
def test_render_orders_errors_by_rule_within_a_level() -> None:
report = ValidationReport(
violations=(
_violation(RuleId.TRAVEL_TOO_SHORT, "l1", "b travels too little"),
_violation(RuleId.GRID_SHAPE, "l1", "a shape disagrees"),
)
)
error_lines = [line for line in report.render().splitlines() if "ERROR" in line]
assert len(error_lines) == 2
assert RuleId.GRID_SHAPE in error_lines[0]
assert RuleId.TRAVEL_TOO_SHORT in error_lines[1]
def test_clean_report_renders_one_ok_line() -> None:
report = ValidationReport()
assert report.ok
assert report.render() == "content OK: no violations"
def test_warnings_only_report_renders_ok_and_warn_lines() -> None:
report = ValidationReport(
warnings=(_violation(RuleId.TARGET_IMPLAUSIBLE, "l1", "tight but fair"),)
)
rendered = report.render()
assert report.ok # warnings never fail
assert "WARN" in rendered and "ERROR" not in rendered
assert "0 error(s), 1 warning(s)" in rendered
+281
View File
@@ -0,0 +1,281 @@
"""Unit tests for `undercooked.sim.content` (phase 02 task 02).
The loader is the "no hard-coded content lists" rule (A8) in executable form:
a directory of YAML in, a campaign-ordered `ContentBundle` out. These tests
pin discovery (recursion, `_`-drafts, the recipe book's own name), the
`(order, id)` sort, and the promise that *every* file-level problem — bad
YAML, schema violations, unknown order-pool recipes, duplicate ids — is
reported in one `ContentError`, not one per run.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
import yaml
from undercooked.sim.content import (
LEVEL_FILE_SUFFIXES,
ContentBundle,
ContentError,
load_content,
load_level_file,
)
from undercooked.sim.grid import GridError
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import ItemKind
from undercooked.sim.recipe import RecipeBook
pytestmark = pytest.mark.unit
BOOK_YAML = """
ingredients: [tomato, onion]
recipes:
soup:
id: soup
name: Tomato Soup
base_score: 40
ingredients: [{kind: tomato, form: raw}, {kind: onion, form: raw}]
steps:
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5}
"""
def level_body(level_id: str, order: int, **overrides: Any) -> dict[str, Any]:
body: dict[str, Any] = {
"id": level_id,
"name": level_id.replace("_", " ").title(),
"order": order,
"time_limit": 150,
"target_score": 45,
"grid": ["#####", "#S..#", "#.C.#", "#..V#", "#####"],
"tiles": {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"C": {"kind": "floor", "station": "chop_board"},
"V": {"kind": "floor", "station": "serve"},
},
"spawns": [[1, 1]],
"orders": {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
},
}
body.update(overrides)
return body
def write_yaml(directory: Path, name: str, body: dict[str, Any]) -> Path:
path = directory / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(yaml.safe_dump(body, sort_keys=False), encoding="utf-8")
return path
def make_content_dir(tmp_path: Path, *levels: tuple[str, int]) -> tuple[Path, Path]:
"""A content dir with a recipe book and the named (filename, order) levels."""
levels_dir = tmp_path / "levels"
levels_dir.mkdir()
recipes_path = levels_dir / "recipes.yaml"
recipes_path.write_text(BOOK_YAML, encoding="utf-8")
for name, order in levels:
write_yaml(levels_dir, name, level_body(Path(name).stem, order))
return levels_dir, recipes_path
# ---------------------------------------------------------------------------- happy path
def test_loads_the_book_and_every_level_file(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1), ("02_b.yaml", 2))
bundle = load_content(levels_dir, recipes_path)
assert bundle.recipe_book.ids() == ("soup",)
assert [level.id for level in bundle.levels] == ["01_a", "02_b"]
assert set(bundle.by_id) == {"01_a", "02_b"}
assert bundle.by_id["02_b"].order == 2
def test_levels_order_by_order_then_id_not_by_filename(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(
tmp_path, ("zzz_first.yaml", 1), ("01_second.yaml", 2), ("aaa_tie_b.yaml", 1)
)
write_yaml(levels_dir, "aab_tie.yaml", level_body("aab_tie", 1))
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == [
"aaa_tie_b",
"aab_tie",
"zzz_first",
"01_second",
]
def test_an_empty_levels_dir_is_a_legal_empty_campaign(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path)
bundle = load_content(levels_dir, recipes_path)
assert bundle.levels == ()
assert dict(bundle.by_id) == {}
def test_paths_may_be_plain_strings(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
bundle = load_content(str(levels_dir), str(recipes_path))
assert [level.id for level in bundle.levels] == ["01_a"]
# --------------------------------------------------------------------------- discovery
def test_discovery_recurses_into_subdirectories(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
write_yaml(levels_dir / "campaign" / "deep", "02_b.yaml", level_body("02_b", 2))
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == ["01_a", "02_b"]
def test_underscore_prefixed_files_are_ignored_even_when_broken(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
(levels_dir / "_draft.yaml").write_text("grid: [unclosed\n", encoding="utf-8")
(levels_dir / "_wip").mkdir()
write_yaml(levels_dir / "_wip", "scratch.yaml", {"id": "nothing"})
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == ["01_a"]
def test_recipes_yaml_in_the_levels_dir_is_not_a_level(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path)
# recipes.yaml itself would not validate as a level; loading must not even try.
bundle = load_content(levels_dir, recipes_path)
assert bundle.levels == ()
def test_non_yaml_files_are_ignored(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
(levels_dir / "notes.md").write_text("# ideas\n- more soup\n", encoding="utf-8")
(levels_dir / "level.json").write_text("{not yaml at all", encoding="utf-8")
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == ["01_a"]
def test_suffix_set_covers_yaml_and_yml(tmp_path: Path) -> None:
assert set(LEVEL_FILE_SUFFIXES) == {".yaml", ".yml"}
levels_dir, recipes_path = make_content_dir(tmp_path)
(levels_dir / "02_b.yml").write_text(
yaml.safe_dump(level_body("02_b", 2), sort_keys=False), encoding="utf-8"
)
bundle = load_content(levels_dir, recipes_path)
assert [level.id for level in bundle.levels] == ["02_b"]
# ---------------------------------------------------------------- error aggregation
def test_unknown_order_pool_recipe_is_a_load_error(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path)
write_yaml(levels_dir, "01_a.yaml", level_body("01_a", 1, orders={
"pool": ["soup", "ghost"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
}))
with pytest.raises(ContentError, match="unknown recipes: ghost"):
load_content(levels_dir, recipes_path)
def test_duplicate_level_ids_are_refused(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path, ("01_a.yaml", 1))
write_yaml(levels_dir, "90_copy.yaml", level_body("01_a", 5))
with pytest.raises(ContentError, match="duplicate level ids: 01_a"):
load_content(levels_dir, recipes_path)
def test_missing_levels_directory_is_reported(tmp_path: Path) -> None:
_, recipes_path = make_content_dir(tmp_path)
with pytest.raises(ContentError, match="does not exist"):
load_content(tmp_path / "nowhere", recipes_path)
def test_missing_recipe_book_is_reported(tmp_path: Path) -> None:
levels_dir, _ = make_content_dir(tmp_path)
with pytest.raises(ContentError, match="cannot read content file"):
load_content(levels_dir, tmp_path / "no" / "recipes.yaml")
def test_all_file_errors_survive_to_one_content_error(tmp_path: Path) -> None:
levels_dir, recipes_path = make_content_dir(tmp_path)
(levels_dir / "bad_one.yaml").write_text("grid: [unclosed\n", encoding="utf-8")
write_yaml(levels_dir, "bad_two.yaml", {"id": "x"}) # mostly-missing schema
write_yaml(levels_dir, "bad_three.yaml", level_body("ghost", 1, orders={
"pool": ["phantom"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
}))
with pytest.raises(ContentError) as excinfo:
load_content(levels_dir, recipes_path)
message = str(excinfo.value)
assert "bad_one.yaml" in message
assert "invalid YAML" in message
assert "bad_two.yaml" in message
assert "Field required" in message
assert "bad_three.yaml" in message
assert "phantom" in message
# every broken file opens its own issue block
assert f"{levels_dir / 'bad_one.yaml'}: " in message
assert f"{levels_dir / 'bad_two.yaml'}: " in message
assert f"{levels_dir / 'bad_three.yaml'}: " in message
# ------------------------------------------------------------------ load_level_file
def test_load_level_file_returns_a_built_level(tmp_path: Path) -> None:
path = write_yaml(tmp_path, "01_a.yaml", level_body("01_a", 1))
level = load_level_file(path)
assert isinstance(level, Level)
assert level.station_items == {"spawn_shelf_0": ItemKind.TOMATO}
assert level.grid.width == 5
def test_load_level_file_names_the_file_on_schema_errors(tmp_path: Path) -> None:
path = write_yaml(tmp_path, "broken.yaml", level_body("x", 1, grid=["###", "#..#"]))
with pytest.raises(ContentError, match=r"broken.yaml.*ragged grid"):
load_level_file(path)
def test_load_level_file_wraps_geometry_failures(tmp_path: Path, monkeypatch: Any) -> None:
def explode(_spec: Any) -> Level:
raise GridError("planted geometry failure")
monkeypatch.setattr(Level, "build", staticmethod(explode))
path = write_yaml(tmp_path, "01_a.yaml", level_body("01_a", 1))
with pytest.raises(ContentError, match=r"01_a\.yaml: planted geometry failure"):
load_level_file(path)
# --------------------------------------------------------------------- ContentBundle
def empty_book() -> RecipeBook:
return RecipeBook.model_validate({"recipes": {}})
def built_level(level_id: str, order: int) -> Level:
return Level.build(LevelDef.model_validate(level_body(level_id, order)))
def test_bundle_by_id_is_derived_and_can_be_read_back() -> None:
bundle = ContentBundle(recipe_book=empty_book(), levels=(built_level("a", 2),))
assert list(bundle.by_id) == ["a"]
assert bundle.by_id["a"].order == 2
def test_a_hand_built_bundle_cannot_smuggle_duplicate_ids() -> None:
levels = (built_level("a", 1), built_level("a", 2))
with pytest.raises(ContentError, match="duplicate level ids: a"):
ContentBundle(recipe_book=empty_book(), levels=levels)
+234
View File
@@ -0,0 +1,234 @@
"""A8, enforced: the campaign is YAML on disk and never a list in Python.
Phase 02 task 05's regression lock. The loader (`sim/content.py`), the content
API (`server/routes_content.py`) and the author CLI (`tools/levels.py`) all
discover levels by *reading a directory*, so a new `.yaml` file is the only
thing an author writes to ship a level. This test proves that twice over:
* **addition without code** — copy `levels/` to `tmp_path`, drop in
`90_zerocool.yaml` with `order: 99`, and the loader, `/api/levels` and
`undercooked-levels list`/`show`/`validate` all carry it, while the id
appears in no Python file at all (and deleting the file removes it again);
* **absence of literals** — no file under `src/undercooked/` (and no browser
asset) contains a shipped level id or recipe id as a literal, so a hard-coded
list cannot be quietly re-added later.
The session fixture points `UNDERCOOKED_LEVELS_DIR` at the empty
`tests/fixtures/levels/` directory, so every path here is set explicitly.
"""
from __future__ import annotations
import shutil
from collections.abc import Mapping
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from undercooked.config import load_settings
from undercooked.server.app import create_app
from undercooked.server.routes_content import RECIPES_FILENAME
from undercooked.sim.content import load_content
from undercooked.sim.recipe import load_recipe_book
from undercooked.tools.levels import EXIT_OK, EXIT_UNUSABLE, main
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
SHIPPED_RECIPES = SHIPPED_LEVELS / RECIPES_FILENAME
SOURCE_ROOT = REPO_ROOT / "src" / "undercooked"
STATIC_ROOT = REPO_ROOT / "app" / "static"
def shipped_level_ids() -> set[str]:
"""Every id the shipped `levels/` directory carries (the book is not a level)."""
return {
path.stem
for path in SHIPPED_LEVELS.glob("*.yaml")
if path.name not in {RECIPES_FILENAME, "recipes.yml"}
}
#: The level this test adds. `order: 99` puts it last in the campaign whatever
#: the shipped levels do, and three dishes with three orders live at once move
#: the escalation axes the campaign rule (`DIFFICULTY_ORDER`) asks about.
NEW_LEVEL_ID = "90_zerocool"
NEW_LEVEL_YAML = """\
id: 90_zerocool
name: Zero Cool
order: 99
difficulty: 3
time_limit: 120
target_score: 110
blurb: >-
A manifest probe, not a campaign level: three dishes, three orders live at
once, and the same pinched floor as the second kitchen.
grid:
- "#############"
- "#T....C....O#"
- "#.....S.....#"
- "####=...=####"
- "#....P.X....#"
- "#...........#"
- "####=...=####"
- "#R.K.....L..#"
- "#############"
tiles:
"#": {kind: wall}
".": {kind: floor}
"=": {kind: counter}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"O": {kind: floor, station: spawn_shelf, item: onion}
"L": {kind: floor, station: spawn_shelf, item: lettuce}
"R": {kind: floor, station: spawn_shelf, item: rice}
"C": {kind: floor, station: chop_board}
"S": {kind: floor, station: stove}
"P": {kind: floor, station: prep}
"X": {kind: floor, station: serve}
"K": {kind: floor, station: sink}
spawns: [[2, 2], [10, 5]]
orders:
pool: [soup, salad, fried_rice]
concurrent: 3
spawn_interval: [10, 14]
deadline: 45
hazards: []
escalation_notes: >-
Moves recipe_complexity, order_pressure and time_budget up versus the last
shipped level, which is what the campaign rule asks for.
"""
def copy_shipped_levels(destination: Path) -> Path:
"""`levels/` copied verbatim — making a level here edits no Python."""
shutil.copytree(SHIPPED_LEVELS, destination)
return destination
def text_files(root: Path, suffixes: tuple[str, ...]) -> Mapping[str, str]:
"""Every file under `root` with one of `suffixes`, keyed by path."""
return {
str(path): path.read_text(encoding="utf-8")
for path in sorted(root.rglob("*"))
if path.is_file()
and path.suffix in suffixes
and "__pycache__" not in path.parts
}
def offenders(literals: set[str], texts: Mapping[str, str]) -> dict[str, list[str]]:
"""Which literals appear in which file — empty means the scan passed."""
return {
path: sorted(literal for literal in literals if literal in text)
for path, text in texts.items()
if any(literal in text for literal in literals)
}
@pytest.fixture()
def extended_campaign(tmp_path: Path) -> Path:
"""A copy of the shipped campaign plus one level nothing ever registered."""
campaign = copy_shipped_levels(tmp_path / "campaign")
(campaign / f"{NEW_LEVEL_ID}.yaml").write_text(NEW_LEVEL_YAML, encoding="utf-8")
return campaign
# --- a file is the only thing an author writes ----------------------------------
def test_a_new_yaml_file_reaches_the_loader_the_api_and_the_cli_without_code(
extended_campaign: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
shipped_ids = [level.id for level in load_content(SHIPPED_LEVELS, SHIPPED_RECIPES).levels]
# 1. the pure loader
bundle = load_content(extended_campaign, extended_campaign / RECIPES_FILENAME)
assert [level.id for level in bundle.levels] == [*shipped_ids, NEW_LEVEL_ID]
assert list(bundle.by_id) == [*shipped_ids, NEW_LEVEL_ID]
assert bundle.by_id[NEW_LEVEL_ID].order == 99
# 2. the HTTP content API, addressed only through the environment
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(extended_campaign))
monkeypatch.delenv("UNDERCOOKED_RECIPES_PATH", raising=False)
with TestClient(create_app(load_settings())) as client:
listing = client.get("/api/levels")
assert listing.status_code == 200
assert [level["id"] for level in listing.json()] == [*shipped_ids, NEW_LEVEL_ID]
detail = client.get(f"/api/levels/{NEW_LEVEL_ID}")
assert detail.status_code == 200
assert detail.json()["order"] == 99
assert detail.json()["difficulty"] == 3
# 3. the author CLI, on the same environment and no `--dir`
assert main(["list"]) == EXIT_OK
assert capsys.readouterr().out.splitlines()[-1].split()[1] == NEW_LEVEL_ID
assert main(["show", NEW_LEVEL_ID, "--grid"]) == EXIT_OK
shown = capsys.readouterr().out
assert shown.startswith(f"{NEW_LEVEL_ID} - Zero Cool")
assert "concurrent=3" in shown
# 4. and the gate accepts it: a row in a table that breaks PLAN §8 is not a level
assert main(["validate"]) == EXIT_OK
assert NEW_LEVEL_ID in capsys.readouterr().out
# The proof of the whole point: the new level is everywhere except the code.
assert offenders({NEW_LEVEL_ID}, text_files(SOURCE_ROOT, (".py", ".pyi"))) == {}
def test_the_copied_campaign_on_its_own_is_still_the_shipped_two(tmp_path: Path) -> None:
"""The copy is faithful, so the test above measures exactly one added file."""
pristine = copy_shipped_levels(tmp_path / "pristine")
copied = load_content(pristine, pristine / RECIPES_FILENAME)
shipped = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
assert [level.id for level in copied.levels] == [level.id for level in shipped.levels]
def test_a_deletion_is_as_invisible_as_an_addition(
extended_campaign: Path, capsys: pytest.CaptureFixture[str]
) -> None:
"""Nothing caches the campaign in Python either: remove the file, it is gone."""
(extended_campaign / f"{NEW_LEVEL_ID}.yaml").unlink()
assert main(["list", "--dir", str(extended_campaign)]) == EXIT_OK
assert NEW_LEVEL_ID not in capsys.readouterr().out
assert main(["show", NEW_LEVEL_ID, "--dir", str(extended_campaign)]) == EXIT_UNUSABLE
assert NEW_LEVEL_ID not in load_content(
extended_campaign, extended_campaign / RECIPES_FILENAME
).by_id
# --- no literals in the source --------------------------------------------------
def test_no_python_source_names_a_shipped_level_id() -> None:
"""`levels/*.yaml` owns level ids: nothing in `src/undercooked/` repeats one."""
shipped = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
literals = shipped_level_ids() | {level.id for level in shipped.levels}
assert literals # a scan of an empty set would pass and prove nothing
assert offenders(literals, text_files(SOURCE_ROOT, (".py", ".pyi"))) == {}
def test_no_python_source_names_a_recipe_from_the_book() -> None:
"""A8 covers recipes too: `recipes.yaml` is the only list of dishes."""
literals = set(load_recipe_book(SHIPPED_RECIPES).ids())
assert literals
assert offenders(literals, text_files(SOURCE_ROOT, (".py", ".pyi"))) == {}
def test_no_browser_asset_names_a_shipped_level_id() -> None:
"""The client asks the API for the campaign; it does not carry one."""
literals = shipped_level_ids()
assets = text_files(STATIC_ROOT, (".js", ".css", ".html", ".svg"))
assert assets
assert offenders(literals, assets) == {}
def test_the_loader_discovers_levels_by_walking_the_directory() -> None:
"""The discovery mechanism behind every test above: a directory walk."""
loader = (SOURCE_ROOT / "sim" / "content.py").read_text(encoding="utf-8")
assert "rglob" in loader
assert "iterdir" not in loader # one walk, at any depth, not a flat listing
+186
View File
@@ -0,0 +1,186 @@
"""Startup content gate (task 04): broken content refuses to start the app.
``create_app`` loads and validates the campaign once through
``load_validated_content``. These tests drive that function and the factory
directly: the shipped content loads; a level whose serve window is sealed
into a wall pocket raises ``ContentError`` and prints the named-rule report
on stderr; a missing recipe book does the same; and a *warning* band target
score logs a warning without refusing startup.
"""
from __future__ import annotations
import logging
import shutil
import textwrap
from pathlib import Path
import pytest
from undercooked.config import Settings
from undercooked.server.app import create_app
from undercooked.server.routes_content import (
RECIPES_FILENAME,
load_validated_content,
resolve_recipes_path,
)
from undercooked.sim.recipe import ContentError
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
def make_settings(levels_dir: Path) -> Settings:
return Settings(levels_dir=levels_dir, recipes_path=levels_dir / RECIPES_FILENAME)
# --- path resolution ----------------------------------------------------------
def test_recipes_path_defaults_inside_the_levels_dir() -> None:
settings = Settings(levels_dir=Path("/content/campaign"))
assert settings.recipes_path is None # the shipped default is "derive it"
assert resolve_recipes_path(settings) == Path("/content/campaign/recipes.yaml")
def test_recipes_path_override_wins() -> None:
settings = Settings(
levels_dir=Path("/content/campaign"), recipes_path=Path("/other/book.yaml")
)
assert resolve_recipes_path(settings) == Path("/other/book.yaml")
# --- the happy path ------------------------------------------------------------
def test_startup_loads_the_shipped_bundle() -> None:
application = create_app(Settings(levels_dir=SHIPPED_LEVELS))
loaded = application.state.content
assert [level.id for level in loaded.levels] == [
"01_first_kitchen",
"02_double_shift",
]
assert "burger" in loaded.recipe_book.recipes
# --- broken content refuses startup ---------------------------------------------
def copy_shipped_content(tmp_path: Path) -> Path:
"""A writable copy of the shipped campaign the tests may vandalise."""
target = tmp_path / "levels"
shutil.copytree(SHIPPED_LEVELS, target)
return target
def test_missing_recipe_book_refuses_startup_with_a_stderr_report(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
capsys.readouterr() # discard anything earlier tests printed
with pytest.raises(ContentError, match=r"recipes\.yaml"):
load_validated_content(make_settings(tmp_path))
err = capsys.readouterr().err
assert "content failed to load" in err
assert "recipes.yaml" in err
def test_unparsable_level_file_refuses_startup(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
levels = copy_shipped_content(tmp_path)
(levels / "03_broken.yaml").write_text("\tthis: [is not", encoding="utf-8")
capsys.readouterr()
with pytest.raises(ContentError, match=r"03_broken\.yaml"):
create_app(make_settings(levels))
assert "03_broken.yaml" in capsys.readouterr().err
def test_sealed_serve_window_refuses_startup_with_named_rule_report(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
levels = copy_shipped_content(tmp_path)
path = levels / "02_double_shift.yaml"
text = path.read_text(encoding="utf-8")
vandalism = text.replace('- "####=...=####"', '- "####=..#=####"', 1)
vandalism = vandalism.replace('- "#....P.X....#"', '- "#....P#X#...#"')
vandalism = vandalism.replace('- "#...........#"', '- "#......#....#"')
assert vandalism != text, "the grid rows this test vandalises changed"
path.write_text(vandalism, encoding="utf-8")
capsys.readouterr()
with pytest.raises(ContentError, match="SERVE_REACHABLE"):
create_app(make_settings(levels))
err = capsys.readouterr().err
assert "SERVE_REACHABLE" in err
assert "02_double_shift" in err
# --- warnings do not refuse startup ----------------------------------------------
WARN_BAND_CONTENT = """\
id: probe_level
name: Probe
order: 1
difficulty: 1
time_limit: 30
target_score: 40
blurb: A synthetic level that lands just inside the target-score warning band.
grid:
- "#######"
- "#T....#"
- "#.....#"
- "#....X#"
- "#######"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"X": {kind: floor, station: serve}
spawns: [[1, 1]]
orders:
pool: [probe]
concurrent: 1
spawn_interval: [10, 14]
deadline: 40
"""
WARN_BAND_RECIPES = """\
recipes:
probe:
id: probe
name: Probe Dish
base_score: 10
ingredients:
- {kind: tomato, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: serve, station: serve}
"""
def write_warn_band_content(tmp_path: Path) -> Path:
(tmp_path / RECIPES_FILENAME).write_text(
textwrap.dedent(WARN_BAND_RECIPES), encoding="utf-8"
)
(tmp_path / "probe_level.yaml").write_text(
textwrap.dedent(WARN_BAND_CONTENT), encoding="utf-8"
)
return tmp_path
def test_warn_band_target_starts_but_logs_the_warning(
tmp_path: Path, caplog: pytest.LogCaptureFixture
) -> None:
levels = write_warn_band_content(tmp_path)
with caplog.at_level(logging.WARNING, logger="undercooked.server.routes_content"):
bundle = load_validated_content(make_settings(levels))
assert [level.id for level in bundle.levels] == ["probe_level"]
messages = [record.getMessage() for record in caplog.records]
assert any("TARGET_IMPLAUSIBLE" in message for message in messages), messages
def test_warn_band_level_still_builds_an_app(tmp_path: Path) -> None:
levels = write_warn_band_content(tmp_path)
application = create_app(make_settings(levels))
assert application.state.content.by_id["probe_level"].target_score == 40
+7 -2
View File
@@ -204,9 +204,14 @@ def test_no_doc_file_exists_that_nothing_promises() -> None:
) )
def test_the_docs_on_disk_are_the_three_phase_01_documents() -> None: def test_the_docs_on_disk_are_the_shipped_documents() -> None:
"""Pins today's tree so that adding a document is a deliberate, named act.""" """Pins today's tree so that adding a document is a deliberate, named act."""
assert written_docs() == {"docs/DESIGN.md", "docs/DEPLOYMENT.md", "docs/TESTING.md"} assert written_docs() == {
"docs/DESIGN.md",
"docs/DEPLOYMENT.md",
"docs/LEVELS.md",
"docs/TESTING.md",
}
def stub_without_owner(relative: str, text: str) -> str | None: def stub_without_owner(relative: str, text: str) -> str | None:
+6 -1
View File
@@ -50,7 +50,12 @@ def test_fixture_levels_dir_exists(isolated_levels_dir: None) -> None:
levels_dir = Path(os.environ["UNDERCOOKED_LEVELS_DIR"]) levels_dir = Path(os.environ["UNDERCOOKED_LEVELS_DIR"])
assert levels_dir == REPO_ROOT / "tests" / "fixtures" / "levels" assert levels_dir == REPO_ROOT / "tests" / "fixtures" / "levels"
assert levels_dir.is_dir() assert levels_dir.is_dir()
assert not list(levels_dir.glob("*.yaml")) # Hermeticity is about *levels*: a default test must never see the shipped
# campaign. The committed `recipes.yaml` is not one (the loader never treats
# it as a level) — startup has required a recipe book since phase 02 task 04,
# and this minimal one is what lets fixture-isolated apps boot at all.
levels = [p.name for p in levels_dir.glob("*.yaml") if p.name != "recipes.yaml"]
assert not levels
def test_e2e_tests_follow_the_story_naming_convention() -> None: def test_e2e_tests_follow_the_story_naming_convention() -> None:
+440
View File
@@ -0,0 +1,440 @@
"""Unit tests for `undercooked.sim.level` (phase 02 task 02).
The level schema is what makes a YAML file *well-formed* — rectangular grid,
complete legend both directions, spawns on the map, exactly one serve, ordered
spawn intervals, placed hazards — and `build_grid`/`Level.build` are the
deterministic geometry those rules guarantee. Each validator branch gets a
failing document; the happy path asserts the documented defaults and station
id conventions from `docs/LEVELS.md`.
"""
from __future__ import annotations
from typing import Any
import pytest
from pydantic import ValidationError
from undercooked.sim.grid import MAX_GRID, Grid
from undercooked.sim.level import (
HazardSpec,
Level,
LevelDef,
OrdersSpec,
StationLimits,
TileSpec,
build_grid,
build_station_items,
build_stations,
)
from undercooked.sim.model import HazardKind, ItemKind, StationKind, TileKind
pytestmark = pytest.mark.unit
TILES: dict[str, Any] = {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"C": {"kind": "floor", "station": "chop_board"},
"V": {"kind": "floor", "station": "serve"},
}
GRID = ["#####", "#S..#", "#.C.#", "#..V#", "#####"]
def level_dict(**overrides: Any) -> dict[str, Any]:
"""A minimal valid level body; overrides replace top-level fields."""
body: dict[str, Any] = {
"id": "01_first_kitchen",
"name": "First Kitchen",
"order": 1,
"time_limit": 150,
"target_score": 45,
"grid": list(GRID),
"tiles": {key: dict(value) for key, value in TILES.items()},
"spawns": [[1, 1]],
"orders": {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [16, 20],
"deadline": 60,
},
}
body.update(overrides)
return body
def make_level(**overrides: Any) -> LevelDef:
return LevelDef.model_validate(level_dict(**overrides))
# ------------------------------------------------------------------------- TileSpec
def test_plain_tiles_default_to_no_station_no_item() -> None:
spec = TileSpec.model_validate({"kind": "floor"})
assert spec.kind is TileKind.FLOOR
assert spec.station is None
assert spec.item is None
assert spec.id_prefix is None
def test_spawn_shelf_must_name_the_item_it_spawns() -> None:
with pytest.raises(ValidationError, match="must name the item"):
TileSpec.model_validate({"kind": "floor", "station": "spawn_shelf"})
def test_only_a_spawn_shelf_may_carry_an_item() -> None:
with pytest.raises(ValidationError, match="only a spawn_shelf"):
TileSpec.model_validate({"kind": "floor", "station": "chop_board", "item": "tomato"})
def test_item_without_any_station_is_meaningless() -> None:
with pytest.raises(ValidationError, match="only a spawn_shelf"):
TileSpec.model_validate({"kind": "floor", "item": "tomato"})
def test_id_prefix_belongs_to_a_station_tile() -> None:
with pytest.raises(ValidationError, match="id_prefix belongs to a station"):
TileSpec.model_validate({"kind": "wall", "id_prefix": "north"})
def test_id_prefix_must_not_be_empty() -> None:
with pytest.raises(ValidationError):
TileSpec.model_validate({"kind": "floor", "station": "stove", "id_prefix": ""})
def test_tilespec_rejects_unknown_keys_and_values() -> None:
with pytest.raises(ValidationError):
TileSpec.model_validate({"kind": "floor", "glow": True})
with pytest.raises(ValidationError):
TileSpec.model_validate({"kind": "swamp"})
# ------------------------------------------------------------------------ OrdersSpec
def test_orders_defaults() -> None:
orders = OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 42}
)
assert orders.penalty == 10
assert orders.spawn_interval == (7.0, 11.0)
def test_spawn_interval_min_must_not_exceed_max() -> None:
with pytest.raises(ValidationError, match="greater than its end"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [12, 8], "deadline": 42}
)
def test_spawn_interval_must_start_above_zero() -> None:
with pytest.raises(ValidationError, match="above 0"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [0, 8], "deadline": 42}
)
def test_orders_pool_needs_recipes_and_sane_numbers() -> None:
with pytest.raises(ValidationError, match="pool"):
OrdersSpec.model_validate(
{"pool": [], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 42}
)
with pytest.raises(ValidationError, match="concurrent"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 0, "spawn_interval": [7, 11], "deadline": 42}
)
with pytest.raises(ValidationError, match="deadline"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 0}
)
with pytest.raises(ValidationError, match="penalty"):
OrdersSpec.model_validate(
{"pool": ["soup"], "concurrent": 1, "spawn_interval": [7, 11], "deadline": 42,
"penalty": -1}
)
# ------------------------------------------------------------------------ HazardSpec
def hazard_dict(**overrides: Any) -> dict[str, Any]:
body: dict[str, Any] = {"type": "spill", "x": 3, "y": 4}
body.update(overrides)
return body
def test_hazard_defaults() -> None:
hazard = HazardSpec.model_validate(hazard_dict())
assert hazard.type is HazardKind.SPILL
assert hazard.chance_per_s is None
assert hazard.respawn_s is None
assert hazard.path is None
assert hazard.intensity == 1.0
def test_fire_may_attach_to_a_station() -> None:
hazard = HazardSpec.model_validate({"type": "fire", "station": "stove_2"})
assert hazard.station == "stove_2"
def test_fire_needs_a_station_or_coordinates() -> None:
with pytest.raises(ValidationError, match="fire hazard needs a station or x/y"):
HazardSpec.model_validate({"type": "fire"})
def test_spill_needs_a_location_too() -> None:
with pytest.raises(ValidationError, match="spill hazard needs a station or x/y"):
HazardSpec.model_validate({"type": "spill"})
def test_station_and_coordinates_are_exclusive() -> None:
with pytest.raises(ValidationError, match="not both"):
HazardSpec.model_validate({"type": "fire", "station": "stove_0", "x": 2, "y": 3})
def test_coordinates_come_as_a_pair() -> None:
with pytest.raises(ValidationError, match="given together"):
HazardSpec.model_validate({"type": "spill", "x": 2})
with pytest.raises(ValidationError, match="given together"):
HazardSpec.model_validate({"type": "spill", "y": 3})
def test_moving_obstacle_needs_a_route_and_a_period() -> None:
with pytest.raises(ValidationError, match="path of at least 2 points"):
HazardSpec.model_validate({"type": "moving_obstacle", "period_s": 9})
with pytest.raises(ValidationError, match="path of at least 2 points"):
HazardSpec.model_validate(
{"type": "moving_obstacle", "path": [[4, 3]], "period_s": 9}
)
with pytest.raises(ValidationError, match="needs period_s"):
HazardSpec.model_validate({"type": "moving_obstacle", "path": [[4, 3], [10, 3]]})
def test_moving_obstacle_happy_path_needs_no_placement() -> None:
hazard = HazardSpec.model_validate(
{"type": "moving_obstacle", "path": [[4, 3], [10, 3]], "period_s": 9}
)
assert hazard.path == [(4, 3), (10, 3)]
assert hazard.period_s == 9.0
def test_rush_is_global_but_needs_positive_intensity() -> None:
assert HazardSpec.model_validate({"type": "rush"}).intensity == 1.0
with pytest.raises(ValidationError, match="positive intensity"):
HazardSpec.model_validate({"type": "rush", "intensity": 0})
def test_hazard_numeric_bounds() -> None:
with pytest.raises(ValidationError, match="chance_per_s"):
HazardSpec.model_validate(hazard_dict(chance_per_s=-0.01))
with pytest.raises(ValidationError, match="respawn_s"):
HazardSpec.model_validate(hazard_dict(respawn_s=0))
# -------------------------------------------------------------------------- LevelDef
def test_level_defaults_match_the_documented_values() -> None:
level = make_level()
assert level.hazards == []
assert level.station_limits.plates == 99
assert level.station_limits.pots == 99
assert level.difficulty == 1
assert level.blurb == ""
assert level.escalation_notes == ""
def test_ragged_grid_is_refused() -> None:
with pytest.raises(ValidationError, match="ragged grid: row 4 is 4 chars, want 5"):
make_level(grid=["#####", "#S..#", "#.C.#", "#..V#", "####"])
def test_empty_rows_are_refused() -> None:
with pytest.raises(ValidationError, match="rows must not be empty"):
make_level(grid=["", ""], tiles={".": {"kind": "floor"}, "V": {"kind": "floor",
"station": "serve"}}, spawns=[])
def test_grid_larger_than_the_engine_supports_is_refused() -> None:
wide = "#" * (MAX_GRID + 1)
with pytest.raises(ValidationError, match=f"at most {MAX_GRID}x{MAX_GRID}"):
make_level(grid=[wide, wide])
def test_grid_character_without_a_legend_entry_is_refused() -> None:
with pytest.raises(ValidationError, match="grid character 'Z' has no tiles"):
make_level(grid=["#####", "#Z..#", "#.C.#", "#..V#", "#####"])
def test_unused_legend_character_is_refused() -> None:
with pytest.raises(ValidationError, match="legend character 'X' is never used"):
make_level(tiles={**TILES, "X": {"kind": "wall"}})
def test_level_needs_at_least_one_spawn() -> None:
with pytest.raises(ValidationError, match="at least one spawn"):
make_level(spawns=[])
def test_spawns_must_be_inside_the_grid() -> None:
with pytest.raises(ValidationError, match=r"spawn \(9,9\) is outside the 5x5 grid"):
make_level(spawns=[[9, 9]])
with pytest.raises(ValidationError, match=r"spawn \(-1,2\) is outside"):
make_level(spawns=[[-1, 2]])
def test_level_needs_exactly_one_serve_legend() -> None:
no_serve = {key: dict(value) for key, value in TILES.items() if key != "V"}
with pytest.raises(ValidationError, match="found 0"):
make_level(grid=["#####", "#S..#", "#.C.#", "#...#", "#####"], tiles=no_serve)
with pytest.raises(ValidationError, match="found 2"):
make_level(
grid=["#####", "#S..#", "#.C.#", "#V.U#", "#####"],
tiles={**TILES, "U": {"kind": "floor", "station": "serve"}},
)
def test_level_numbers_are_validated() -> None:
with pytest.raises(ValidationError, match="difficulty"):
make_level(difficulty=11)
with pytest.raises(ValidationError, match="difficulty"):
make_level(difficulty=0)
with pytest.raises(ValidationError, match="order"):
make_level(order=0)
with pytest.raises(ValidationError, match="time_limit"):
make_level(time_limit=0)
with pytest.raises(ValidationError, match="target_score"):
make_level(target_score=0)
def test_level_rejects_unknown_keys() -> None:
with pytest.raises(ValidationError):
make_level(weather="rain")
def test_level_requires_a_grid_and_a_legend() -> None:
with pytest.raises(ValidationError, match="grid"):
make_level(grid=[])
with pytest.raises(ValidationError, match="tiles"):
make_level(tiles={})
def test_hazards_and_station_limits_round_trip_on_the_level() -> None:
level = make_level(
hazards=[
{"type": "fire", "station": "stove_2", "chance_per_s": 0.02},
{"type": "moving_obstacle", "path": [[4, 3], [10, 3]], "period_s": 9},
],
station_limits={"plates": 3, "pots": 2},
difficulty=7,
blurb="A kitchen.",
escalation_notes="tighter",
)
assert [hazard.type for hazard in level.hazards] == [
HazardKind.FIRE,
HazardKind.MOVING_OBSTACLE,
]
assert level.station_limits == StationLimits(plates=3, pots=2)
assert level.difficulty == 7
# ------------------------------------------------------------------- building geometry
def test_build_grid_maps_the_legend_and_marks_station_cells() -> None:
level = make_level()
grid = build_grid(level)
assert isinstance(grid, Grid)
assert (grid.width, grid.height) == (5, 5)
assert grid.tile_at(0, 0) is TileKind.WALL
assert grid.tile_at(1, 1) is TileKind.FLOOR
assert dict(grid.station_at) == {6: "spawn_shelf_0", 12: "chop_board_0", 18: "serve_0"}
def test_station_ids_count_per_kind_in_reading_order() -> None:
stations = build_stations(make_level())
assert [(station.id, station.kind) for station in stations] == [
("spawn_shelf_0", StationKind.SPAWN_SHELF),
("chop_board_0", StationKind.CHOP_BOARD),
("serve_0", StationKind.SERVE),
]
def test_two_legend_chars_of_one_station_kind_share_one_counter() -> None:
level = make_level(
grid=["######", "#T...#", "#..t.#", "#...V#", "######"],
tiles={
".": {"kind": "floor"},
"#": {"kind": "wall"},
"T": {"kind": "floor", "station": "stove"},
"t": {"kind": "floor", "station": "stove", "id_prefix": "burner"},
"V": {"kind": "floor", "station": "serve"},
},
)
stations = build_stations(level)
assert [station.id for station in stations] == ["stove_0", "burner_1", "serve_0"]
def test_station_positions_are_float_tile_coordinates() -> None:
chop = next(station for station in build_stations(make_level()) if station.kind
is StationKind.CHOP_BOARD)
assert (chop.x, chop.y) == (2.0, 2.0)
def test_a_station_on_a_non_floor_tile_still_builds_and_is_task_03_problem() -> None:
level = make_level(
grid=["#####", "#S..#", "#.C.#", "#.=V#", "#####"],
tiles={**TILES, "=": {"kind": "counter", "station": "stove"}},
)
grid = build_grid(level)
assert grid.tile_at(2, 3) is TileKind.COUNTER
assert grid.station_at[17] == "stove_0"
def test_station_items_cover_only_spawn_shelves() -> None:
level = make_level(
grid=["######", "#Ss..#", "#.C..#", "#...V#", "######"],
tiles={
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"s": {"kind": "floor", "station": "spawn_shelf", "item": "onion"},
"C": {"kind": "floor", "station": "chop_board"},
"V": {"kind": "floor", "station": "serve"},
},
spawns=[[1, 1], [2, 1]],
)
assert build_station_items(level) == {
"spawn_shelf_0": ItemKind.TOMATO,
"spawn_shelf_1": ItemKind.ONION,
}
def test_level_build_pairs_the_spec_with_grid_stations_and_spawns() -> None:
spec = make_level()
level = Level.build(spec)
assert level.spec is spec
assert level.grid == build_grid(spec)
assert level.stations == build_stations(spec)
assert level.spawns == ((1, 1),)
assert level.station_items == {"spawn_shelf_0": ItemKind.TOMATO}
def test_level_delegates_the_authoring_fields_to_its_spec() -> None:
level = Level.build(
make_level(difficulty=3, blurb="Cook!", escalation_notes="harder",
station_limits={"plates": 4})
)
assert level.id == "01_first_kitchen"
assert level.name == "First Kitchen"
assert level.order == 1
assert level.difficulty == 3
assert level.time_limit == 150
assert level.target_score == 45
assert level.orders.pool == ["soup"]
assert level.hazards == []
assert level.station_limits.plates == 4
assert level.blurb == "Cook!"
assert level.escalation_notes == "harder"
+839
View File
@@ -0,0 +1,839 @@
"""Unit tests for the ``undercooked-levels`` author CLI (phase 02 task 05).
The CLI is the author's half of the content contract, so these tests pin the
three things a human depends on:
* **the same code path as the server** — `validate` is `load_content` +
`validate_bundle`, so a green run here is a green `create_app()`;
* **the output contract** — one line per level, the named-rule report, a
`--json` document a script can parse, the `show --grid` reachability overlay
that makes a sealed pocket visible, and the `axes` table phase 11 will feed;
* **the exit codes** — 0 usable, 1 a rule is broken, 2 nothing usable to work
with (load failure, unknown id, refused overwrite, usage error).
Content is addressed explicitly with ``--dir`` (the session-wide
`UNDERCOOKED_LEVELS_DIR` fixture points at an empty campaign), plus one test
that deletes the variable and ``chdir``s to prove the documented commands still
resolve ``levels/`` from the checkout rather than from luck about cwd.
"""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import sys
import tomllib
from collections.abc import Iterator, Sequence
from dataclasses import replace
from pathlib import Path
import pytest
import yaml
from undercooked.config import Settings, load_settings
from undercooked.server.routes_content import RECIPES_FILENAME
from undercooked.sim.content import ContentBundle, load_content, load_level_file
from undercooked.sim.recipe import ContentError, RecipeBook, load_recipe_book
from undercooked.sim.validation import (
AXIS_NAMES,
LevelViolation,
ValidationReport,
escalation_axes,
)
from undercooked.tools import levels
from undercooked.tools.levels import (
CHECKOUT_ROOT,
EXIT_OK,
EXIT_UNUSABLE,
EXIT_VIOLATIONS,
LIST_HEADER,
STARTER_HEIGHT,
_assign_chars,
_char_for,
_CliError,
_level_status,
describe_level,
main,
reachability_overlay,
render_starter_yaml,
resolve_content_paths,
)
pytestmark = pytest.mark.unit
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIPPED_LEVELS = REPO_ROOT / "levels"
SHIPPED_RECIPES = SHIPPED_LEVELS / RECIPES_FILENAME
BAD_LEVELS = REPO_ROOT / "tests" / "fixtures" / "levels" / "_bad"
PYPROJECT = REPO_ROOT / "pyproject.toml"
def run(argv: Sequence[str]) -> int:
"""The console script's entry point, in-process (same target as the wrapper)."""
return main(list(argv))
@pytest.fixture()
def shipped_bundle() -> ContentBundle:
return load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
@pytest.fixture()
def campaign_copy(tmp_path: Path) -> Iterator[Path]:
"""A writable copy of the shipped campaign that `new` may extend."""
target = tmp_path / "campaign"
shutil.copytree(SHIPPED_LEVELS, target)
yield target
def write_campaign(tmp_path: Path, level_yaml: str, book_yaml: str) -> Path:
"""A throwaway campaign directory built from two YAML documents."""
directory = tmp_path / "mini"
directory.mkdir(parents=True, exist_ok=True)
(directory / RECIPES_FILENAME).write_text(book_yaml, encoding="utf-8")
(directory / "01_mini.yaml").write_text(level_yaml, encoding="utf-8")
return directory
# --- path resolution -----------------------------------------------------------
def test_absolute_flags_win_over_everything() -> None:
levels_dir, recipes = resolve_content_paths(
Settings(levels_dir=SHIPPED_LEVELS, recipes_path=SHIPPED_RECIPES),
Path("/content/levels"),
Path("/content/book.yaml"),
)
assert levels_dir == Path("/content/levels")
assert recipes == Path("/content/book.yaml")
def test_the_recipe_book_defaults_to_the_one_inside_the_levels_dir() -> None:
levels_dir, recipes = resolve_content_paths(Settings(levels_dir=SHIPPED_LEVELS))
assert levels_dir == SHIPPED_LEVELS
assert recipes == SHIPPED_RECIPES
def test_the_recipe_book_flag_beats_the_environment(tmp_path: Path, monkeypatch) -> None:
env_book = tmp_path / "env-book.yaml"
monkeypatch.setenv("UNDERCOOKED_RECIPES_PATH", str(env_book))
_, from_env = resolve_content_paths(load_settings())
assert from_env == env_book
_, from_flag = resolve_content_paths(load_settings(), None, SHIPPED_RECIPES)
assert from_flag == SHIPPED_RECIPES
def test_a_relative_path_that_exists_here_is_used_here(tmp_path: Path, monkeypatch) -> None:
base = tmp_path.resolve()
(base / "levels").mkdir()
monkeypatch.chdir(base)
levels_dir, recipes = resolve_content_paths(Settings(levels_dir=Path("levels")))
assert levels_dir == base / "levels"
assert recipes == base / "levels" / RECIPES_FILENAME
def test_a_relative_path_with_nothing_local_anchors_to_the_checkout(
tmp_path: Path, monkeypatch
) -> None:
"""The documented `undercooked-levels validate` works from any directory."""
monkeypatch.chdir(tmp_path)
levels_dir, recipes = resolve_content_paths(Settings(levels_dir=Path("levels")))
assert levels_dir == CHECKOUT_ROOT / "levels"
assert recipes == CHECKOUT_ROOT / "levels" / RECIPES_FILENAME
assert levels_dir.is_dir()
def test_a_path_that_exists_nowhere_is_reported_as_it_was_typed(
tmp_path: Path, monkeypatch
) -> None:
monkeypatch.chdir(tmp_path)
levels_dir, recipes = resolve_content_paths(
Settings(levels_dir=Path("nope/levels"), recipes_path=Path("nope/book.yaml"))
)
assert levels_dir == Path("nope/levels")
assert recipes == Path("nope/book.yaml")
def test_a_home_relative_path_is_expanded() -> None:
levels_dir, _ = resolve_content_paths(Settings(levels_dir=Path("~/levels")))
assert levels_dir == Path.home() / "levels"
# --- validate -------------------------------------------------------------------
def test_validate_is_green_on_the_shipped_campaign_and_prints_one_line_per_level(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["validate", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
lines = capsys.readouterr().out.splitlines()
assert lines[-1] == "content OK: no violations"
assert len(lines) == len(shipped_bundle.levels) + 1
for level, line in zip(shipped_bundle.levels, lines[:-1], strict=True):
assert line.startswith(level.id)
assert "ok" in line
assert f"{level.grid.width}x{level.grid.height}" in line
def test_validate_exits_1_and_names_every_rule_it_hit(
capsys: pytest.CaptureFixture[str]
) -> None:
assert run(["validate", "--dir", str(BAD_LEVELS)]) == EXIT_VIOLATIONS
out = capsys.readouterr().out
assert "99_broken_room" in out
assert "4 error(s)" in out
for rule in ("SERVE_REACHABLE", "RECIPE_STATION_MISSING", "TARGET_IMPLAUSIBLE"):
assert rule in out
def test_validate_json_is_machine_readable_when_green(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["validate", "--dir", str(SHIPPED_LEVELS), "--json"]) == EXIT_OK
payload = json.loads(capsys.readouterr().out)
assert payload["ok"] is True
assert payload["errors"] == []
assert payload["warnings"] == []
assert payload["counts"] == {"errors": 0, "warnings": 0}
assert [level["id"] for level in payload["levels"]] == [
level.id for level in shipped_bundle.levels
]
def test_validate_json_carries_the_rule_hits_and_their_detail(
capsys: pytest.CaptureFixture[str]
) -> None:
assert run(["validate", "--dir", str(BAD_LEVELS), "--json"]) == EXIT_VIOLATIONS
payload = json.loads(capsys.readouterr().out)
errors = payload["errors"]
assert payload["ok"] is False
assert payload["counts"] == {"errors": len(errors), "warnings": len(payload["warnings"])}
assert {error["rule"] for error in errors} >= {
"SERVE_REACHABLE",
"RECIPE_STATION_MISSING",
"TARGET_IMPLAUSIBLE",
}
assert {error["level"] for error in errors} == {"99_broken_room"}
assert {"recipe": "soup", "station": "stove"} in [error["detail"] for error in errors]
keys = [(error["level"], error["rule"], error["message"]) for error in errors]
assert keys == sorted(keys)
def test_validate_json_is_what_python_m_json_tool_accepts(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
"""The documented pipe: `undercooked-levels validate --json | python -m json.tool`."""
assert run(["validate", "--dir", str(BAD_LEVELS), "--json"]) == EXIT_VIOLATIONS
raw = capsys.readouterr().out
formatted = subprocess.run(
[sys.executable, "-m", "json.tool"],
input=raw,
capture_output=True,
text=True,
timeout=60,
check=False,
)
assert formatted.returncode == 0, formatted.stderr
assert "SERVE_REACHABLE" in formatted.stdout
assert json.loads(formatted.stdout)["ok"] is False
assert tmp_path # nothing is written; the report only goes to stdout
def test_validate_names_a_missing_directory_and_exits_2(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
missing = tmp_path / "not-here"
assert run(["validate", "--dir", str(missing)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert err.startswith("undercooked-levels:")
assert "not-here" in err
def test_validate_reports_a_broken_recipe_book_as_exit_2(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
directory = tmp_path / "levels"
directory.mkdir()
(directory / RECIPES_FILENAME).write_text("\tthis: [is not", encoding="utf-8")
assert run(["validate", "--dir", str(directory)]) == EXIT_UNUSABLE
assert "recipes.yaml" in capsys.readouterr().err
def test_validate_follows_the_levels_dir_from_the_environment(
capsys: pytest.CaptureFixture[str], monkeypatch, campaign_copy: Path
) -> None:
monkeypatch.setenv("UNDERCOOKED_LEVELS_DIR", str(campaign_copy))
assert run(["validate"]) == EXIT_OK
assert "02_double_shift" in capsys.readouterr().out
@pytest.mark.parametrize(
"argv",
[
["validate"],
["list"],
["axes"],
["show", "01_first_kitchen", "--grid"],
],
)
def test_every_subcommand_resolves_levels_from_the_checkout_not_from_cwd(
argv: list[str], capsys: pytest.CaptureFixture[str], tmp_path: Path, monkeypatch
) -> None:
"""No `--dir`, empty cwd, unset env: the built-in `levels` still finds campaign 02."""
monkeypatch.delenv("UNDERCOOKED_LEVELS_DIR", raising=False)
monkeypatch.delenv("UNDERCOOKED_RECIPES_PATH", raising=False)
monkeypatch.chdir(tmp_path)
assert run(argv) == EXIT_OK
assert "01_first_kitchen" in capsys.readouterr().out
def test_level_status_counts_warnings_of_a_warning_only_level() -> None:
bundle = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES)
level = bundle.levels[0]
warning = LevelViolation(rule="TARGET_IMPLAUSIBLE", level_id=level.id, message="tight")
clean = _level_status(level, ValidationReport())
noisy = _level_status(level, ValidationReport(violations=(), warnings=(warning,)))
error = LevelViolation(rule="SERVE_REACHABLE", level_id=level.id, message="sealed")
broken = _level_status(level, ValidationReport(violations=(error,), warnings=(warning,)))
assert clean == f"{level.id:<28} {'ok':<24} {level.grid.width}x{level.grid.height}" + (
f", {len(level.stations)} stations"
)
assert "0 error(s)" not in clean
assert ", 1 warning(s)" in noisy
assert "1 error(s), 1 warning(s)" in broken
# --- list -----------------------------------------------------------------------
def test_list_prints_one_line_per_level_in_campaign_order(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["list", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
lines = capsys.readouterr().out.splitlines()
assert lines[0] == LIST_HEADER
assert len(lines) == 1 + len(shipped_bundle.levels)
for level, line in zip(shipped_bundle.levels, lines[1:], strict=True):
assert line.startswith(f"{level.order:>5} {level.id:<28}")
assert str(level.target_score) in line
assert f"{level.time_limit:>6.1f}" in line
for recipe_id in level.orders.pool:
assert recipe_id in line
def test_list_of_an_empty_campaign_is_only_the_header(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
empty = tmp_path / "empty"
empty.mkdir()
shutil.copy(SHIPPED_RECIPES, empty / RECIPES_FILENAME)
assert run(["list", "--dir", str(empty)]) == EXIT_OK
assert capsys.readouterr().out.splitlines() == [LIST_HEADER]
# --- show -----------------------------------------------------------------------
def test_show_prints_the_resolved_level(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
level = shipped_bundle.levels[1]
assert run(["show", level.id, "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
out = capsys.readouterr().out
assert out.startswith(f"{level.id} - {level.name}")
for label in (
"order",
"difficulty",
"time_limit",
"target_score",
"grid",
"spawns",
"stations",
"orders",
"limits",
"hazards",
"reachability",
"recipes",
"blurb",
"escalation_notes",
):
assert f"{label:<18}" in out
assert "spawn_shelf x" in out
assert "map (as authored)" not in out # --grid is opt-in
def test_show_grid_adds_the_authored_map_and_the_reachability_overlay(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
level = shipped_bundle.levels[0]
assert run(["show", level.id, "--grid", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
out = capsys.readouterr().out
assert "map (as authored)" in out
for row in level.spec.grid:
assert f" {row}" in out
assert ". reachable" in out
assert "?" not in out.split("reachability")[1] # the shipped kitchen has no dead pockets
assert f"{level.grid.width}x{level.grid.height}" in out
def test_show_grid_marks_a_sealed_pocket_as_unreachable(
capsys: pytest.CaptureFixture[str]
) -> None:
assert run(["show", "99_broken_room", "--grid", "--dir", str(BAD_LEVELS)]) == EXIT_OK
out = capsys.readouterr().out
overlay = out.split("? walkable but cut off")[1]
assert "??" in overlay
assert "????#" in overlay # the walled-in servery row
assert "walkable tiles are reachable from the spawns" in overlay
def test_show_rejects_an_unknown_level_id(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["show", "07_no_such_kitchen", "--dir", str(SHIPPED_LEVELS)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "no level '07_no_such_kitchen'" in err
assert "01_first_kitchen" in err # it says what does exist
# --- axes -----------------------------------------------------------------------
def test_axes_table_lists_every_axis_for_every_level_in_campaign_order(
capsys: pytest.CaptureFixture[str], shipped_bundle: ContentBundle
) -> None:
assert run(["axes", "--dir", str(SHIPPED_LEVELS)]) == EXIT_OK
lines = capsys.readouterr().out.splitlines()
header = lines[0]
assert header.startswith("LEVEL")
for name in AXIS_NAMES:
assert name in header
assert set(AXIS_NAMES) == set(escalation_axes(shipped_bundle.levels[0]))
assert len(lines) == 1 + len(shipped_bundle.levels)
for level, line in zip(shipped_bundle.levels, lines[1:], strict=True):
assert line.startswith(f"{level.id:<28}{level.order:>6}")
axes = escalation_axes(level)
for name in AXIS_NAMES:
assert f"{axes[name]:>20.4f}" in line
def test_axes_of_an_empty_campaign_is_only_the_header(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
empty = tmp_path / "empty"
empty.mkdir()
shutil.copy(SHIPPED_RECIPES, empty / RECIPES_FILENAME)
assert run(["axes", "--dir", str(empty)]) == EXIT_OK
assert len(capsys.readouterr().out.splitlines()) == 1
# --- new ------------------------------------------------------------------------
def test_new_writes_a_starter_level_and_validates_it(
capsys: pytest.CaptureFixture[str], campaign_copy: Path, shipped_bundle: ContentBundle
) -> None:
assert run(["new", "03_zero_cool", "--dir", str(campaign_copy)]) == EXIT_OK
out = capsys.readouterr().out
assert f"wrote {campaign_copy / '03_zero_cool.yaml'}" in out
assert "content OK: no violations" in out
written = campaign_copy / "03_zero_cool.yaml"
body = yaml.safe_load(written.read_text(encoding="utf-8"))
top_difficulty = max(level.difficulty for level in shipped_bundle.levels)
top_order = max(level.order for level in shipped_bundle.levels)
assert body["id"] == "03_zero_cool"
assert body["order"] == top_order + 1
assert body["difficulty"] == top_difficulty + 1
# It is a real level: it loads, and the campaign now carries three of them.
level = load_level_file(written)
assert level.name == "Zero Cool"
assert load_content(campaign_copy, SHIPPED_RECIPES).levels[-1].id == "03_zero_cool"
# ...and the scaffolded kitchen is self-consistent: every grid letter used is
# declared, and nothing declared is left unused.
assert set(body["tiles"]) == {cell for row in body["grid"] for cell in row}
def test_new_uses_the_requested_order(
capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
assert run(["new", "07_late_shift", "--order", "7", "--dir", str(campaign_copy)]) == EXIT_OK
body = yaml.safe_load((campaign_copy / "07_late_shift.yaml").read_text(encoding="utf-8"))
assert body["order"] == 7
capsys.readouterr()
def test_new_refuses_to_overwrite_an_existing_level(
capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
target = campaign_copy / "01_first_kitchen.yaml"
before = target.read_text(encoding="utf-8")
assert run(["new", "01_first_kitchen", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "already exists" in err
assert "refusing to overwrite" in err
assert target.read_text(encoding="utf-8") == before
@pytest.mark.parametrize("candidate", ["ZeroCool", "03 zero cool", "_private", "03-second"])
def test_new_rejects_an_id_that_is_not_a_filename_safe_slug(
candidate: str, capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
assert run(["new", candidate, "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
assert "slug" in capsys.readouterr().err
assert not (campaign_copy / f"{candidate}.yaml").exists()
def test_new_rejects_an_order_below_one(
capsys: pytest.CaptureFixture[str], campaign_copy: Path
) -> None:
assert run(["new", "03_probe", "--order", "0", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
assert "--order must be at least 1" in capsys.readouterr().err
def test_new_needs_a_readable_campaign(
capsys: pytest.CaptureFixture[str], tmp_path: Path
) -> None:
assert run(["new", "03_probe", "--dir", str(tmp_path / "nothing")]) == EXIT_UNUSABLE
assert "undercooked-levels:" in capsys.readouterr().err
@pytest.mark.parametrize(
"error",
[OSError("disk full"), OSError(28, "No space left on device")],
ids=["no-strerror", "with-strerror"],
)
def test_new_reports_a_write_failure(
error: OSError,
capsys: pytest.CaptureFixture[str],
campaign_copy: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
def refuse(self: Path, *args: object, **kwargs: object) -> int:
raise error
monkeypatch.setattr(Path, "write_text", refuse)
assert run(["new", "03_probe", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "cannot write" in err
assert (error.strerror or str(error)) in err
def test_new_reports_a_file_the_loader_rejects(
capsys: pytest.CaptureFixture[str], campaign_copy: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
def refuse(path: Path | str) -> object:
raise ContentError("the loader hated the scaffold")
monkeypatch.setattr(levels, "load_level_file", refuse)
assert run(["new", "03_probe", "--dir", str(campaign_copy)]) == EXIT_UNUSABLE
assert "the loader hated the scaffold" in capsys.readouterr().err
# --- the scaffold itself --------------------------------------------------------
def test_the_scaffold_is_built_from_the_recipe_book_not_a_template_kitchen() -> None:
book = RecipeBook.model_validate(
{
"recipes": {
"broth": {
"id": "broth",
"name": "Plain Broth",
"base_score": 20,
"ingredients": [
{"kind": "rice", "form": "raw"},
{"kind": "fish", "form": "raw"},
],
"steps": [
{"kind": "pick", "station": "spawn_shelf"},
{"kind": "wash", "station": "sink"},
{"kind": "cook", "station": "stove"},
{"kind": "serve", "station": "serve"},
],
}
}
}
)
text = render_starter_yaml(level_id="05_broth_house", order=5, difficulty=4, book=book)
body = yaml.safe_load(text)
assert body["id"] == "05_broth_house"
assert body["name"] == "Broth House"
assert body["order"] == 5
assert body["difficulty"] == 4
assert body["orders"]["pool"] == ["broth"]
stations = {spec["station"] for spec in body["tiles"].values() if "station" in spec}
items = {spec["item"] for spec in body["tiles"].values() if "item" in spec}
assert stations == {"spawn_shelf", "sink", "stove", "serve"} # no chop board: no chop step
assert items == {"rice", "fish"}
assert set(body["tiles"]) == {cell for row in body["grid"] for cell in row}
assert len({len(row) for row in body["grid"]}) == 1
def test_the_scaffold_of_the_shipped_book_shelves_that_books_first_dish() -> None:
book = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES).recipe_book
first = book.ids()[0]
body = yaml.safe_load(
render_starter_yaml(level_id="05_probe", order=5, difficulty=1, book=book)
)
expected_items = {
ingredient.kind.value
for ingredient in book.get(first).ingredients
if str(ingredient.form) == "raw"
}
assert {spec["item"] for spec in body["tiles"].values() if "item" in spec} == expected_items
assert body["orders"]["pool"] == [first]
def test_the_scaffold_needs_something_to_cook() -> None:
with pytest.raises(_CliError):
render_starter_yaml(level_id="05_probe", order=1, difficulty=1, book=RecipeBook(recipes={}))
def test_the_scaffold_keeps_the_spawn_far_enough_from_the_window() -> None:
book = load_content(SHIPPED_LEVELS, SHIPPED_RECIPES).recipe_book
body = yaml.safe_load(
render_starter_yaml(level_id="05_probe", order=1, difficulty=5, book=book)
)
width = len(body["grid"][0])
assert body["grid"][0] == "#" * width == body["grid"][-1]
assert len(body["grid"]) == STARTER_HEIGHT
fixtures = body["grid"][4]
serve_x = fixtures.index(
next(char for char, spec in body["tiles"].items() if spec.get("station") == "serve")
)
assert fixtures[serve_x] != "." # the window really stands on the fixture row
for x, y in body["spawns"]:
assert abs(x - serve_x) + abs(y - 4) >= 3 # TRAVEL_TOO_SHORT's floor
def test_grid_letters_stay_unique_when_a_preferred_letter_is_taken() -> None:
free = {"#", "."}
assert _char_for("T", free) == "T" # an untouched preference wins
assert _char_for("T", free | {"T"}) not in free | {"T"} # taken: a spare instead
assert _char_for(None, free) not in free
used = set(free)
assigned = _assign_chars(
["tomato", "onion", "lettuce", "rice"],
{"tomato": "T", "onion": "T", "lettuce": "T", "rice": "R"},
used,
)
assert list(assigned) == ["tomato", "onion", "lettuce", "rice"]
assert assigned["tomato"] == "T" # first in line keeps its preferred letter
assert assigned["onion"] != "T" # the collisions fall back to spares
assert assigned["rice"] == "R"
assert len(set(assigned.values())) == 4
assert used == {"#", ".", *assigned.values()} # the caller's set is the ledger
# --- describe_level / overlay directly ------------------------------------------
MINI_BOOK = """\
recipes:
snack:
id: snack
name: Plain Snack
base_score: 20
ingredients:
- {kind: tomato, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: serve, station: serve}
"""
MINI_LEVEL = """\
id: 01_mini
name: Mini
order: 1
time_limit: 90
target_score: 40
grid:
- "#######"
- "#T....#"
- "#.#.#.#"
- "#....X#"
- "#######"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"X": {kind: floor, station: serve}
spawns: [[1, 1]]
orders:
pool: [snack]
concurrent: 1
spawn_interval: [10, 14]
deadline: 40
"""
#: The same kitchen with a cupboard walled in at (3, 3) and (4, 3): walkable
#: floor no spawn can reach, which is exactly what the overlay exists to show.
POCKET_LEVEL = MINI_LEVEL.replace(
"""\
id: 01_mini
name: Mini""",
"""\
id: 02_pocket
name: Pocket""",
).replace(
"""\
- "#######"
- "#T....#"
- "#.#.#.#"
- "#....X#"
- "#######""",
"""\
- "########"
- "#T....X#"
- "#.####.#"
- "#.#BB#.#"
- "#.####.#"
- "########""",
).replace(
' "X": {kind: floor, station: serve}',
' "X": {kind: floor, station: serve}\n "B": {kind: floor}',
)
def test_describe_marks_missing_prose_as_none(tmp_path: Path) -> None:
directory = write_campaign(tmp_path, MINI_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
out = "\n".join(describe_level(level, load_recipe_book(directory / RECIPES_FILENAME)))
assert "snack: Plain Snack" in out # the prose is missing, the recipe is not
assert out.count("(none)") == 2 # blurb and escalation_notes say so out loud
def test_describe_reports_a_pool_recipe_the_book_does_not_define(tmp_path: Path) -> None:
directory = write_campaign(tmp_path, MINI_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
out = "\n".join(describe_level(level, RecipeBook(recipes={})))
assert "snack: MISSING from the recipe book" in out
def test_describe_counts_hazards(tmp_path: Path) -> None:
level_yaml = MINI_LEVEL.replace("hazards: []", "") + (
"hazards:\n - {type: spill, x: 4, y: 1}\n - {type: fire, x: 2, y: 1}\n"
)
directory = tmp_path / "haz"
directory.mkdir()
(directory / RECIPES_FILENAME).write_text(MINI_BOOK, encoding="utf-8")
(directory / "01_mini.yaml").write_text(level_yaml, encoding="utf-8")
level = load_level_file(directory / "01_mini.yaml")
lines = describe_level(level, load_recipe_book(directory / RECIPES_FILENAME))
hazards = next(line for line in lines if line.startswith("hazards"))
assert "fire x1" in hazards
assert "spill x1" in hazards
def test_describe_says_none_when_a_level_has_no_stations(tmp_path: Path) -> None:
"""A hand-built level with no stations (content forbids one: no serve window).
`LevelDef` requires exactly one serve tile, so the station-less `Level` can
only come from a hand-built dataclass — which is what a phase-03 caller
holding a partially-built level does.
"""
directory = write_campaign(tmp_path, MINI_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
assert level.stations # the authored level has some; the stripped one has none
lines = describe_level(replace(level, stations=()), RecipeBook(recipes={}))
empty = "stations".ljust(18) + "none"
assert next(line for line in lines if line.startswith("stations")) == empty
def test_reachability_overlay_only_uses_the_three_terrain_characters(tmp_path: Path) -> None:
directory = write_campaign(tmp_path, POCKET_LEVEL, MINI_BOOK)
level = load_level_file(directory / "01_mini.yaml")
overlay = reachability_overlay(level)
assert {cell for row in overlay for cell in row} == {".", "?", "#"}
# the walled-in pocket reads as `??`; everything else the cooks can walk to.
assert overlay[3] == "#.#??#.#"
assert sum(row.count("?") for row in overlay) == 2
# --- entry point ----------------------------------------------------------------
def test_no_subcommand_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
assert run([]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "usage: undercooked-levels" in err
assert "arguments are required: COMMAND" in err
def test_an_unknown_subcommand_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
assert run(["scullery"]) == EXIT_UNUSABLE
err = capsys.readouterr().err
assert "usage: undercooked-levels" in err
assert "invalid choice: 'scullery'" in err
def test_an_unknown_flag_is_a_usage_error(capsys: pytest.CaptureFixture[str]) -> None:
assert run(["validate", "--nope"]) == EXIT_UNUSABLE
assert "unrecognized arguments" in capsys.readouterr().err
@pytest.mark.parametrize("argv", [["--help"], ["validate", "--help"], ["new", "-h"]])
def test_help_exits_cleanly(argv: list[str], capsys: pytest.CaptureFixture[str]) -> None:
assert run(argv) == EXIT_OK
out = capsys.readouterr().out
assert "usage: undercooked-levels" in out
def test_help_documents_the_subcommands_and_exit_codes(
capsys: pytest.CaptureFixture[str],
) -> None:
assert run(["--help"]) == EXIT_OK
out = capsys.readouterr().out
for command in ("validate", "list", "show", "new", "axes"):
assert command in out
assert "0 ok, 1 a level breaks a validation rule" in out
def test_the_parser_builds_with_a_handler_for_every_subcommand() -> None:
parser = levels.build_parser()
for command, expected in (
(["validate"], "validate"),
(["list"], "list"),
(["show", "x"], "show"),
(["new", "x"], "new"),
(["axes"], "axes"),
):
args = parser.parse_args(command)
assert args.command == expected
assert callable(args.handler)
assert args.dir is None or isinstance(args.dir, Path)
def test_console_script_is_declared_and_runs_as_a_module() -> None:
"""The real entry point: `[project.scripts]` -> this module's `main`."""
declared = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["project"]["scripts"]
assert declared["undercooked-levels"] == "undercooked.tools.levels:main"
result = subprocess.run( # fixed argv, the repo's own interpreter
[
sys.executable,
"-m",
"undercooked.tools.levels",
"list",
],
capture_output=True,
text=True,
timeout=120,
check=False,
cwd=REPO_ROOT,
env={**os.environ, "UNDERCOOKED_LEVELS_DIR": str(SHIPPED_LEVELS)},
)
assert result.returncode == EXIT_OK, result.stderr
assert "01_first_kitchen" in result.stdout
assert "02_double_shift" in result.stdout
def test_exit_codes_are_the_documented_ones() -> None:
assert (EXIT_OK, EXIT_VIOLATIONS, EXIT_UNUSABLE) == (0, 1, 2)
+104
View File
@@ -0,0 +1,104 @@
"""docs/LEVELS.md must document exactly the fields the models have.
The doc is the content author's contract; the Pydantic models are its
executable form. This test parses every ``### <Model>`` section's *Field*
table (the first table whose header row starts with ``Field``, so the
HazardSpec placement table is not mistaken for a field list) and requires the
back-quoted field names to equal ``model_fields`` — no undocumented field,
no documented ghost. A planted-drift case proves the extraction can fail.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from pydantic import BaseModel
from undercooked.sim.level import (
HazardSpec,
LevelDef,
OrdersSpec,
StationLimits,
TileSpec,
)
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
pytestmark = pytest.mark.unit
DOC_PATH = Path(__file__).resolve().parents[2] / "docs" / "LEVELS.md"
MODELS: tuple[type[BaseModel], ...] = (
Ingredient,
Step,
Recipe,
RecipeBook,
TileSpec,
OrdersSpec,
HazardSpec,
StationLimits,
LevelDef,
)
def _section(lines: list[str], model_name: str) -> list[str]:
"""Lines of the `### model_name` section, up to the next heading."""
heading = f"### {model_name}"
collected: list[str] = []
in_section = False
for line in lines:
if line.startswith("#"):
if in_section:
break
in_section = line.strip() == heading
continue
if in_section:
collected.append(line)
return collected
def _table_fields(section: list[str]) -> set[str]:
"""Back-quoted first-column entries of the section's `Field` table."""
start = None
for index, line in enumerate(section):
stripped = line.strip()
if stripped.startswith("|") and stripped.strip("|").split("|")[0].strip() == "Field":
start = index
break
if start is None:
return set()
fields: set[str] = set()
for line in section[start + 2 :]: # skip header + |---| separator
stripped = line.strip()
if not stripped.startswith("|"):
break
first = stripped.strip("|").split("|")[0].strip()
if len(first) > 2 and first.startswith("`") and first.endswith("`"):
fields.add(first[1:-1])
return fields
@pytest.fixture(scope="module")
def doc_lines() -> list[str]:
assert DOC_PATH.is_file(), f"{DOC_PATH} is missing"
return DOC_PATH.read_text(encoding="utf-8").splitlines()
@pytest.mark.parametrize("model", MODELS, ids=lambda model: model.__name__)
def test_every_model_has_an_exact_field_table(doc_lines: list[str], model: type[BaseModel]) -> None:
documented = _table_fields(_section(doc_lines, model.__name__))
assert documented, f"docs/LEVELS.md has no Field table for {model.__name__}"
assert documented == set(model.model_fields), (
f"docs/LEVELS.md vs {model.__name__}: "
f"undocumented={sorted(set(model.model_fields) - documented)}, "
f"not-in-model={sorted(documented - set(model.model_fields))}"
)
def test_the_comparison_catches_planted_drift(doc_lines: list[str]) -> None:
planted = [
line.replace("| `penalty` |", "| `reward` |") for line in doc_lines
]
documented = _table_fields(_section(planted, "OrdersSpec"))
assert "reward" in documented
assert documented != set(OrdersSpec.model_fields)
+34
View File
@@ -37,6 +37,7 @@ from undercooked.config import (
get_settings, get_settings,
reset_settings_cache, reset_settings_cache,
) )
from undercooked.sim.recipe import ContentError
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator from collections.abc import Iterator
@@ -214,6 +215,39 @@ def test_main_reports_an_invalid_environment(
assert "UNDERCOOKED_TICK_HZ=99" in capsys.readouterr().err assert "UNDERCOOKED_TICK_HZ=99" in capsys.readouterr().err
def test_main_exits_two_when_the_content_gate_refuses_startup(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""Unusable levels are a startup error, not a traceback.
Content is read when uvicorn imports the app string, so `create_app`'s
`ContentError` surfaces out of `uvicorn.run` — and the CLI answers with exit 2,
the same code an unusable `UNDERCOOKED_PORT` gets. The named-rule report itself
is written by `create_app` before it raises, so this only adds the closing line.
"""
def refusing_run(_target: str, **_kwargs: Any) -> None:
raise ContentError("undercooked: level content failed validation:\nBOGUS_RULE: nope")
monkeypatch.setattr(uvicorn, "run", refusing_run)
assert main([]) == EXIT_CONFIG_ERROR
err = capsys.readouterr().err
assert "refusing to start" in err
assert "undercooked-levels validate" in err
assert "Traceback" not in err
def test_main_lets_any_other_serving_failure_propagate(monkeypatch: pytest.MonkeyPatch) -> None:
"""Only the content gate is translated: a real crash stays a real crash."""
def crashing_run(_target: str, **_kwargs: Any) -> None:
raise OSError("port in use")
monkeypatch.setattr(uvicorn, "run", crashing_run)
with pytest.raises(OSError, match="port in use"):
main([])
def test_unknown_flag_is_an_argument_error() -> None: def test_unknown_flag_is_an_argument_error() -> None:
with pytest.raises(SystemExit) as excinfo: with pytest.raises(SystemExit) as excinfo:
main(["--nope"]) main(["--nope"])
+311
View File
@@ -0,0 +1,311 @@
"""Unit tests for `undercooked.sim.recipe` (phase 02 task 02).
The recipe book is the content contract every later phase cooks against: strict
schemas (typos are load errors, not defaults), deterministic helpers, and a
`min_seconds_to_complete` solver bound the validator trusts. Every failing case
here is a YAML an author could plausibly write.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from pydantic import ValidationError
from undercooked.sim.model import ItemForm, ItemKind, StationKind
from undercooked.sim.recipe import (
HANDLING_S,
ContentError,
Ingredient,
Recipe,
RecipeBook,
Step,
describe_validation_error,
load_recipe_book,
read_yaml_mapping,
)
pytestmark = pytest.mark.unit
def soup_dict(**overrides: Any) -> dict[str, Any]:
"""A minimal valid soup recipe body; overrides replace top-level fields."""
body: dict[str, Any] = {
"id": "soup",
"name": "Tomato Soup",
"base_score": 40,
"ingredients": [
{"kind": "tomato", "form": "raw"},
{"kind": "onion", "form": "raw", "count": 2},
],
"steps": [
{"kind": "chop", "station": "chop_board", "seconds": 0.9},
{"kind": "cook", "station": "stove", "seconds": 5.0},
],
}
body.update(overrides)
return body
def book_dict(**overrides: Any) -> dict[str, Any]:
data: dict[str, Any] = {"recipes": {"soup": soup_dict()}}
data.update(overrides)
return data
def write_book(directory: Path, text: str) -> Path:
path = directory / "recipes.yaml"
path.write_text(text, encoding="utf-8")
return path
# --------------------------------------------------------------------- Ingredient / Step
def test_handling_constant_is_the_documented_bound() -> None:
assert HANDLING_S == 0.6
def test_ingredient_defaults_count_to_one() -> None:
ingredient = Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW)
assert ingredient.count == 1
def test_ingredient_count_must_be_positive() -> None:
with pytest.raises(ValidationError, match="count"):
Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW, count=0)
def test_ingredient_kinds_are_the_enum_not_arbitrary_strings() -> None:
with pytest.raises(ValidationError):
Ingredient.model_validate({"kind": "durian", "form": "raw"})
def test_step_defaults_to_zero_seconds() -> None:
step = Step(kind="pick", station=StationKind.SPAWN_SHELF)
assert step.seconds == 0.0
def test_step_kind_is_a_closed_vocabulary() -> None:
with pytest.raises(ValidationError, match="kind"):
Step.model_validate({"kind": "throw", "station": "chop_board"})
def test_step_seconds_cannot_be_negative() -> None:
with pytest.raises(ValidationError, match="seconds"):
Step(kind="chop", station=StationKind.CHOP_BOARD, seconds=-0.1)
def test_step_station_must_be_a_known_station_kind() -> None:
with pytest.raises(ValidationError, match="station"):
Step.model_validate({"kind": "cook", "station": "deep_fryer"})
# --------------------------------------------------------------------------- Recipe
def test_recipe_happy_path_carries_documented_defaults() -> None:
recipe = Recipe.model_validate(soup_dict())
assert recipe.requires_plating is True
assert recipe.tags == []
assert [ingredient.count for ingredient in recipe.ingredients] == [1, 2]
def test_recipe_rejects_unknown_keys() -> None:
with pytest.raises(ValidationError, match=r"extra_forbidden|Extra inputs"):
Recipe.model_validate(soup_dict(garnish="parsley"))
def test_recipe_requires_ingredients_and_steps() -> None:
with pytest.raises(ValidationError, match="ingredients"):
Recipe.model_validate(soup_dict(ingredients=[]))
with pytest.raises(ValidationError, match="steps"):
Recipe.model_validate(soup_dict(steps=[]))
def test_recipe_score_and_ids_are_sane() -> None:
with pytest.raises(ValidationError):
Recipe.model_validate(soup_dict(base_score=-1))
with pytest.raises(ValidationError):
Recipe.model_validate(soup_dict(id=""))
# ------------------------------------------------------------------------ RecipeBook
def test_book_reads_keys_ids_and_the_ingredient_catalog() -> None:
book = RecipeBook.model_validate(
book_dict(ingredients=["tomato", "onion", "plate"])
)
assert book.ids() == ("soup",)
assert book.get("soup").base_score == 40
assert book.ingredients == [ItemKind.TOMATO, ItemKind.ONION, ItemKind.PLATE]
def test_book_ids_follow_declaration_order() -> None:
book = RecipeBook.model_validate(
book_dict(recipes={"salad": soup_dict(id="salad"), "soup": soup_dict()})
)
assert book.ids() == ("salad", "soup")
def test_book_key_must_equal_the_recipe_id() -> None:
with pytest.raises(ValidationError, match="keys must equal"):
RecipeBook.model_validate(book_dict(recipes={"soup": soup_dict(id="chowder")}))
def test_get_unknown_recipe_names_the_known_ones() -> None:
book = RecipeBook.model_validate(book_dict())
with pytest.raises(KeyError, match="unknown recipe 'ghost'"):
book.get("ghost")
def test_required_station_kinds_for_a_two_step_recipe() -> None:
book = RecipeBook.model_validate(book_dict())
assert book.required_station_kinds("soup") == frozenset(
{StationKind.CHOP_BOARD, StationKind.STOVE}
)
def four_step_book() -> RecipeBook:
recipe = soup_dict(
steps=[
{"kind": "chop", "station": "chop_board", "seconds": 0.9},
{"kind": "cook", "station": "stove", "seconds": 4.0},
{"kind": "plate", "station": "prep", "seconds": 1.2},
{"kind": "serve", "station": "serve"},
]
)
return RecipeBook.model_validate(book_dict(recipes={"soup": recipe}))
def test_required_station_kinds_for_a_four_step_recipe() -> None:
assert four_step_book().required_station_kinds("soup") == frozenset(
{StationKind.CHOP_BOARD, StationKind.STOVE, StationKind.PREP, StationKind.SERVE}
)
def test_required_item_kinds_adds_a_plate_only_when_plating_is_required() -> None:
book = RecipeBook.model_validate(book_dict())
assert book.required_item_kinds("soup") == frozenset(
{ItemKind.TOMATO, ItemKind.ONION, ItemKind.PLATE}
)
unplated = RecipeBook.model_validate(
book_dict(recipes={"soup": soup_dict(requires_plating=False)})
)
assert unplated.required_item_kinds("soup") == frozenset({ItemKind.TOMATO, ItemKind.ONION})
def test_min_seconds_to_complete_is_steps_plus_one_handling_allowance() -> None:
book = RecipeBook.model_validate(book_dict())
assert book.min_seconds_to_complete("soup") == pytest.approx(0.9 + 5.0 + HANDLING_S)
def test_min_seconds_grows_with_more_steps() -> None:
minutes = four_step_book().min_seconds_to_complete("soup")
assert minutes == pytest.approx(0.9 + 4.0 + 1.2 + 0.0 + HANDLING_S)
@pytest.mark.parametrize(
"method",
["required_station_kinds", "required_item_kinds", "min_seconds_to_complete"],
)
def test_solver_helpers_reject_unknown_recipes(method: str) -> None:
book = RecipeBook.model_validate(book_dict())
with pytest.raises(KeyError, match="unknown recipe"):
getattr(book, method)("ghost")
# ------------------------------------------------------------------- load_recipe_book
def test_load_recipe_book_reads_a_real_yaml_file(tmp_path: Path) -> None:
path = write_book(
tmp_path,
"""
ingredients: [tomato, onion, plate]
recipes:
soup:
id: soup
name: Tomato Soup
base_score: 40
ingredients: [{kind: tomato, form: raw}, {kind: onion, form: raw, count: 2}]
steps:
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5}
""",
)
book = load_recipe_book(path)
assert book.ids() == ("soup",)
assert book.ingredients == [ItemKind.TOMATO, ItemKind.ONION, ItemKind.PLATE]
def test_load_recipe_book_reports_every_field_problem(tmp_path: Path) -> None:
path = write_book(
tmp_path,
"""
recipes:
soup:
id: soup
name: Soup
base_score: not-a-number
ingredients: [{kind: durian, form: raw}]
steps: [{kind: throw, station: chop_board}]
""",
)
with pytest.raises(ContentError) as excinfo:
load_recipe_book(path)
message = str(excinfo.value)
assert "base_score" in message
assert "ingredients.0.kind" in message
assert "steps.0.kind" in message
def test_load_recipe_book_rejects_unknown_top_level_keys(tmp_path: Path) -> None:
path = write_book(tmp_path, "recipes: {}\ndesserts: {}\n")
with pytest.raises(ContentError, match="desserts"):
load_recipe_book(path)
def test_load_recipe_book_missing_file_is_a_content_error(tmp_path: Path) -> None:
with pytest.raises(ContentError, match="cannot read content file"):
load_recipe_book(tmp_path / "nope.yaml")
def test_load_recipe_book_invalid_yaml_is_a_content_error(tmp_path: Path) -> None:
path = write_book(tmp_path, "recipes: [unclosed\n")
with pytest.raises(ContentError, match="invalid YAML"):
load_recipe_book(path)
def test_load_recipe_book_rejects_a_list_document(tmp_path: Path) -> None:
path = write_book(tmp_path, "- just\n- a\n- list\n")
with pytest.raises(ContentError, match="expected a YAML mapping"):
load_recipe_book(path)
def test_empty_file_reads_as_an_empty_mapping_then_fails_the_schema(tmp_path: Path) -> None:
path = write_book(tmp_path, "")
assert read_yaml_mapping(path) == {}
with pytest.raises(ContentError, match="recipes: Field required"):
load_recipe_book(path)
# ----------------------------------------------------------------- strictness plumbing
def test_assignment_revalidates_so_late_edits_cannot_corrupt_a_model() -> None:
ingredient = Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW)
ingredient.count = 3
assert ingredient.count == 3
with pytest.raises(ValidationError):
ingredient.count = 0
def test_describe_validation_error_flattens_loc_paths(tmp_path: Path) -> None:
with pytest.raises(ValidationError) as excinfo:
RecipeBook.model_validate({})
described = describe_validation_error(excinfo.value, "book.yaml")
assert described.startswith("book.yaml: recipes: Field required")
+215
View File
@@ -0,0 +1,215 @@
"""Regression tests for the shipped campaign content (``levels/``, task 04).
These pin what campaign 02 ships, exactly as the phase task specifies: the
recipe book (ingredients + four named dishes with PLAN §5 step pipelines and
fixed base scores), the first two levels' tunables (pool, concurrency, spawn
interval, deadline, time limit, target), the blurb floor, campaign ordering,
and the level-1 → level-2 escalation contract (≥2 axes up). They read the
real files from ``levels/`` — not a copy — so editing shipped content that
a test pins fails here, in review, instead of at a player's startup.
Startup behaviour and the HTTP surface live in ``test_content_startup.py``
and ``tests/integration/test_content_api.py``; the generic loader and
validator have their own unit suites.
"""
from __future__ import annotations
from pathlib import Path
import pytest
from undercooked.sim.content import ContentBundle, load_content
from undercooked.sim.level import Level
from undercooked.sim.recipe import RecipeBook, load_recipe_book
from undercooked.sim.validation import escalation_axes, validate_bundle
REPO_ROOT = Path(__file__).resolve().parents[2]
LEVELS_DIR = REPO_ROOT / "levels"
RECIPES_PATH = LEVELS_DIR / "recipes.yaml"
LEVEL_FILES = sorted(
path for path in LEVELS_DIR.glob("*.yaml") if path.name != "recipes.yaml"
)
#: The campaign order this task ships (PLAN §8: soup tutorial, then salad).
SHIPPED_IDS = ["01_first_kitchen", "02_double_shift"]
#: PLAN §5 station pipelines as (interaction, station) pairs:
#: pick at the shelf -> chop at the board -> cook on the stove -> plate at
#: prep -> serve at the window; a dish skips the stages its recipe lacks.
EXPECTED_STEPS = {
"soup": [
("pick", "spawn_shelf"),
("chop", "chop_board"),
("cook", "stove"),
("plate", "prep"),
("serve", "serve"),
],
"salad": [
("pick", "spawn_shelf"),
("chop", "chop_board"),
("plate", "prep"),
("serve", "serve"),
],
"fried_rice": [
("pick", "spawn_shelf"),
("cook", "stove"),
("plate", "prep"),
("serve", "serve"),
],
"burger": [
("pick", "spawn_shelf"),
("chop", "chop_board"),
("cook", "stove"),
("plate", "prep"),
("serve", "serve"),
],
}
@pytest.fixture(scope="module")
def book() -> RecipeBook:
return load_recipe_book(RECIPES_PATH)
@pytest.fixture(scope="module")
def bundle() -> ContentBundle:
return load_content(LEVELS_DIR, RECIPES_PATH)
def level_of(bundle: ContentBundle, level_id: str) -> Level:
return bundle.by_id[level_id]
# --- the recipe book ---------------------------------------------------------
def test_recipe_book_ships_the_named_ingredient_catalog(book: RecipeBook) -> None:
assert set(book.ingredients) == {
"tomato",
"onion",
"lettuce",
"dough",
"patty",
"rice",
"fish",
"plate",
}
def test_recipe_book_ships_exactly_the_four_dishes(book: RecipeBook) -> None:
assert set(book.recipes) == {"soup", "salad", "fried_rice", "burger"}
@pytest.mark.parametrize(
("recipe_id", "base_score"),
[("soup", 40), ("salad", 30), ("fried_rice", 50), ("burger", 70)],
)
def test_dishes_carry_their_pinned_base_scores(
book: RecipeBook, recipe_id: str, base_score: int
) -> None:
assert book.get(recipe_id).base_score == base_score
@pytest.mark.parametrize("recipe_id", sorted(EXPECTED_STEPS))
def test_dishes_follow_the_plan_station_pipelines(
book: RecipeBook, recipe_id: str
) -> None:
recipe = book.get(recipe_id)
assert [(step.kind, step.station.value) for step in recipe.steps] == (
EXPECTED_STEPS[recipe_id]
)
@pytest.mark.parametrize("recipe_id", sorted(EXPECTED_STEPS))
def test_every_dish_consumes_catalogued_ingredients(
book: RecipeBook, recipe_id: str
) -> None:
recipe = book.get(recipe_id)
assert recipe.ingredients, recipe_id
assert {item.kind for item in recipe.ingredients} <= set(book.ingredients)
# --- the level files ----------------------------------------------------------
def test_every_yaml_file_in_levels_is_a_shipped_level(bundle: ContentBundle) -> None:
stems = {path.stem for path in LEVEL_FILES}
assert stems, "levels/ must ship at least one level"
assert stems == set(bundle.by_id)
def test_shipped_campaign_validates_without_warnings(bundle: ContentBundle) -> None:
report = validate_bundle(bundle)
assert report.ok, report.render()
assert not report.warnings, report.render()
def test_campaign_order_is_unique_and_starts_at_one(bundle: ContentBundle) -> None:
orders = [level.order for level in bundle.levels]
assert orders == list(range(1, len(bundle.levels) + 1))
def test_campaign_ships_the_first_two_levels(bundle: ContentBundle) -> None:
assert [level.id for level in bundle.levels] == SHIPPED_IDS
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_each_level_has_a_readable_premise(bundle: ContentBundle, level_id: str) -> None:
blurb = level_of(bundle, level_id).blurb
assert len(blurb) >= 20
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_difficulty_is_within_the_declared_range(
bundle: ContentBundle, level_id: str
) -> None:
assert 1 <= level_of(bundle, level_id).difficulty <= 10
@pytest.mark.parametrize("level_id", SHIPPED_IDS)
def test_pool_recipes_exist_in_the_book(
bundle: ContentBundle, book: RecipeBook, level_id: str
) -> None:
recipe_ids = level_of(bundle, level_id).orders.pool
assert recipe_ids
for recipe_id in recipe_ids:
assert recipe_id in book.recipes
# --- the two levels' pinned tunables (task 04) --------------------------------
def test_level_one_is_the_soup_tutorial(bundle: ContentBundle) -> None:
level = level_of(bundle, "01_first_kitchen")
assert level.order == 1
assert level.difficulty == 1
assert level.time_limit == 150
assert level.target_score == 45
assert level.orders.pool == ["soup"]
assert level.orders.concurrent == 1
assert tuple(level.orders.spawn_interval) == (16, 20)
assert level.orders.deadline == 60
assert level.hazards == []
def test_level_two_adds_salad_and_pressure(bundle: ContentBundle) -> None:
level = level_of(bundle, "02_double_shift")
assert level.order == 2
assert level.difficulty == 2
assert level.time_limit == 130
assert level.target_score == 80
assert level.orders.pool == ["soup", "salad"]
assert level.orders.concurrent == 2
assert tuple(level.orders.spawn_interval) == (12, 16)
assert level.orders.deadline == 52
assert level.hazards == []
def test_level_two_escalates_at_least_two_axes(bundle: ContentBundle) -> None:
before = escalation_axes(level_of(bundle, "01_first_kitchen"))
after = escalation_axes(level_of(bundle, "02_double_shift"))
moved_up = sorted(
name for name, value in after.items() if value > before[name] + 1e-9
)
assert len(moved_up) >= 2, f"only these axes moved: {moved_up}"