diff --git a/.agents/phases/todo/02_level_content_and_validation/00_phase.md b/.agents/phases/complete/02_level_content_and_validation/00_phase.md similarity index 100% rename from .agents/phases/todo/02_level_content_and_validation/00_phase.md rename to .agents/phases/complete/02_level_content_and_validation/00_phase.md diff --git a/.agents/phases/todo/02_level_content_and_validation/01_sim_core_data_model.md b/.agents/phases/complete/02_level_content_and_validation/01_sim_core_data_model.md similarity index 100% rename from .agents/phases/todo/02_level_content_and_validation/01_sim_core_data_model.md rename to .agents/phases/complete/02_level_content_and_validation/01_sim_core_data_model.md diff --git a/.agents/phases/todo/02_level_content_and_validation/02_recipe_and_level_schema.md b/.agents/phases/complete/02_level_content_and_validation/02_recipe_and_level_schema.md similarity index 100% rename from .agents/phases/todo/02_level_content_and_validation/02_recipe_and_level_schema.md rename to .agents/phases/complete/02_level_content_and_validation/02_recipe_and_level_schema.md diff --git a/.agents/phases/todo/02_level_content_and_validation/03_level_validation_rules.md b/.agents/phases/complete/02_level_content_and_validation/03_level_validation_rules.md similarity index 100% rename from .agents/phases/todo/02_level_content_and_validation/03_level_validation_rules.md rename to .agents/phases/complete/02_level_content_and_validation/03_level_validation_rules.md diff --git a/.agents/phases/todo/02_level_content_and_validation/04_seed_recipes_and_two_levels.md b/.agents/phases/complete/02_level_content_and_validation/04_seed_recipes_and_two_levels.md similarity index 100% rename from .agents/phases/todo/02_level_content_and_validation/04_seed_recipes_and_two_levels.md rename to .agents/phases/complete/02_level_content_and_validation/04_seed_recipes_and_two_levels.md diff --git a/.agents/phases/todo/02_level_content_and_validation/05_levels_cli_and_manifest_test.md b/.agents/phases/complete/02_level_content_and_validation/05_levels_cli_and_manifest_test.md similarity index 100% rename from .agents/phases/todo/02_level_content_and_validation/05_levels_cli_and_manifest_test.md rename to .agents/phases/complete/02_level_content_and_validation/05_levels_cli_and_manifest_test.md diff --git a/AGENTS.md b/AGENTS.md index 53ea952..0e0e9cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,9 +16,10 @@ - Execution is owned by the `phased-execution` skill (`run-task.sh`, `run-phase.sh`, `auto-phase.sh`). Each task runs in a fresh subprocess and must pass `.agents/validate.sh` before it moves to `complete/`. That gate fails fast in this - order: `ruff check .` → `pyright` → `undercooked-levels validate` (skipped with a - notice until phase 02 creates the loader) → the **full** `pytest` suite with the - coverage floor → repo-hygiene checks. It runs from the repo root or from `.agents/`. + order: `ruff check .` → `pyright` → `undercooked-levels validate` (unconditional: + the same loader + PLAN §8 rules `create_app` runs at startup, over + `$UNDERCOOKED_LEVELS_DIR`) → the **full** `pytest` suite with the coverage floor → + repo-hygiene checks. It runs from the repo root or from `.agents/`. - New work is authored by the `phase-authoring` skill as a phase directory — not implemented straight from chat. - **Never** modify `.agents/PLAN.md` or anything under `.agents/phases/complete/`. @@ -37,6 +38,17 @@ deviation from the default FastAPI stack. - **Data-driven content.** Levels/recipes/hazards come from YAML in `levels/` loaded through validated Pydantic models. No hard-coded level or recipe lists in Python. + `create_app()` loads the bundle once (`routes_content.load_validated_content`) and + **refuses to start** when content fails to load or violates a PLAN §8 rule — the + named-rule report goes to stderr, `ContentError` propagates and the `undercooked` + command exits `2` like it does for an impossible setting. The content API + projects through `extra="forbid"` response models: grids, station ids and spawn + coordinates never leave the server. `undercooked-levels` (`tools/levels.py`, its + author CLI) obeys the same rule: it prints whatever the loaded directory holds. + `tests/unit/test_content_not_hardcoded.py` is the lock — it adds a level YAML to a + copy of `levels/` and expects the loader, `/api/levels` and the CLI to carry it with + no Python edited, and fails if any `src/undercooked/**.py` or browser asset names a + shipped level or recipe id as a literal. - **Client: Canvas 2D + vanilla ES modules + this repo's own SVG art.** No bundler, no framework, no CDN, no analytics, no external fonts. - **Tests: pytest + Playwright.** One Playwright suite per user story; multi-player @@ -52,7 +64,8 @@ - [ ] **Full** project suite passes (`uv run pytest`), coverage gate met — including failures the task did not cause: fix them, do not skip, xfail, or delete tests. - [ ] `uv run ruff check .` and `uv run pyright` report zero errors. -- [ ] `uv run undercooked-levels validate` passes 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 (setup, commands, config, protocol, features, conventions). Every phase's final 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 `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md`, and the client in the same task, and bumping `PROTOCOL_VERSION` (PLAN §11). -- **Determinism is a contract.** `sim/step.py` must produce identical state given the - same seed and input script — the replay tests in `tests/sim/` are the guard. +- **Determinism is a contract.** `sim/step.py` (phase 03) must produce identical state + given the same seed and input script — the replay tests in `tests/sim/` are the guard + once the tick exists; until then `tests/sim/test_purity.py` holds the A5 boundary that + determinism depends on. - **ntfy is outbound-only.** Never use it to ask questions or wait for approval. If a task is ambiguous, make the best judgment consistent with PLAN.md, or stop and let the user rewrite the task. @@ -134,9 +149,12 @@ | Configuration (env vars → typed `Settings`), debugpy bootstrap | `src/undercooked/config.py`, `.env.example`, `src/undercooked/debug.py` | | Pure game rules (movement, stations, orders, scoring, hazards) | `src/undercooked/sim/` | | Level/recipe content | `levels/*.yaml` | +| Content schema, ASCII-grid parser, content loader, playability validator (contract: `docs/LEVELS.md`) | `src/undercooked/sim/recipe.py`, `level.py`, `content.py`, `validation.py` | | Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py`, `room.py` | | Wire messages + validation | `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md` | -| HTTP app (`/`, `/healthz`, `/static`), lifespan | `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` | | CLI entry point (`undercooked`): flags → `--help` → serve | `src/undercooked/__main__.py` | | 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/integration/` (in-process app boot, live uvicorn + WebSocket clients), `tests/sim/` -(headless determinism and replays), `tests/e2e/` (Playwright, named `test_usNN_*.py`, -marked `e2e`). `tests/` is not a package; shared fixtures live in `tests/conftest.py`. +(the pure kernel without a server: grid geometry, entity model, the A5 purity guard, +the level validator — headless determinism and replays join it with the tick in +phase 03), `tests/e2e/` (Playwright, named `test_usNN_*.py`, marked `e2e`). `tests/` is +not a package; shared fixtures live in `tests/conftest.py`, and the autouse fixture there +points `UNDERCOOKED_LEVELS_DIR` at `tests/fixtures/levels/` so no test silently reads the +shipped `levels/`. - The browser suites use the **`playwright` sync API directly with fixtures written in `tests/e2e/conftest.py`** — `pytest-playwright` is deliberately not a dependency, so diff --git a/README.md b/README.md index eac9cdb..6836869 100644 --- a/README.md +++ b/README.md @@ -10,21 +10,34 @@ email, no password: you create a room, share the 5-character code, play. ## Status -Phase 01 (runnable service scaffold) is **complete**; the next pending phase is -`02_level_content_and_validation`. What runs today: the Python toolchain is locked -(`uv.lock`), the pytest harness covers unit, integration and Playwright e2e layers, -configuration is a single typed, validated `Settings` object (`DEBUGPY` support -included), and `uv run undercooked` starts a FastAPI server that serves the client -shell at `/`, the browser assets from `app/static/`, and `GET /healthz` — which answers -`{"status":"ok","version":"0.1.0","rooms":0}` (`rooms` stays `0` until phase 05 owns a -supervisor) — with the security headers (CSP `default-src 'self'`) on every response. -The rules are tests and not promises: the no-external-asset rule is -`tests/unit/test_no_external_assets.py`, the documentation set is guarded by -`tests/unit/test_docs_index.py`, `bash .agents/validate.sh` is the gate the phase -pipeline runs after every task, and `docs/` holds the DESIGN / TESTING / DEPLOYMENT -documents. Lobby buttons answer "Not implemented until phase 05.": levels and their -validator land in phase 02, the simulation in 03-04, rooms and the WebSocket protocol -in 05-06, the playable renderer in 07. Execute phases with the `phased-execution` skill +Phases **01** (runnable service scaffold) and **02** (level content, schema and +validation) are **complete**; the next pending phase is +`03_simulation_movement_and_interaction`. What runs today: + +- **The service.** The Python toolchain is locked (`uv.lock`), configuration is a + single typed, validated `Settings` object (`DEBUGPY` support included), and + `uv run undercooked` starts a FastAPI server that serves the client shell at `/`, + the browser assets from `app/static/`, and `GET /healthz` — which answers + `{"status":"ok","version":"0.1.0","rooms":0}` (`rooms` stays `0` until phase 05 owns + a supervisor) — with the security headers (CSP `default-src 'self'`) on every + response. +- **The content.** The recipe book and the first two campaign levels live in + `levels/`; `sim/level.py` parses them into typed models, `sim/validation.py` runs + PLAN §8's named rules, and `create_app` loads + validates that bundle at startup and + **refuses to boot** broken content — the named-rule report goes to stderr and + `undercooked` exits `2` instead of serving a half-working kitchen. `GET /api/levels`, + `GET /api/levels/{id}` and `GET /api/config` answer from it, and + `undercooked-levels` gives authors the same gate from the command line + (`docs/LEVELS.md` is the schema reference). +- **The rules are tests, not promises.** The no-external-asset rule is + `tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is + `tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by + `tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the + phase pipeline runs after every task. + +What does not exist yet: the simulation (phases 03-04), rooms and the WebSocket +protocol (05-06 — the lobby buttons answer "Not implemented until phase 05.") and the +playable Canvas renderer (07). Execute phases with the `phased-execution` skill (`auto-phase.sh`); it always takes the lowest-numbered directory still in `.agents/phases/todo/`. @@ -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 --reload # dev reload uv run undercooked --levels-dir levels # point at another content dir +curl -fsS http://127.0.0.1:8000/api/levels # the campaign cards the lobby will show +curl -fsS http://127.0.0.1:8000/api/config # client-safe settings + protocol version ``` `python -m undercooked` is the same command (the container uses it). A flag always beats the environment, which beats the built-in default. There is no `/docs` or `/openapi.json`: FastAPI renders those pages from a public CDN, which -this project forbids. `undercooked-levels validate` (check every level file loads -and is solvable) is declared in `pyproject.toml` but becomes runnable in phase 02, -when the content loader lands. +this project forbids. The validation the server runs at startup (every level +file loads, and no rule of PLAN §8 is broken) is a command too — +`undercooked-levels`, see **Authoring content** below. Debug the server with a real debugger attached (dev only - the listener binds `0.0.0.0`, so never enable it on a public host): @@ -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 ``` +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` > `/recipes.yaml`) +and never from the current directory, so it works from any path. Exit codes: +`0` ok, `1` a level breaks a rule, `2` nothing usable to work with (load +failure, unknown id, refused overwrite, usage error). The schema, every field, +the overlay trick and a "which rule did I just hit" table live in +[docs/LEVELS.md](docs/LEVELS.md); `tests/unit/test_content_not_hardcoded.py` +is what keeps levels and recipes out of the Python. + Run the checks: ```bash @@ -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 `.agents/phases/complete/`. It stops at the first failure, in this order: -`ruff check .`, `pyright`, `undercooked-levels validate` (skipped with a notice until -phase 02 creates the loader), the **full** `pytest` suite with the 90 % coverage floor, +`ruff check .`, `pyright`, `undercooked-levels validate` (unconditional: the same +loader and PLAN §8 rules `create_app` runs at startup, so a campaign that cannot +validate cannot commit), the **full** `pytest` suite with the 90 % coverage floor, then repo-hygiene checks (nothing tracked that `.gitignore` excludes, no log drops or key material committed). Run it from the repo root or from `.agents/`. @@ -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 current directory upwards; never commit it), then the built-in defaults. A blank value counts as unset. Invalid values (`UNDERCOOKED_TICK_HZ=99`) raise `ConfigError` at startup -instead of half-configuring the server. `.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`. | 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_ROOMS` | `200` | Concurrent rooms before new rooms are refused | | `UNDERCOOKED_LEVELS_DIR` | `levels` | Directory of level YAML files (`~` is expanded) | +| `UNDERCOOKED_RECIPES_PATH` | *(derived)* | Recipe book file; empty = `recipes.yaml` inside `UNDERCOOKED_LEVELS_DIR` (here: `levels/recipes.yaml`) | | `UNDERCOOKED_STATIC_DIR` | *(derived)* | Directory served at `/static`; empty = `app/static/` beside the source (containers set it) | | `UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S` | `900` | Close empty/idle lobbies | | `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` | `45` | Drop a disconnected player's seat after this | @@ -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/config.py typed env settings (the only `os.environ` reader) 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 .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 and this repo's SVG art arrive with phase 07 -tests/ unit/, integration/, sim/, e2e/ (one Playwright suite per story); - fixtures/levels/ (test-isolated content dir); conftest.py -docs/ DESIGN.md, TESTING.md (how this project is verified), - DEPLOYMENT.md; PROTOCOL / LEVELS / SIMULATION / CLIENT land with - their own phases (the index is PLAN.md §13) +tests/ unit/, integration/, e2e/ (one Playwright suite per story) and + sim/ (the pure kernel: grid, entities, purity guard, validation); + fixtures/levels/ is the content dir tests see (a recipe book and no + levels; `_bad/` holds the deliberately-broken campaign the `_` + discovery rule ignores); conftest.py +docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract), + TESTING.md (how this project is verified), DEPLOYMENT.md; + PROTOCOL / SIMULATION / CLIENT land with their own phases + (the index is PLAN.md §13) .agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan ``` diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 9809439..374d8a7 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -37,6 +37,11 @@ Rules that hold for every deployment of this project: 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 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 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index eb930f5..e19eb23 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -1,6 +1,6 @@ # 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. ## 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) — 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 | |---|---|---| | `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/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/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/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 | diff --git a/docs/LEVELS.md b/docs/LEVELS.md new file mode 100644 index 0000000..58759d5 --- /dev/null +++ b/docs/LEVELS.md @@ -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 :`). It reads like: + +```text +undercooked-levels: /…/levels/02_ghost.yaml: orders.pool names unknown recipes: ghost +/…/levels/03_wreck.yaml: : 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 `/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 `/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 | diff --git a/docs/TESTING.md b/docs/TESTING.md index 8629fad..1ecf4fe 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -1,6 +1,6 @@ # 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. ## Purpose @@ -20,7 +20,7 @@ layer live in `tests/conftest.py`, and layer-specific fixtures live in that laye | 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/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/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 | @@ -59,6 +59,16 @@ uv run pyright # types 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 on, `--cov-fail-under=90` (AGENTS.md A20 asks for > 90 % on new/changed code; the floor is the mechanical half of that). diff --git a/levels/01_first_kitchen.yaml b/levels/01_first_kitchen.yaml new file mode 100644 index 0000000..4a0e140 --- /dev/null +++ b/levels/01_first_kitchen.yaml @@ -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. diff --git a/levels/02_double_shift.yaml b/levels/02_double_shift.yaml new file mode 100644 index 0000000..f38bf8b --- /dev/null +++ b/levels/02_double_shift.yaml @@ -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. diff --git a/levels/recipes.yaml b/levels/recipes.yaml new file mode 100644 index 0000000..2040a28 --- /dev/null +++ b/levels/recipes.yaml @@ -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] diff --git a/src/undercooked/__main__.py b/src/undercooked/__main__.py index c7fb4c1..77c4e35 100644 --- a/src/undercooked/__main__.py +++ b/src/undercooked/__main__.py @@ -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`. 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 @@ -21,6 +22,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any from undercooked.config import ConfigError, Settings, apply_cli_overrides +from undercooked.sim.recipe import ContentError if TYPE_CHECKING: 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) return EXIT_CONFIG_ERROR - _serve(options) + try: + _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 diff --git a/src/undercooked/config.py b/src/undercooked/config.py index 2d261fe..7b27591 100644 --- a/src/undercooked/config.py +++ b/src/undercooked/config.py @@ -57,6 +57,7 @@ ENV_KEYS: Mapping[str, str] = { "max_players": f"{ENV_PREFIX}MAX_PLAYERS", "max_rooms": f"{ENV_PREFIX}MAX_ROOMS", "levels_dir": f"{ENV_PREFIX}LEVELS_DIR", + "recipes_path": f"{ENV_PREFIX}RECIPES_PATH", "static_dir": f"{ENV_PREFIX}STATIC_DIR", "lobby_idle_timeout_s": f"{ENV_PREFIX}LOBBY_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_ROOMS = 200 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 # `server.app.STATIC_DIR`); containers set an explicit path instead. DEFAULT_STATIC_DIR: Path | None = None @@ -119,6 +123,8 @@ class Settings: max_players: seats per room (PLAN 2: 1-4). max_rooms: process-wide room cap before new rooms are refused. 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 package layout (``/app/static``), which is wrong inside a container image, so those set it explicitly. @@ -140,6 +146,7 @@ class Settings: max_players: int = DEFAULT_MAX_PLAYERS max_rooms: int = DEFAULT_MAX_ROOMS levels_dir: Path = DEFAULT_LEVELS_DIR + recipes_path: Path | None = DEFAULT_RECIPES_PATH static_dir: Path | None = DEFAULT_STATIC_DIR lobby_idle_timeout_s: float = DEFAULT_LOBBY_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_rooms=_int(env, f"{prefix}MAX_ROOMS", DEFAULT_MAX_ROOMS, MIN_ROOMS), 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"), lobby_idle_timeout_s=_float( env, f"{prefix}LOBBY_IDLE_TIMEOUT_S", DEFAULT_LOBBY_IDLE_TIMEOUT_S, MIN_TIMEOUT_S diff --git a/src/undercooked/server/app.py b/src/undercooked/server/app.py index 744fd86..c33d67e 100644 --- a/src/undercooked/server/app.py +++ b/src/undercooked/server/app.py @@ -5,9 +5,13 @@ WebSocket endpoint (`/ws/{code}`, phase 06) and the level manifest (phase 02) all register on the app built here. Nothing game-like exists yet; the contract is only: -* ``GET /healthz`` — liveness probe for containers and tests, -* ``GET /`` — the client shell from ``app/static/index.html``, -* ``/static/*`` — the client's ES modules, CSS and art (never a CDN, A12), +* ``GET /healthz`` — liveness probe for containers and tests, +* ``GET /`` — the client shell from ``app/static/index.html``, +* ``/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`). ``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.debug import maybe_start_debugpy from undercooked.server.headers import SecurityHeadersMiddleware +from undercooked.server.routes_content import content_router, load_validated_content __all__ = [ "STATIC_DIR", @@ -87,6 +92,9 @@ def create_app(settings: Settings | None = None) -> FastAPI: Raises: ConfigError: the resolved static directory does not exist — better to 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() 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 " "(set UNDERCOOKED_STATIC_DIR to the directory holding index.html)" ) + content = load_validated_content(resolved) @asynccontextmanager async def lifespan(_app: FastAPI) -> AsyncIterator[None]: @@ -115,8 +124,10 @@ def create_app(settings: Settings | None = None) -> FastAPI: openapi_url=None, ) application.state.settings = resolved + application.state.content = content # loaded + validated once, above application.state.rooms = None # phase 05 installs the RoomSupervisor here application.add_middleware(SecurityHeadersMiddleware) + application.include_router(content_router) @application.get("/healthz") async def healthz() -> dict[str, object]: diff --git a/src/undercooked/server/routes_content.py b/src/undercooked/server/routes_content.py new file mode 100644 index 0000000..92d3e15 --- /dev/null +++ b/src/undercooked/server/routes_content.py @@ -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, + ) diff --git a/src/undercooked/sim/content.py b/src/undercooked/sim/content.py new file mode 100644 index 0000000..7ec93c8 --- /dev/null +++ b/src/undercooked/sim/content.py @@ -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)) diff --git a/src/undercooked/sim/grid.py b/src/undercooked/sim/grid.py new file mode 100644 index 0000000..2551bab --- /dev/null +++ b/src/undercooked/sim/grid.py @@ -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) diff --git a/src/undercooked/sim/level.py b/src/undercooked/sim/level.py new file mode 100644 index 0000000..0126d8e --- /dev/null +++ b/src/undercooked/sim/level.py @@ -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 diff --git a/src/undercooked/sim/model.py b/src/undercooked/sim/model.py new file mode 100644 index 0000000..1c435d1 --- /dev/null +++ b/src/undercooked/sim/model.py @@ -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}) diff --git a/src/undercooked/sim/purity.py b/src/undercooked/sim/purity.py new file mode 100644 index 0000000..3659b23 --- /dev/null +++ b/src/undercooked/sim/purity.py @@ -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]: + """`': imports '` 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) diff --git a/src/undercooked/sim/recipe.py b/src/undercooked/sim/recipe.py new file mode 100644 index 0000000..a8f9952 --- /dev/null +++ b/src/undercooked/sim/recipe.py @@ -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 `: : ` per problem.""" + parts: list[str] = [] + for problem in exc.errors(): + location = ".".join(str(part) for part in problem["loc"]) or "" + 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 diff --git a/src/undercooked/sim/validation.py b/src/undercooked/sim/validation.py new file mode 100644 index 0000000..b00142e --- /dev/null +++ b/src/undercooked/sim/validation.py @@ -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) diff --git a/src/undercooked/tools/levels.py b/src/undercooked/tools/levels.py new file mode 100644 index 0000000..f37c224 --- /dev/null +++ b/src/undercooked/tools/levels.py @@ -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 ", + "# uv run undercooked-levels axes --dir ", + "# 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 /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()) diff --git a/tests/conftest.py b/tests/conftest.py index 9be5cca..e8db06b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,10 +42,13 @@ def _as_env_value(value: object) -> str: @pytest.fixture(scope="session", autouse=True) 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 - content pipeline only arrives with phase 02. + No test may read the real `levels/` content implicitly. Since 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) previous = os.environ.get("UNDERCOOKED_LEVELS_DIR") diff --git a/tests/fixtures/levels/_bad/99_broken_room.yaml b/tests/fixtures/levels/_bad/99_broken_room.yaml new file mode 100644 index 0000000..ac67285 --- /dev/null +++ b/tests/fixtures/levels/_bad/99_broken_room.yaml @@ -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. diff --git a/tests/fixtures/levels/_bad/recipes.yaml b/tests/fixtures/levels/_bad/recipes.yaml new file mode 100644 index 0000000..b779d6d --- /dev/null +++ b/tests/fixtures/levels/_bad/recipes.yaml @@ -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} diff --git a/tests/fixtures/levels/recipes.yaml b/tests/fixtures/levels/recipes.yaml new file mode 100644 index 0000000..20f4e84 --- /dev/null +++ b/tests/fixtures/levels/recipes.yaml @@ -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} diff --git a/tests/integration/test_content_api.py b/tests/integration/test_content_api.py new file mode 100644 index 0000000..acc20cc --- /dev/null +++ b/tests/integration/test_content_api.py @@ -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 diff --git a/tests/sim/test_grid.py b/tests/sim/test_grid.py new file mode 100644 index 0000000..16f0d9e --- /dev/null +++ b/tests/sim/test_grid.py @@ -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 diff --git a/tests/sim/test_model.py b/tests/sim/test_model.py new file mode 100644 index 0000000..43aecf9 --- /dev/null +++ b/tests/sim/test_model.py @@ -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()) diff --git a/tests/sim/test_purity.py b/tests/sim/test_purity.py new file mode 100644 index 0000000..8332f9d --- /dev/null +++ b/tests/sim/test_purity.py @@ -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) diff --git a/tests/sim/test_validation.py b/tests/sim/test_validation.py new file mode 100644 index 0000000..3fd4c69 --- /dev/null +++ b/tests/sim/test_validation.py @@ -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"] diff --git a/tests/sim/test_validation_render.py b/tests/sim/test_validation_render.py new file mode 100644 index 0000000..17f3a43 --- /dev/null +++ b/tests/sim/test_validation_render.py @@ -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 diff --git a/tests/unit/test_content_loader.py b/tests/unit/test_content_loader.py new file mode 100644 index 0000000..ebe5de2 --- /dev/null +++ b/tests/unit/test_content_loader.py @@ -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) diff --git a/tests/unit/test_content_not_hardcoded.py b/tests/unit/test_content_not_hardcoded.py new file mode 100644 index 0000000..a3ac5c0 --- /dev/null +++ b/tests/unit/test_content_not_hardcoded.py @@ -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 diff --git a/tests/unit/test_content_startup.py b/tests/unit/test_content_startup.py new file mode 100644 index 0000000..f248184 --- /dev/null +++ b/tests/unit/test_content_startup.py @@ -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 diff --git a/tests/unit/test_docs_index.py b/tests/unit/test_docs_index.py index 52e9a86..cfb68aa 100644 --- a/tests/unit/test_docs_index.py +++ b/tests/unit/test_docs_index.py @@ -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.""" - 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: diff --git a/tests/unit/test_harness.py b/tests/unit/test_harness.py index 8924506..585366c 100644 --- a/tests/unit/test_harness.py +++ b/tests/unit/test_harness.py @@ -50,7 +50,12 @@ def test_fixture_levels_dir_exists(isolated_levels_dir: None) -> None: levels_dir = Path(os.environ["UNDERCOOKED_LEVELS_DIR"]) assert levels_dir == REPO_ROOT / "tests" / "fixtures" / "levels" 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: diff --git a/tests/unit/test_level_schema.py b/tests/unit/test_level_schema.py new file mode 100644 index 0000000..d4e134a --- /dev/null +++ b/tests/unit/test_level_schema.py @@ -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" diff --git a/tests/unit/test_levels_cli.py b/tests/unit/test_levels_cli.py new file mode 100644 index 0000000..86050f0 --- /dev/null +++ b/tests/unit/test_levels_cli.py @@ -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) diff --git a/tests/unit/test_levels_doc.py b/tests/unit/test_levels_doc.py new file mode 100644 index 0000000..43428bb --- /dev/null +++ b/tests/unit/test_levels_doc.py @@ -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 ``### `` 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) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 18a8f9f..150a1de 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -37,6 +37,7 @@ from undercooked.config import ( get_settings, reset_settings_cache, ) +from undercooked.sim.recipe import ContentError if TYPE_CHECKING: 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 +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: with pytest.raises(SystemExit) as excinfo: main(["--nope"]) diff --git a/tests/unit/test_recipe_schema.py b/tests/unit/test_recipe_schema.py new file mode 100644 index 0000000..3d1c5fe --- /dev/null +++ b/tests/unit/test_recipe_schema.py @@ -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") diff --git a/tests/unit/test_shipped_content.py b/tests/unit/test_shipped_content.py new file mode 100644 index 0000000..e4f59a9 --- /dev/null +++ b/tests/unit/test_shipped_content.py @@ -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}"