phase: 02_level_content_and_validation
Phase `02_level_content_and_validation` — final verification **green**. - **Gate:** `bash .agents/validate.sh` → ruff ✅ · pyright 0 errors ✅ · `undercooked-levels validate` OK (2 levels) ✅ · **668 passed** (incl. 10 Playwright e2e) ✅ · coverage **100%** ✅ · repo hygiene ✅ - **Criteria:** `validate` exit 0, one line per level; `list` = `01_first_kitchen`, `02_double_shift` in campaign order; live `:8000` `/healthz` 200, `/api/levels` (+detail) carries both levels with numeric `target_score`/`time_limit` and no grid/station leakage, `/api/config` correct, unknown id → 404, startup log `content loaded: 2 level(s), 4 recipe(s)`; `validate --dir tests/fixtures/levels/_bad/` → exit 1 with named `RECIPE_STATION_MISSING` / `SERVE_REACHABLE` / `TARGET_IMPLAUSIBLE`; A8 lock test proves a YAML-only level reaches loader+API+CLI. - **Defect fixed (pre-existing path, phase-02-triggered):** `undercooked` with unusable content printed the report then a raw traceback and exited `1`. `src/undercooked/__main__.py` now catches `ContentError` → one closing line, exit `2` (like a bad setting); 2 new tests in `tests/unit/test_main.py` (incl. non-content failures still propagate). - **Docs pass (full, project-wide):** README (content gate + exit `2`, config section), AGENTS.md §3, `docs/LEVELS.md` (startup refusal + exact CLI exit codes), `docs/DEPLOYMENT.md` (crash-loop diagnosis bullet); DESIGN/TESTING re-read, consistent. - **Container re-verified (no phase-01 regression):** `podman build -f Containerfile` succeeds; the image ships `levels/` and serves `/healthz` 200, `/api/levels` (2 levels), `/` 200 as non-root. - **Deviation to note:** the phase overview names `tests/fixtures/levels/bad/`; the actual broken-fixture dir is `tests/fixtures/levels/_bad/` (the `_` prefix is what keeps it out of normal discovery). Overview files are read-only to me — README/LEVELS.md document the real path. - No git index/history commands run; all changes left unstaged for the harness. ntfy notification sent.
This commit is contained in:
@@ -16,9 +16,10 @@
|
|||||||
- Execution is owned by the `phased-execution` skill (`run-task.sh`, `run-phase.sh`,
|
- Execution is owned by the `phased-execution` skill (`run-task.sh`, `run-phase.sh`,
|
||||||
`auto-phase.sh`). Each task runs in a fresh subprocess and must pass
|
`auto-phase.sh`). Each task runs in a fresh subprocess and must pass
|
||||||
`.agents/validate.sh` before it moves to `complete/`. That gate fails fast in this
|
`.agents/validate.sh` before it moves to `complete/`. That gate fails fast in this
|
||||||
order: `ruff check .` → `pyright` → `undercooked-levels validate` (skipped with a
|
order: `ruff check .` → `pyright` → `undercooked-levels validate` (unconditional:
|
||||||
notice until phase 02 creates the loader) → the **full** `pytest` suite with the
|
the same loader + PLAN §8 rules `create_app` runs at startup, over
|
||||||
coverage floor → repo-hygiene checks. It runs from the repo root or from `.agents/`.
|
`$UNDERCOOKED_LEVELS_DIR`) → the **full** `pytest` suite with the coverage floor →
|
||||||
|
repo-hygiene checks. It runs from the repo root or from `.agents/`.
|
||||||
- New work is authored by the `phase-authoring` skill as a phase directory — not
|
- New work is authored by the `phase-authoring` skill as a phase directory — not
|
||||||
implemented straight from chat.
|
implemented straight from chat.
|
||||||
- **Never** modify `.agents/PLAN.md` or anything under `.agents/phases/complete/`.
|
- **Never** modify `.agents/PLAN.md` or anything under `.agents/phases/complete/`.
|
||||||
@@ -37,6 +38,17 @@
|
|||||||
deviation from the default FastAPI stack.
|
deviation from the default FastAPI stack.
|
||||||
- **Data-driven content.** Levels/recipes/hazards come from YAML in `levels/` loaded
|
- **Data-driven content.** Levels/recipes/hazards come from YAML in `levels/` loaded
|
||||||
through validated Pydantic models. No hard-coded level or recipe lists in Python.
|
through validated Pydantic models. No hard-coded level or recipe lists in Python.
|
||||||
|
`create_app()` loads the bundle once (`routes_content.load_validated_content`) and
|
||||||
|
**refuses to start** when content fails to load or violates a PLAN §8 rule — the
|
||||||
|
named-rule report goes to stderr, `ContentError` propagates and the `undercooked`
|
||||||
|
command exits `2` like it does for an impossible setting. The content API
|
||||||
|
projects through `extra="forbid"` response models: grids, station ids and spawn
|
||||||
|
coordinates never leave the server. `undercooked-levels` (`tools/levels.py`, its
|
||||||
|
author CLI) obeys the same rule: it prints whatever the loaded directory holds.
|
||||||
|
`tests/unit/test_content_not_hardcoded.py` is the lock — it adds a level YAML to a
|
||||||
|
copy of `levels/` and expects the loader, `/api/levels` and the CLI to carry it with
|
||||||
|
no Python edited, and fails if any `src/undercooked/**.py` or browser asset names a
|
||||||
|
shipped level or recipe id as a literal.
|
||||||
- **Client: Canvas 2D + vanilla ES modules + this repo's own SVG art.** No bundler,
|
- **Client: Canvas 2D + vanilla ES modules + this repo's own SVG art.** No bundler,
|
||||||
no framework, no CDN, no analytics, no external fonts.
|
no framework, no CDN, no analytics, no external fonts.
|
||||||
- **Tests: pytest + Playwright.** One Playwright suite per user story; multi-player
|
- **Tests: pytest + Playwright.** One Playwright suite per user story; multi-player
|
||||||
@@ -52,7 +64,8 @@
|
|||||||
- [ ] **Full** project suite passes (`uv run pytest`), coverage gate met — including
|
- [ ] **Full** project suite passes (`uv run pytest`), coverage gate met — including
|
||||||
failures the task did not cause: fix them, do not skip, xfail, or delete tests.
|
failures the task did not cause: fix them, do not skip, xfail, or delete tests.
|
||||||
- [ ] `uv run ruff check .` and `uv run pyright` report zero errors.
|
- [ ] `uv run ruff check .` and `uv run pyright` report zero errors.
|
||||||
- [ ] `uv run undercooked-levels validate` passes if any level/content file changed.
|
- [ ] `uv run undercooked-levels validate` passes — `.agents/validate.sh` runs it for
|
||||||
|
every task, whether or not any content file changed.
|
||||||
- [ ] `README.md` and `AGENTS.md` updated when the change is user-visible
|
- [ ] `README.md` and `AGENTS.md` updated when the change is user-visible
|
||||||
(setup, commands, config, protocol, features, conventions). Every phase's final
|
(setup, commands, config, protocol, features, conventions). Every phase's final
|
||||||
pass re-reviews both **in full, project-wide** and fixes inconsistencies,
|
pass re-reviews both **in full, project-wide** and fixes inconsistencies,
|
||||||
@@ -115,8 +128,10 @@
|
|||||||
- **Protocol changes need a version bump**: editing a wire message means editing
|
- **Protocol changes need a version bump**: editing a wire message means editing
|
||||||
`src/undercooked/server/protocol.py`, `docs/PROTOCOL.md`, and the client in the same task,
|
`src/undercooked/server/protocol.py`, `docs/PROTOCOL.md`, and the client in the same task,
|
||||||
and bumping `PROTOCOL_VERSION` (PLAN §11).
|
and bumping `PROTOCOL_VERSION` (PLAN §11).
|
||||||
- **Determinism is a contract.** `sim/step.py` must produce identical state given the
|
- **Determinism is a contract.** `sim/step.py` (phase 03) must produce identical state
|
||||||
same seed and input script — the replay tests in `tests/sim/` are the guard.
|
given the same seed and input script — the replay tests in `tests/sim/` are the guard
|
||||||
|
once the tick exists; until then `tests/sim/test_purity.py` holds the A5 boundary that
|
||||||
|
determinism depends on.
|
||||||
- **ntfy is outbound-only.** Never use it to ask questions or wait for approval.
|
- **ntfy is outbound-only.** Never use it to ask questions or wait for approval.
|
||||||
If a task is ambiguous, make the best judgment consistent with PLAN.md, or stop
|
If a task is ambiguous, make the best judgment consistent with PLAN.md, or stop
|
||||||
and let the user rewrite the task.
|
and let the user rewrite the task.
|
||||||
@@ -134,9 +149,12 @@
|
|||||||
| Configuration (env vars → typed `Settings`), debugpy bootstrap | `src/undercooked/config.py`, `.env.example`, `src/undercooked/debug.py` |
|
| Configuration (env vars → typed `Settings`), debugpy bootstrap | `src/undercooked/config.py`, `.env.example`, `src/undercooked/debug.py` |
|
||||||
| Pure game rules (movement, stations, orders, scoring, hazards) | `src/undercooked/sim/` |
|
| Pure game rules (movement, stations, orders, scoring, hazards) | `src/undercooked/sim/` |
|
||||||
| Level/recipe content | `levels/*.yaml` |
|
| Level/recipe content | `levels/*.yaml` |
|
||||||
|
| Content schema, ASCII-grid parser, content loader, playability validator (contract: `docs/LEVELS.md`) | `src/undercooked/sim/recipe.py`, `level.py`, `content.py`, `validation.py` |
|
||||||
| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py`, `room.py` |
|
| Rooms, lobby lifecycle, session records | `src/undercooked/server/rooms.py`, `room.py` |
|
||||||
| Wire messages + validation | `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md` |
|
| Wire messages + validation | `src/undercooked/server/protocol.py`, `docs/PROTOCOL.md` |
|
||||||
| HTTP app (`/`, `/healthz`, `/static`), lifespan | `src/undercooked/server/app.py` |
|
| HTTP app (`/`, `/healthz`, `/static`), lifespan, startup content gate | `src/undercooked/server/app.py` |
|
||||||
|
| Content HTTP API (`/api/levels`, `/api/levels/{id}`, `/api/config`) + strict response models | `src/undercooked/server/routes_content.py` |
|
||||||
|
| Author CLI `undercooked-levels` (`validate`, `list`, `show`, `new`, `axes`); workflow in `docs/LEVELS.md` | `src/undercooked/tools/levels.py` |
|
||||||
| Security headers (CSP `default-src 'self'`, no-sniff, frame-ancestors) | `src/undercooked/server/headers.py` |
|
| Security headers (CSP `default-src 'self'`, no-sniff, frame-ancestors) | `src/undercooked/server/headers.py` |
|
||||||
| CLI entry point (`undercooked`): flags → `--help` → serve | `src/undercooked/__main__.py` |
|
| CLI entry point (`undercooked`): flags → `--help` → serve | `src/undercooked/__main__.py` |
|
||||||
| Renderer, HUD, input, interpolation | `app/static/js/`, `app/static/assets/` |
|
| Renderer, HUD, input, interpolation | `app/static/js/`, `app/static/assets/` |
|
||||||
@@ -146,8 +164,12 @@
|
|||||||
|
|
||||||
Tests mirror that: `tests/unit/` (pure, no-I/O logic incl. HTTP-route unit tests),
|
Tests mirror that: `tests/unit/` (pure, no-I/O logic incl. HTTP-route unit tests),
|
||||||
`tests/integration/` (in-process app boot, live uvicorn + WebSocket clients), `tests/sim/`
|
`tests/integration/` (in-process app boot, live uvicorn + WebSocket clients), `tests/sim/`
|
||||||
(headless determinism and replays), `tests/e2e/` (Playwright, named `test_usNN_*.py`,
|
(the pure kernel without a server: grid geometry, entity model, the A5 purity guard,
|
||||||
marked `e2e`). `tests/` is not a package; shared fixtures live in `tests/conftest.py`.
|
the level validator — headless determinism and replays join it with the tick in
|
||||||
|
phase 03), `tests/e2e/` (Playwright, named `test_usNN_*.py`, marked `e2e`). `tests/` is
|
||||||
|
not a package; shared fixtures live in `tests/conftest.py`, and the autouse fixture there
|
||||||
|
points `UNDERCOOKED_LEVELS_DIR` at `tests/fixtures/levels/` so no test silently reads the
|
||||||
|
shipped `levels/`.
|
||||||
|
|
||||||
- The browser suites use the **`playwright` sync API directly with fixtures written in
|
- The browser suites use the **`playwright` sync API directly with fixtures written in
|
||||||
`tests/e2e/conftest.py`** — `pytest-playwright` is deliberately not a dependency, so
|
`tests/e2e/conftest.py`** — `pytest-playwright` is deliberately not a dependency, so
|
||||||
|
|||||||
@@ -10,21 +10,34 @@ email, no password: you create a room, share the 5-character code, play.
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Phase 01 (runnable service scaffold) is **complete**; the next pending phase is
|
Phases **01** (runnable service scaffold) and **02** (level content, schema and
|
||||||
`02_level_content_and_validation`. What runs today: the Python toolchain is locked
|
validation) are **complete**; the next pending phase is
|
||||||
(`uv.lock`), the pytest harness covers unit, integration and Playwright e2e layers,
|
`03_simulation_movement_and_interaction`. What runs today:
|
||||||
configuration is a single typed, validated `Settings` object (`DEBUGPY` support
|
|
||||||
included), and `uv run undercooked` starts a FastAPI server that serves the client
|
- **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
|
||||||
shell at `/`, the browser assets from `app/static/`, and `GET /healthz` — which answers
|
single typed, validated `Settings` object (`DEBUGPY` support included), and
|
||||||
`{"status":"ok","version":"0.1.0","rooms":0}` (`rooms` stays `0` until phase 05 owns a
|
`uv run undercooked` starts a FastAPI server that serves the client shell at `/`,
|
||||||
supervisor) — with the security headers (CSP `default-src 'self'`) on every response.
|
the browser assets from `app/static/`, and `GET /healthz` — which answers
|
||||||
The rules are tests and not promises: the no-external-asset rule is
|
`{"status":"ok","version":"0.1.0","rooms":0}` (`rooms` stays `0` until phase 05 owns
|
||||||
`tests/unit/test_no_external_assets.py`, the documentation set is guarded by
|
a supervisor) — with the security headers (CSP `default-src 'self'`) on every
|
||||||
`tests/unit/test_docs_index.py`, `bash .agents/validate.sh` is the gate the phase
|
response.
|
||||||
pipeline runs after every task, and `docs/` holds the DESIGN / TESTING / DEPLOYMENT
|
- **The content.** The recipe book and the first two campaign levels live in
|
||||||
documents. Lobby buttons answer "Not implemented until phase 05.": levels and their
|
`levels/`; `sim/level.py` parses them into typed models, `sim/validation.py` runs
|
||||||
validator land in phase 02, the simulation in 03-04, rooms and the WebSocket protocol
|
PLAN §8's named rules, and `create_app` loads + validates that bundle at startup and
|
||||||
in 05-06, the playable renderer in 07. Execute phases with the `phased-execution` skill
|
**refuses to boot** broken content — the named-rule report goes to stderr and
|
||||||
|
`undercooked` exits `2` instead of serving a half-working kitchen. `GET /api/levels`,
|
||||||
|
`GET /api/levels/{id}` and `GET /api/config` answer from it, and
|
||||||
|
`undercooked-levels` gives authors the same gate from the command line
|
||||||
|
(`docs/LEVELS.md` is the schema reference).
|
||||||
|
- **The rules are tests, not promises.** The no-external-asset rule is
|
||||||
|
`tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is
|
||||||
|
`tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by
|
||||||
|
`tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
|
||||||
|
phase pipeline runs after every task.
|
||||||
|
|
||||||
|
What does not exist yet: the simulation (phases 03-04), rooms and the WebSocket
|
||||||
|
protocol (05-06 — the lobby buttons answer "Not implemented until phase 05.") and the
|
||||||
|
playable Canvas renderer (07). Execute phases with the `phased-execution` skill
|
||||||
(`auto-phase.sh`); it always takes the lowest-numbered directory still in
|
(`auto-phase.sh`); it always takes the lowest-numbered directory still in
|
||||||
`.agents/phases/todo/`.
|
`.agents/phases/todo/`.
|
||||||
|
|
||||||
@@ -56,14 +69,16 @@ uv run undercooked # http://127.0.0.1:8000 (shell page + /health
|
|||||||
uv run undercooked --host 0.0.0.0 --port 8000 # e.g. to reach it from a phone
|
uv run undercooked --host 0.0.0.0 --port 8000 # e.g. to reach it from a phone
|
||||||
uv run undercooked --reload # dev reload
|
uv run undercooked --reload # dev reload
|
||||||
uv run undercooked --levels-dir levels # point at another content dir
|
uv run undercooked --levels-dir levels # point at another content dir
|
||||||
|
curl -fsS http://127.0.0.1:8000/api/levels # the campaign cards the lobby will show
|
||||||
|
curl -fsS http://127.0.0.1:8000/api/config # client-safe settings + protocol version
|
||||||
```
|
```
|
||||||
|
|
||||||
`python -m undercooked` is the same command (the container uses it). A flag
|
`python -m undercooked` is the same command (the container uses it). A flag
|
||||||
always beats the environment, which beats the built-in default. There is no
|
always beats the environment, which beats the built-in default. There is no
|
||||||
`/docs` or `/openapi.json`: FastAPI renders those pages from a public CDN, which
|
`/docs` or `/openapi.json`: FastAPI renders those pages from a public CDN, which
|
||||||
this project forbids. `undercooked-levels validate` (check every level file loads
|
this project forbids. The validation the server runs at startup (every level
|
||||||
and is solvable) is declared in `pyproject.toml` but becomes runnable in phase 02,
|
file loads, and no rule of PLAN §8 is broken) is a command too —
|
||||||
when the content loader lands.
|
`undercooked-levels`, see **Authoring content** below.
|
||||||
|
|
||||||
Debug the server with a real debugger attached (dev only - the listener binds
|
Debug the server with a real debugger attached (dev only - the listener binds
|
||||||
`0.0.0.0`, so never enable it on a public host):
|
`0.0.0.0`, so never enable it on a public host):
|
||||||
@@ -72,6 +87,27 @@ Debug the server with a real debugger attached (dev only - the listener binds
|
|||||||
DEBUGPY=1 uv run undercooked # opens a debugpy listener on :5678, then serves as usual
|
DEBUGPY=1 uv run undercooked # opens a debugpy listener on :5678, then serves as usual
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Authoring content:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run undercooked-levels list # the campaign, in campaign order
|
||||||
|
uv run undercooked-levels show 01_first_kitchen --grid # resolved level + reachability overlay
|
||||||
|
uv run undercooked-levels validate # the content gate; --json for scripts
|
||||||
|
uv run undercooked-levels new 03_riverboat # scaffold a level from the recipe book
|
||||||
|
uv run undercooked-levels axes # escalation-axis table (phase 11's curve)
|
||||||
|
```
|
||||||
|
|
||||||
|
`undercooked-levels` is a thin shell over the loader and the PLAN §8 rules
|
||||||
|
`create_app` runs at startup, so a green `validate` is a server that boots. It
|
||||||
|
finds content through the settings (`--dir` > `UNDERCOOKED_LEVELS_DIR` >
|
||||||
|
`levels`, plus `--recipes` > `UNDERCOOKED_RECIPES_PATH` > `<dir>/recipes.yaml`)
|
||||||
|
and never from the current directory, so it works from any path. Exit codes:
|
||||||
|
`0` ok, `1` a level breaks a rule, `2` nothing usable to work with (load
|
||||||
|
failure, unknown id, refused overwrite, usage error). The schema, every field,
|
||||||
|
the overlay trick and a "which rule did I just hit" table live in
|
||||||
|
[docs/LEVELS.md](docs/LEVELS.md); `tests/unit/test_content_not_hardcoded.py`
|
||||||
|
is what keeps levels and recipes out of the Python.
|
||||||
|
|
||||||
Run the checks:
|
Run the checks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -91,8 +127,9 @@ running first, and no test ever listens on a routable interface.
|
|||||||
|
|
||||||
`bash .agents/validate.sh` is the gate a phase task must pass before it moves to
|
`bash .agents/validate.sh` is the gate a phase task must pass before it moves to
|
||||||
`.agents/phases/complete/`. It stops at the first failure, in this order:
|
`.agents/phases/complete/`. It stops at the first failure, in this order:
|
||||||
`ruff check .`, `pyright`, `undercooked-levels validate` (skipped with a notice until
|
`ruff check .`, `pyright`, `undercooked-levels validate` (unconditional: the same
|
||||||
phase 02 creates the loader), the **full** `pytest` suite with the 90 % coverage floor,
|
loader and PLAN §8 rules `create_app` runs at startup, so a campaign that cannot
|
||||||
|
validate cannot commit), the **full** `pytest` suite with the 90 % coverage floor,
|
||||||
then repo-hygiene checks (nothing tracked that `.gitignore` excludes, no log drops or
|
then repo-hygiene checks (nothing tracked that `.gitignore` excludes, no log drops or
|
||||||
key material committed). Run it from the repo root or from `.agents/`.
|
key material committed). Run it from the repo root or from `.agents/`.
|
||||||
|
|
||||||
@@ -113,7 +150,10 @@ nothing else in the codebase touches `os.environ`. Precedence: `undercooked` CLI
|
|||||||
flags, then exported environment variables, then a local `.env` (found from the
|
flags, then exported environment variables, then a local `.env` (found from the
|
||||||
current directory upwards; never commit it), then the built-in defaults. A blank value counts as
|
current directory upwards; never commit it), then the built-in defaults. A blank value counts as
|
||||||
unset. Invalid values (`UNDERCOOKED_TICK_HZ=99`) raise `ConfigError` at startup
|
unset. Invalid values (`UNDERCOOKED_TICK_HZ=99`) raise `ConfigError` at startup
|
||||||
instead of half-configuring the server. `.env.example` and the table below list
|
instead of half-configuring the server, and unusable content is refused the same
|
||||||
|
way — an unreadable `UNDERCOOKED_LEVELS_DIR` or a level that breaks a PLAN §8
|
||||||
|
rule prints the named-rule report and exits `2` (under `--reload` the reload
|
||||||
|
child reports and restarts instead; docs/LEVELS.md). `.env.example` and the table below list
|
||||||
every variable with its default; tests keep both in sync with `config.py`.
|
every variable with its default; tests keep both in sync with `config.py`.
|
||||||
|
|
||||||
| Variable | Default | Meaning |
|
| Variable | Default | Meaning |
|
||||||
@@ -125,6 +165,7 @@ every variable with its default; tests keep both in sync with `config.py`.
|
|||||||
| `UNDERCOOKED_MAX_PLAYERS` | `4` | Players per room (1-4) |
|
| `UNDERCOOKED_MAX_PLAYERS` | `4` | Players per room (1-4) |
|
||||||
| `UNDERCOOKED_MAX_ROOMS` | `200` | Concurrent rooms before new rooms are refused |
|
| `UNDERCOOKED_MAX_ROOMS` | `200` | Concurrent rooms before new rooms are refused |
|
||||||
| `UNDERCOOKED_LEVELS_DIR` | `levels` | Directory of level YAML files (`~` is expanded) |
|
| `UNDERCOOKED_LEVELS_DIR` | `levels` | Directory of level YAML files (`~` is expanded) |
|
||||||
|
| `UNDERCOOKED_RECIPES_PATH` | *(derived)* | Recipe book file; empty = `recipes.yaml` inside `UNDERCOOKED_LEVELS_DIR` (here: `levels/recipes.yaml`) |
|
||||||
| `UNDERCOOKED_STATIC_DIR` | *(derived)* | Directory served at `/static`; empty = `app/static/` beside the source (containers set it) |
|
| `UNDERCOOKED_STATIC_DIR` | *(derived)* | Directory served at `/static`; empty = `app/static/` beside the source (containers set it) |
|
||||||
| `UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S` | `900` | Close empty/idle lobbies |
|
| `UNDERCOOKED_LOBBY_IDLE_TIMEOUT_S` | `900` | Close empty/idle lobbies |
|
||||||
| `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` | `45` | Drop a disconnected player's seat after this |
|
| `UNDERCOOKED_GUEST_IDLE_TIMEOUT_S` | `45` | Drop a disconnected player's seat after this |
|
||||||
@@ -142,17 +183,34 @@ src/undercooked/ server: FastAPI app, rooms, WebSocket protocol, simulati
|
|||||||
src/undercooked/__main__.py the `undercooked` command (CLI > env > default)
|
src/undercooked/__main__.py the `undercooked` command (CLI > env > default)
|
||||||
src/undercooked/config.py typed env settings (the only `os.environ` reader)
|
src/undercooked/config.py typed env settings (the only `os.environ` reader)
|
||||||
src/undercooked/debug.py DEBUGPY-gated debugger bootstrap
|
src/undercooked/debug.py DEBUGPY-gated debugger bootstrap
|
||||||
src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`
|
src/undercooked/sim/ pure simulation kernel: entities (`model`), grid geometry
|
||||||
|
(`grid`), content schema + loader (`recipe`, `level`,
|
||||||
|
`content` — the YAML contract is docs/LEVELS.md), the
|
||||||
|
level validator (`validation` — PLAN §8 named rules;
|
||||||
|
rule table in docs/LEVELS.md), and the
|
||||||
|
A5 purity guard (`purity`, test-enforced). There is no
|
||||||
|
`step()` yet: the tick arrives with phase 03
|
||||||
|
src/undercooked/tools/levels.py the `undercooked-levels` author CLI — validate, list,
|
||||||
|
show [--grid], new, axes; same loader, same rules, no
|
||||||
|
hard-coded content list (docs/LEVELS.md "Authoring workflow")
|
||||||
|
src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`, mounts the content API
|
||||||
|
src/undercooked/server/routes_content.py `/api/levels`, `/api/levels/{id}`, `/api/config`,
|
||||||
|
plus the startup gate: broken content refuses startup
|
||||||
src/undercooked/server/headers.py the security-header middleware
|
src/undercooked/server/headers.py the security-header middleware
|
||||||
.env.example every configuration variable, with its default
|
.env.example every configuration variable, with its default
|
||||||
levels/ data-driven level definitions (YAML) + recipes (empty until 02)
|
levels/ data-driven content (YAML): the recipe book (A8) and the
|
||||||
|
first two campaign levels (01_first_kitchen, 02_double_shift)
|
||||||
app/static/ browser client: the ES-module shell today; Canvas 2D renderer
|
app/static/ browser client: the ES-module shell today; Canvas 2D renderer
|
||||||
and this repo's SVG art arrive with phase 07
|
and this repo's SVG art arrive with phase 07
|
||||||
tests/ unit/, integration/, sim/, e2e/ (one Playwright suite per story);
|
tests/ unit/, integration/, e2e/ (one Playwright suite per story) and
|
||||||
fixtures/levels/ (test-isolated content dir); conftest.py
|
sim/ (the pure kernel: grid, entities, purity guard, validation);
|
||||||
docs/ DESIGN.md, TESTING.md (how this project is verified),
|
fixtures/levels/ is the content dir tests see (a recipe book and no
|
||||||
DEPLOYMENT.md; PROTOCOL / LEVELS / SIMULATION / CLIENT land with
|
levels; `_bad/` holds the deliberately-broken campaign the `_`
|
||||||
their own phases (the index is PLAN.md §13)
|
discovery rule ignores); conftest.py
|
||||||
|
docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract),
|
||||||
|
TESTING.md (how this project is verified), DEPLOYMENT.md;
|
||||||
|
PROTOCOL / SIMULATION / CLIENT land with their own phases
|
||||||
|
(the index is PLAN.md §13)
|
||||||
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
|
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ Rules that hold for every deployment of this project:
|
|||||||
template; the game has no secrets by design (anonymous, A6/A16).
|
template; the game has no secrets by design (anonymous, A6/A16).
|
||||||
- Everything is in memory: a restart ends every round and erases every scoreboard. That is
|
- Everything is in memory: a restart ends every round and erases every scoreboard. That is
|
||||||
a documented product decision, not an incident.
|
a documented product decision, not an incident.
|
||||||
|
- **The campaign in the image is checked before the port opens.** `create_app` loads and
|
||||||
|
validates `UNDERCOOKED_LEVELS_DIR` at startup, so an image built around content that
|
||||||
|
breaks a PLAN §8 rule exits `2` with the named-rule report on stderr instead of serving
|
||||||
|
an empty lobby; with `restart: unless-stopped` that looks like a crash loop whose logs
|
||||||
|
are the diagnosis. `uv run undercooked-levels validate` reproduces it from the checkout.
|
||||||
|
|
||||||
## What this document will contain when it is filled in
|
## What this document will contain when it is filled in
|
||||||
|
|
||||||
|
|||||||
+10
-4
@@ -1,6 +1,6 @@
|
|||||||
# DESIGN.md — architecture and the decisions behind it
|
# DESIGN.md — architecture and the decisions behind it
|
||||||
|
|
||||||
**Status:** stub written by phase 01 · **Filled in by phase 09**, when the client, the
|
**Status:** stub written by phase 01, module map refreshed by phase 02 · **Filled in by phase 09**, when the client, the
|
||||||
protocol and the first two-player end-to-end story make the prose verifiable.
|
protocol and the first two-player end-to-end story make the prose verifiable.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
@@ -21,15 +21,21 @@ all. Everything around that kernel — `server/` (FastAPI app, WebSocket protoco
|
|||||||
supervisor, in-memory session records) and `app/static/` (vanilla ES modules, Canvas 2D)
|
supervisor, in-memory session records) and `app/static/` (vanilla ES modules, Canvas 2D)
|
||||||
— exists only to move bytes between a player's hands and the simulation.
|
— exists only to move bytes between a player's hands and the simulation.
|
||||||
|
|
||||||
## Module map (as it stands after phase 01)
|
## Module map (as it stands after phase 02)
|
||||||
|
|
||||||
| Path | Owns | Filled in by |
|
| Path | Owns | Filled in by |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `src/undercooked/config.py` | the only read of the environment → typed, validated `Settings` | phase 01 (done) |
|
| `src/undercooked/config.py` | the only read of the environment → typed, validated `Settings` | phase 01 (done) |
|
||||||
| `src/undercooked/debug.py` | `DEBUGPY`-gated debugger bootstrap (A18) | phase 01 (done) |
|
| `src/undercooked/debug.py` | `DEBUGPY`-gated debugger bootstrap (A18) | phase 01 (done) |
|
||||||
| `src/undercooked/server/app.py` | app factory, `/healthz`, `/`, `/static`, startup logging | phase 01 (done) |
|
| `src/undercooked/server/app.py` | app factory, `/healthz`, `/`, `/static`, startup logging, mounts the content router | phase 01 (done) |
|
||||||
|
| `src/undercooked/server/routes_content.py` | `/api/levels`, `/api/levels/{id}`, `/api/config` behind `extra="forbid"` response models, plus the startup gate that refuses broken content | phase 02 (done) |
|
||||||
| `src/undercooked/server/headers.py` | CSP + the four other security headers on every response | phase 01 (done) |
|
| `src/undercooked/server/headers.py` | CSP + the four other security headers on every response | phase 01 (done) |
|
||||||
| `src/undercooked/sim/` | game truth: model, `step()`, interactions, scoring, hazards | phase 03–04 |
|
| `src/undercooked/sim/model.py`, `grid.py` | the entity vocabulary (`Vec2`, items, stations, players, orders, hazards, `KitchenState`) and tile geometry/walkability | phase 02 (done) |
|
||||||
|
| `src/undercooked/sim/recipe.py`, `level.py`, `content.py` | the content contract (A8): recipe book, level schema + ASCII legend, discovery and campaign ordering | phase 02 (done); schema reference in `docs/LEVELS.md` |
|
||||||
|
| `src/undercooked/sim/validation.py` | PLAN §8's named playability rules and the escalation axes | phase 02 (done) |
|
||||||
|
| `src/undercooked/sim/purity.py` | the A5 guard: no I/O, asyncio, clock or FastAPI inside `sim/` | phase 02 (done) |
|
||||||
|
| `src/undercooked/tools/levels.py` | the `undercooked-levels` author CLI — a shell over the loader and the rules, never a second implementation | phase 02 (done) |
|
||||||
|
| `src/undercooked/sim/step.py` | game truth: the tick, interactions, scoring, hazards | phase 03–04 |
|
||||||
| `src/undercooked/server/room.py`, `rooms.py` | one match, its FSM and tick task; the registry that owns all of them | phase 05–06 |
|
| `src/undercooked/server/room.py`, `rooms.py` | one match, its FSM and tick task; the registry that owns all of them | phase 05–06 |
|
||||||
| `src/undercooked/server/protocol.py` | versioned wire messages, whitelists, inbound validation | phase 06 |
|
| `src/undercooked/server/protocol.py` | versioned wire messages, whitelists, inbound validation | phase 06 |
|
||||||
| `src/undercooked/server/sessions.py` | bounded in-memory progression records (A6: no database) | phase 05 |
|
| `src/undercooked/server/sessions.py` | bounded in-memory progression records (A6: no database) | phase 05 |
|
||||||
|
|||||||
+499
@@ -0,0 +1,499 @@
|
|||||||
|
# LEVELS.md — level & recipe YAML reference
|
||||||
|
|
||||||
|
**Status:** current as of phase 02 (schema, parser, loader, validator, and the
|
||||||
|
`undercooked-levels` author CLI). Phase 11 extends
|
||||||
|
this document with the campaign-wide difficulty-curve notes; until then every
|
||||||
|
field of every content model is documented here, and
|
||||||
|
`tests/unit/test_levels_doc.py` keeps the two in lockstep.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document defines the data-driven content contract for Undercooked: the
|
||||||
|
recipe book, one YAML file per level, the ASCII-grid legend conventions, and
|
||||||
|
every schema field with its type, default, and meaning. Content authors write
|
||||||
|
files against this reference and the loader validates them at startup — the
|
||||||
|
Python source never enumerates levels or recipes, and any document that fails
|
||||||
|
these rules is a load error naming its file, never a silently-defaulted
|
||||||
|
guess.
|
||||||
|
|
||||||
|
The models live in `src/undercooked/sim/recipe.py` (book, recipes, steps,
|
||||||
|
ingredients), `src/undercooked/sim/level.py` (levels, tiles, orders, hazards)
|
||||||
|
and `src/undercooked/sim/content.py` (discovery and loading). All models are
|
||||||
|
strict (`extra="forbid"`) and revalidate on assignment: an unknown key is an
|
||||||
|
error, not a typo someone lives with.
|
||||||
|
|
||||||
|
## Files on disk
|
||||||
|
|
||||||
|
```text
|
||||||
|
levels/
|
||||||
|
recipes.yaml <- the recipe book (see "recipes.yaml" below)
|
||||||
|
01_first_kitchen.yaml <- one level per file
|
||||||
|
02_double_shift.yaml
|
||||||
|
_draft.yaml <- ignored: a leading underscore opts a file out
|
||||||
|
notes.md <- ignored: only .yaml / .yml are content
|
||||||
|
```
|
||||||
|
|
||||||
|
`undercooked.sim.content.load_content(levels_dir, recipes_path)` is the whole
|
||||||
|
loader. Its discovery rules:
|
||||||
|
|
||||||
|
| Rule | Behaviour |
|
||||||
|
|---|---|
|
||||||
|
| Level discovery | every `*.yaml` / `*.yml` file under `levels_dir`, at any depth |
|
||||||
|
| Drafts | files whose name — or any ancestor directory's name — starts with `_` are ignored |
|
||||||
|
| The recipe book | `recipes.yaml` / `recipes.yml` inside `levels_dir` is never treated as a level; the book path is passed separately |
|
||||||
|
| Ordering | the campaign order is `(order, id)` — never filename order |
|
||||||
|
| `by_id` | derived from the ordered tuple; cannot disagree with it |
|
||||||
|
| Duplicate ids | a load error listing every duplicated id |
|
||||||
|
| Unknown pool recipes | a level whose `orders.pool` names a recipe the book does not define fails the load |
|
||||||
|
| Error reporting | every file-level problem is aggregated into one `ContentError` naming each offending file |
|
||||||
|
|
||||||
|
Every problem found in one run is aggregated into a single `ContentError` whose
|
||||||
|
message is one line per offending file — no prefix, because the caller adds
|
||||||
|
its own (`undercooked-levels` prefixes `undercooked-levels:`, `create_app`
|
||||||
|
prefixes `undercooked: content failed to load from <dir>:`). It reads like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
undercooked-levels: /…/levels/02_ghost.yaml: orders.pool names unknown recipes: ghost
|
||||||
|
/…/levels/03_wreck.yaml: <root>: Value error, ragged grid: row 1 is 10 chars, want 9; grid character 'Z' has no tiles: legend entry
|
||||||
|
```
|
||||||
|
|
||||||
|
## recipes.yaml — the recipe book
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
ingredients: [tomato, onion, plate] # optional catalog of the campaign's items
|
||||||
|
recipes:
|
||||||
|
soup: # key must equal the recipe's id
|
||||||
|
id: soup
|
||||||
|
name: Tomato Soup
|
||||||
|
base_score: 40
|
||||||
|
ingredients:
|
||||||
|
- {kind: tomato, form: raw}
|
||||||
|
- {kind: onion, form: raw, count: 2}
|
||||||
|
steps:
|
||||||
|
- {kind: chop, station: chop_board, seconds: 0.9}
|
||||||
|
- {kind: cook, station: stove, seconds: 5.0}
|
||||||
|
```
|
||||||
|
|
||||||
|
### RecipeBook
|
||||||
|
|
||||||
|
| Field | Type | Default | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `ingredients` | `list[ItemKind]` | `[]` | Catalog of item kinds the campaign uses. Informational in phase 02; item respawning uses it from phase 03 on. |
|
||||||
|
| `recipes` | `map[id → Recipe]` | required, non-empty | The recipes. Each key must equal the recipe's own `id`. |
|
||||||
|
|
||||||
|
Methods (deterministic, side-effect free):
|
||||||
|
|
||||||
|
- `get(recipe_id)` → the `Recipe`; `KeyError` naming all known ids otherwise.
|
||||||
|
- `ids()` → declaration order, as a tuple.
|
||||||
|
- `required_station_kinds(recipe_id)` → `frozenset[StationKind]` of every
|
||||||
|
station the steps name. The `RECIPE_STATION_MISSING` rule consumes this;
|
||||||
|
nothing hard-codes "soup needs a stove".
|
||||||
|
- `required_item_kinds(recipe_id)` → every ingredient kind plus `PLATE` when
|
||||||
|
`requires_plating` is true.
|
||||||
|
- `min_seconds_to_complete(recipe_id)` → the theoretical minimum for a perfect
|
||||||
|
cook: the sum of all step `seconds` plus one flat handling allowance
|
||||||
|
(`HANDLING_S = 0.6` s per recipe, the constant in `sim/recipe.py`).
|
||||||
|
The `TARGET_IMPLAUSIBLE` rule uses this as the greedy per-order floor of
|
||||||
|
what a round can score (see "Validation rules").
|
||||||
|
|
||||||
|
### Recipe
|
||||||
|
|
||||||
|
| Field | Type | Default | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | non-empty `str` | required | Stable identifier level order pools reference. |
|
||||||
|
| `name` | non-empty `str` | required | Display name. |
|
||||||
|
| `base_score` | `int ≥ 0` | required | Points for a perfect delivery before tips/combo (phase 04's quality, streak and time bonuses scale them). |
|
||||||
|
| `ingredients` | `list[Ingredient]`, ≥ 1 | required | What the dish consumes. |
|
||||||
|
| `steps` | `list[Step]`, ≥ 1 | required | The ordered procedure. |
|
||||||
|
| `requires_plating` | `bool` | `true` | Whether a plain dish needs the plating step before serving. |
|
||||||
|
| `tags` | `list[str]` | `[]` | Free-form labels for scoring/difficulty hints. |
|
||||||
|
|
||||||
|
### Ingredient
|
||||||
|
|
||||||
|
| Field | Type | Default | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `kind` | `ItemKind` | required | Which item (enum in `sim/model.py`; YAML uses the lowercase value). |
|
||||||
|
| `form` | `ItemForm` | required | Form required at the moment of use: `raw`, `chopped`, `cooking`, `cooked`, `burnt`, `plated`, `dirty`. |
|
||||||
|
| `count` | `int ≥ 1` | `1` | How many units the recipe consumes. |
|
||||||
|
|
||||||
|
### Step
|
||||||
|
|
||||||
|
| Field | Type | Default | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `kind` | `pick` \| `chop` \| `cook` \| `wash` \| `plate` \| `serve` | required | The verb. A closed vocabulary — anything else is a load error. Trashing is an interaction, not a recipe step, so it has no verb here. |
|
||||||
|
| `seconds` | `float ≥ 0` | `0.0` | How long the action takes when performed. `pick`/`serve` are instantaneous (`0.0`). |
|
||||||
|
| `station` | `StationKind` | required | Where the verb happens (`spawn_shelf`, `chop_board`, `stove`, `prep`, `serve`, `sink`, `trash`). |
|
||||||
|
|
||||||
|
## A level file
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
id: 01_first_kitchen # convention: the file's stem (see Working conventions)
|
||||||
|
name: First Kitchen
|
||||||
|
order: 1 # campaign position
|
||||||
|
difficulty: 1 # 1..10; feeds DIFFICULTY_ORDER + TRAVEL_TOO_SHORT, tuned in phase 11
|
||||||
|
time_limit: 150 # seconds of round time, > 0
|
||||||
|
target_score: 45 # win threshold
|
||||||
|
blurb: Two cooks, one pot. # optional flavour text
|
||||||
|
grid: # ASCII map, all rows equal length
|
||||||
|
- "#########"
|
||||||
|
- "#S..C...#"
|
||||||
|
- "#.......#"
|
||||||
|
- "#..V....#"
|
||||||
|
- "#########"
|
||||||
|
tiles: # every grid char maps to exactly one TileSpec
|
||||||
|
".": {kind: floor}
|
||||||
|
"#": {kind: wall}
|
||||||
|
"S": {kind: floor, station: spawn_shelf, item: tomato}
|
||||||
|
"C": {kind: floor, station: chop_board}
|
||||||
|
"V": {kind: floor, station: serve}
|
||||||
|
spawns: [[1, 1]] # player spawns as [x, y] grid coords
|
||||||
|
orders:
|
||||||
|
pool: [soup]
|
||||||
|
concurrent: 1
|
||||||
|
spawn_interval: [16, 20]
|
||||||
|
deadline: 60
|
||||||
|
hazards: [] # optional, see HazardSpec
|
||||||
|
station_limits: {plates: 2, pots: 1} # optional, default 99 / 99
|
||||||
|
```
|
||||||
|
|
||||||
|
### LevelDef
|
||||||
|
|
||||||
|
| Field | Type | Default | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `id` | non-empty `str` | required | Unique across the campaign; duplicate ids fail `load_content`. |
|
||||||
|
| `name` | non-empty `str` | required | Display name. |
|
||||||
|
| `order` | `int ≥ 1` | required | Campaign position. Levels are sorted by `(order, id)`. |
|
||||||
|
| `difficulty` | `int` 1–10 | `1` | Author-declared difficulty. Feeds `DIFFICULTY_ORDER` (the campaign may not declare an easier level after a harder one) and switches on `TRAVEL_TOO_SHORT` from 3 upward; phase 11 tunes the curve against it. |
|
||||||
|
| `time_limit` | `float > 0` | required | Round length in seconds. |
|
||||||
|
| `target_score` | `int ≥ 1` | required | Score needed to win the round. |
|
||||||
|
| `grid` | `list[str]`, ≥ 1 row | required | The ASCII map. Every row the same length, at most 64×64 (`MAX_GRID`), every character present in `tiles`. |
|
||||||
|
| `tiles` | `map[char → TileSpec]`, ≥ 1 | required | The legend. Every legend character must be used by the grid — unused entries are typos. |
|
||||||
|
| `spawns` | `list[[x, y]]`, ≥ 1 | required | Player spawn coordinates, each inside the grid. |
|
||||||
|
| `orders` | `OrdersSpec` | required | The order engine configuration for this level. |
|
||||||
|
| `hazards` | `list[HazardSpec]` | `[]` | Placed and periodic hazards. |
|
||||||
|
| `station_limits` | `StationLimits` | `99 / 99` | Concurrent capacity of shared station resources. |
|
||||||
|
| `blurb` | `str` | `""` | Flavour text for the level card. |
|
||||||
|
| `escalation_notes` | `str` | `""` | Free-form note to the phase 11 difficulty-curve pass. |
|
||||||
|
|
||||||
|
Schema-level rules (enforced right here, at load):
|
||||||
|
|
||||||
|
- **rectangular grid** — differing row lengths are a load error naming the row;
|
||||||
|
- **legend completeness both ways** — no grid character without a legend
|
||||||
|
entry, no legend character the grid never uses;
|
||||||
|
- **spawn inside the grid**, at least one spawn;
|
||||||
|
- **exactly one serve legend character** in the tiles map — the validator
|
||||||
|
rejects levels where the win condition cannot be reached by definition
|
||||||
|
(the validator's flood-fill rules below go further and reject pockets no
|
||||||
|
cook can walk into);
|
||||||
|
- numeric sanity on every counter, interval, and limit listed above.
|
||||||
|
|
||||||
|
### TileSpec
|
||||||
|
|
||||||
|
| Field | Type | Default | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `kind` | `TileKind` (`floor` \| `counter` \| `wall` \| `void`) | required | Walkability class; only `floor` is walkable (`WALKABLE` in `sim/model.py`). |
|
||||||
|
| `station` | `StationKind` or `null` | `null` | The station installed on this tile, if any. |
|
||||||
|
| `item` | `ItemKind` or `null` | `null` | What this tile restocks — legal **only** on a `spawn_shelf` station tile, and **mandatory** there. |
|
||||||
|
| `id_prefix` | non-empty `str` or `null` | `null` | Replaces the station-kind stem in generated ids (see "Station ids"). Allowed only on station tiles. |
|
||||||
|
|
||||||
|
Stations sit on tiles of any `kind`; the schema allows it so hazards and
|
||||||
|
decorations can mount stations, but the `STATION_REACHABLE` rule demands a
|
||||||
|
walkable (`floor`) tile for every station players must touch.
|
||||||
|
|
||||||
|
### OrdersSpec
|
||||||
|
|
||||||
|
| Field | Type | Default | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `pool` | `list[str]`, ≥ 1 | required | Recipe ids this level draws orders from. Every entry must exist in the recipe book (`load_content` checks; the validator re-checks hand-built levels). |
|
||||||
|
| `concurrent` | `int ≥ 1` | required | Maximum simultaneously active orders. |
|
||||||
|
| `spawn_interval` | `[min, max]` seconds, both > 0 | required | Uniform range the round RNG draws from. `min ≤ max` is validated — reversed intervals are the classic misconfiguration. |
|
||||||
|
| `deadline` | `float > 0` | required | Seconds an order may live before it burns. |
|
||||||
|
| `penalty` | `int ≥ 0` | `10` | Points lost when an order burns out. |
|
||||||
|
|
||||||
|
### HazardSpec
|
||||||
|
|
||||||
|
One shape for placed and periodic hazards. Placement rules by `type`:
|
||||||
|
|
||||||
|
| `type` | Placement | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| `fire` | `station` **or** `x`/`y` (exactly one) | Ignites that station or that cell. |
|
||||||
|
| `spill` | `station` **or** `x`/`y` (exactly one) | Grease puddle at that location. |
|
||||||
|
| `moving_obstacle` | neither; `path` + `period_s` instead | Travels the path back and forth on a loop. |
|
||||||
|
| `rush` | nowhere (level-wide) | A dining-rush surge; needs a positive `intensity`. |
|
||||||
|
|
||||||
|
| Field | Type | Default | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `type` | `HazardKind` (`fire` \| `spill` \| `moving_obstacle` \| `rush`) | required | Which hazard. |
|
||||||
|
| `station` | `str` or `null` | `null` | Station id to attach to (e.g. `stove_2`). Mutually exclusive with `x`/`y`. |
|
||||||
|
| `x` | `int ≥ 0` or `null` | `null` | Cell x. Must be given together with `y`. |
|
||||||
|
| `y` | `int ≥ 0` or `null` | `null` | Cell y. Must be given together with `x`. |
|
||||||
|
| `chance_per_s` | `float ≥ 0` or `null` | `null` | Periodic re-arm probability per second; `null` = placed once at round start. |
|
||||||
|
| `respawn_s` | `float > 0` or `null` | `null` | Delay before a cleared hazard can re-arm. |
|
||||||
|
| `path` | `list[[x, y]]`, ≥ 2 points, or `null` | `null` | Route for `moving_obstacle` only. |
|
||||||
|
| `period_s` | `float > 0` or `null` | `null` | Loop period for `moving_obstacle`; required when it has a `path`. |
|
||||||
|
| `intensity` | `float` (must be > 0 when `type: rush`) | `1.0` | Magnitude (`rush` surge strength; severity multiplier elsewhere). |
|
||||||
|
|
||||||
|
### StationLimits
|
||||||
|
|
||||||
|
| Field | Type | Default | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `plates` | `int ≥ 0` | `99` | Clean plates available at once. `0` loads and is reported by `STATION_LIMIT_ZERO` — the rule, not a schema error, so the report names it. |
|
||||||
|
| `pots` | `int ≥ 0` | `99` | Pots the stoves share. |
|
||||||
|
|
||||||
|
## Station ids and the built `Level`
|
||||||
|
|
||||||
|
`build_grid(spec)` walks the grid in reading order (row by row, left to
|
||||||
|
right) and assigns each station tile an id: `{id_prefix or station-kind
|
||||||
|
value}_{n}`, where `n` counts **per station kind** across the whole level.
|
||||||
|
Two legend characters of the same kind therefore share one counter — a level
|
||||||
|
with `T` (stove) and `t` (stove, `id_prefix: burner`) produces `stove_0` and
|
||||||
|
`burner_1`. The same walk yields the `Station` list and `build_grid`'s
|
||||||
|
`Grid.station_at` (cell index → station id), and `Grid.from_rows` re-checks
|
||||||
|
geometry one last time so the grid cannot disagree with the spec.
|
||||||
|
|
||||||
|
`Level.build(spec)` pairs the validated `LevelDef` with what the parser
|
||||||
|
produces — `grid` (the `sim/grid.py` `Grid`), `stations`, `spawns` (as
|
||||||
|
`(x, y)` tuples) and `station_items` (spawn-shelf id → restocked `ItemKind`)
|
||||||
|
— and delegates `id`, `order`, `difficulty`, `time_limit`, `target_score`,
|
||||||
|
`orders`, `hazards`, `station_limits`, `blurb` and `escalation_notes` to the
|
||||||
|
spec, so simulation code reads one object. The `ContentBundle` returned by
|
||||||
|
`load_content` holds the `recipe_book`, the campaign-ordered `levels`, and
|
||||||
|
the derived `by_id` map.
|
||||||
|
|
||||||
|
## Validation rules
|
||||||
|
|
||||||
|
`sim/validation.py` proves a built `Level` is playable before anyone joins it —
|
||||||
|
the PLAN §8 content gate. `validate_level(level, book, prev=None)` runs the
|
||||||
|
per-level rules (`prev` is the campaign predecessor and enables the escalation
|
||||||
|
check); `validate_levels(levels, book)` adds the bundle-wide rules over an
|
||||||
|
`(order, id)`-ordered list and `validate_bundle(bundle)` runs that over a
|
||||||
|
loaded bundle. Each hit is a `LevelViolation` naming a stable **rule id** with
|
||||||
|
a human message carrying coordinates and a machine-readable `detail` mapping.
|
||||||
|
`ValidationReport.render()` prints a deterministic, ANSI-free,
|
||||||
|
level-grouped report, and **warnings never fail a level** — `report.ok` reads
|
||||||
|
violations only.
|
||||||
|
|
||||||
|
| Rule | Fires when |
|
||||||
|
|---|---|
|
||||||
|
| `GRID_SHAPE` | the built `Grid` disagrees with the spec's ASCII row count or width (hand-built levels; `LevelDef` validation already rejects ragged grids) |
|
||||||
|
| `SPAWN_ON_FLOOR` | a spawn is not on a walkable floor tile |
|
||||||
|
| `SPAWN_COUNT` | the level has no player spawns |
|
||||||
|
| `STATION_REACHABLE` | a station cell cannot be walked to from any spawn |
|
||||||
|
| `SERVE_REACHABLE` | the same for the serve window specifically — no reachable window means no round can ever be won |
|
||||||
|
| `SPAWNS_MUTUALLY_REACHABLE` | a spawn cannot be walked to from the first spawn |
|
||||||
|
| `RECIPE_STATION_MISSING` | a pool recipe needs a station kind the level does not have within the reachable area |
|
||||||
|
| `RECIPE_INGREDIENT_UNAVAILABLE` | a pool recipe needs a `raw` ingredient no reachable spawn shelf produces (spawn shelves are currently the only item source the content model expresses) |
|
||||||
|
| `ORDER_POOL_UNKNOWN_RECIPE` | `orders.pool` names a recipe the recipe book does not define |
|
||||||
|
| `TARGET_IMPLAUSIBLE` | `target_score` exceeds the plausibility bound below — above 45% of the bound it is a **warning**, above 60% an error |
|
||||||
|
| `TIME_LIMIT_INVALID` | `time_limit` is under 30 s |
|
||||||
|
| `HAZARD_REF_UNKNOWN` | a hazard references a station id that does not exist |
|
||||||
|
| `HAZARD_ON_UNREACHABLE_TILE` | a hazard tile or `moving_obstacle` path point is off-grid, not walkable, or cut off from the cooks |
|
||||||
|
| `STATION_LIMIT_ZERO` | `station_limits.plates` or `.pots` is below 1 |
|
||||||
|
| `DUPLICATE_ID` | two levels in the bundle share an `id` (bundle rule) |
|
||||||
|
| `DIFFICULTY_ORDER` | a level raises fewer than two escalation axes over its predecessor, or the campaign's declared `difficulty` drops between consecutive levels (bundle rule) |
|
||||||
|
| `TRAVEL_TOO_SHORT` | from difficulty 3 up, the mean spawn→serve path is under 3 tiles — sprint-to-serve layouts are not campaigns |
|
||||||
|
|
||||||
|
**Target plausibility** (`theoretical_max_score`) is the greedy bound
|
||||||
|
`floor(time_limit / mean_complete) × mean_order_value` over the level's order
|
||||||
|
pool, where `mean_complete` is the recipe book's `min_seconds_to_complete()`
|
||||||
|
average plus a travel bound of `2 × mean spawn→serve tiles ÷ 3.5 tiles/s` —
|
||||||
|
one walk to the window and one back at max cook speed (PLAN §5). The bound is
|
||||||
|
deliberately generous: it rejects targets a perfect team could never hit, not
|
||||||
|
targets a sloppy one misses. Levels with an invalid time limit or no known
|
||||||
|
pool recipes return no bound — those conditions have their own rules.
|
||||||
|
|
||||||
|
**Escalation axes** (`escalation_axes`, every axis oriented *higher = harder*):
|
||||||
|
|
||||||
|
| Axis | Measure |
|
||||||
|
|---|---|
|
||||||
|
| `recipe_complexity` | number of distinct recipes in the order pool |
|
||||||
|
| `order_pressure` | `orders.concurrent` ÷ mean spawn interval |
|
||||||
|
| `time_budget` | `target_score` ÷ `time_limit` |
|
||||||
|
| `station_scarcity` | 1 ÷ station count |
|
||||||
|
| `layout_hostility` | blocking tiles ÷ total tiles |
|
||||||
|
| `hazard_density` | number of hazards |
|
||||||
|
| `travel_distance` | mean BFS tiles from a spawn to the nearest serve cell |
|
||||||
|
|
||||||
|
Reachability is a BFS flood fill over walkable tiles from every spawn.
|
||||||
|
`moving_obstacle` tiles are deliberately *not* subtracted from the reachable
|
||||||
|
set — movers move, so only their path points must be valid floor. When the
|
||||||
|
serve window is unreachable the travel metric falls back to Manhattan
|
||||||
|
distance so the numbers stay defined; the reachability rules report the break
|
||||||
|
itself. `undercooked-levels validate` prints this report for the levels
|
||||||
|
directory and exits `1` on any violation — see **Authoring workflow** below. The
|
||||||
|
same report stops the game server: `create_app` runs that exact pair at startup,
|
||||||
|
so `uv run undercooked` prints it, refuses to build an app around it and exits
|
||||||
|
`2` rather than open a lobby nobody can cook in.
|
||||||
|
|
||||||
|
## Working conventions
|
||||||
|
|
||||||
|
- `id` should equal the file's stem; the loader keys everything by `id`, and
|
||||||
|
the bundle rule `DUPLICATE_ID` refuses a bundle that reuses one.
|
||||||
|
- Two characters may share a `TileSpec` meaning only by writing it twice;
|
||||||
|
characters are single characters — a multi-character legend key can never
|
||||||
|
match a grid cell, so the load fails with `legend character 'VV' is never
|
||||||
|
used by the grid`, while a grid character with no entry at all fails with
|
||||||
|
`grid character 'Z' has no tiles: legend entry`.
|
||||||
|
- Spawn shelves are the only item source at schema level; deeper restocking
|
||||||
|
semantics belong to phase 03's simulation work.
|
||||||
|
- Everything strict: when a field is added to a model, `docs/LEVELS.md` must
|
||||||
|
gain a row in the same change — `tests/unit/test_levels_doc.py` fails if
|
||||||
|
the doc table and `model_fields` ever disagree.
|
||||||
|
|
||||||
|
## Authoring workflow
|
||||||
|
|
||||||
|
`undercooked-levels` is the author's half of this contract, and a thin shell
|
||||||
|
over the code above: `validate` is exactly `load_content` + `validate_bundle`
|
||||||
|
(the same pair `create_app` runs at startup), so green here means a server that
|
||||||
|
boots there. It ships with the project (`[project.scripts]` in
|
||||||
|
`pyproject.toml`) and never enumerates content itself — it prints whatever the
|
||||||
|
directory it was pointed at contains. `tests/unit/test_content_not_hardcoded.py`
|
||||||
|
holds the CLI, the loader and the content API to that rule.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run undercooked-levels new 03_riverboat # 1. scaffold a level, validate that file
|
||||||
|
uv run undercooked-levels list # 2. the campaign, in campaign order
|
||||||
|
uv run undercooked-levels show 03_riverboat --grid # 3. read the resolved level + overlay
|
||||||
|
uv run undercooked-levels validate # 4. the gate .agents/validate.sh runs
|
||||||
|
uv run undercooked-levels axes # 5. the escalation table phase 11 tunes
|
||||||
|
```
|
||||||
|
|
||||||
|
Every subcommand takes `--dir PATH` (levels) and `--recipes PATH` (the book),
|
||||||
|
which beat `UNDERCOOKED_LEVELS_DIR` / `UNDERCOOKED_RECIPES_PATH`, which beat the
|
||||||
|
defaults `levels` and `<levels>/recipes.yaml`. Path resolution is settings-driven,
|
||||||
|
never cwd-driven: a *relative* path with nothing under the current directory is
|
||||||
|
anchored to this checkout, so the commands above work from `/tmp` exactly as
|
||||||
|
they do from the repo root.
|
||||||
|
|
||||||
|
1. **`new ID [--order N]`** writes `<levels>/ID.yaml` — a valid, minimal kitchen
|
||||||
|
*generated from the recipe book*: one spawn shelf per raw ingredient of the
|
||||||
|
book's first dish, one station per kind that dish's steps name, and two
|
||||||
|
spawns on the floor row far enough from the window to clear
|
||||||
|
`TRAVEL_TOO_SHORT`. `--order` defaults to one past the highest in the
|
||||||
|
directory; `difficulty` starts one above the campaign's hardest. It refuses
|
||||||
|
to overwrite an existing level, and prints the single-level validation
|
||||||
|
report of what it wrote.
|
||||||
|
2. **Edit** the grid, the tunables and the prose. `id` should equal the file's
|
||||||
|
stem. Fill `escalation_notes` with the axes you moved: the campaign rule
|
||||||
|
(`DIFFICULTY_ORDER`) wants at least two axes up versus the level that
|
||||||
|
precedes yours by `order`, and a `difficulty` that never drops.
|
||||||
|
3. **`list` and `show ID [--grid]`** read the level back the way the server sees
|
||||||
|
it — campaign order and resolved geometry, not the text you typed. `show`
|
||||||
|
prints the tunables, the station and hazard counts, how much of the floor is
|
||||||
|
reachable, and each pool dish resolved against the book (or
|
||||||
|
`MISSING from the recipe book`).
|
||||||
|
4. **`validate`** prints one status line per level and then
|
||||||
|
`ValidationReport.render()`. Warnings print and stay green — a warning is a
|
||||||
|
tuning question, a violation is a broken level:
|
||||||
|
|
||||||
|
```text
|
||||||
|
99_broken_room 4 error(s) 11x6, 4 stations
|
||||||
|
4 error(s), 0 warning(s)
|
||||||
|
level 99_broken_room
|
||||||
|
ERROR RECIPE_STATION_MISSING: recipe 'soup' needs a reachable prep station; the level has none
|
||||||
|
ERROR RECIPE_STATION_MISSING: recipe 'soup' needs a reachable stove station; the level has none
|
||||||
|
ERROR SERVE_REACHABLE: serve station serve_0 at (8,1) is not reachable from any spawn
|
||||||
|
ERROR TARGET_IMPLAUSIBLE: target_score 999 is above 60% of the theoretical maximum 160; no one could ever reach it
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **`axes`** prints the seven escalation axes per level in campaign order
|
||||||
|
(higher = harder), which is how you check a level really got harder instead
|
||||||
|
of only *feeling* harder:
|
||||||
|
|
||||||
|
```text
|
||||||
|
LEVEL ORDER recipe_complexity order_pressure time_budget station_scarcity layout_hostility hazard_density travel_distance
|
||||||
|
01_first_kitchen 1 1.0000 0.0556 0.3000 0.1429 0.3519 0.0000 7.5000
|
||||||
|
02_double_shift 2 2.0000 0.1429 0.6154 0.1250 0.4786 0.0000 5.5000
|
||||||
|
```
|
||||||
|
|
||||||
|
### The overlay debugging trick
|
||||||
|
|
||||||
|
`STATION_REACHABLE`, `SERVE_REACHABLE` and `HAZARD_ON_UNREACHABLE_TILE` all
|
||||||
|
come down to one question: which walkable floor can the cooks actually get to?
|
||||||
|
`show --grid` re-draws the authored map as terrain so you see the answer
|
||||||
|
instead of deducing it from a BFS you cannot see:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run undercooked-levels show 99_broken_room --grid --dir tests/fixtures/levels/_bad
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
map (as authored) <- the file, exactly as written
|
||||||
|
###########
|
||||||
|
#T..C#..V.#
|
||||||
|
#....#....#
|
||||||
|
#.#########
|
||||||
|
#S........#
|
||||||
|
###########
|
||||||
|
|
||||||
|
reachability . reachable ? walkable but cut off # blocking
|
||||||
|
###########
|
||||||
|
#....#????#
|
||||||
|
#....#????#
|
||||||
|
#.#########
|
||||||
|
#.........#
|
||||||
|
###########
|
||||||
|
|
||||||
|
18/26 walkable tiles are reachable from the spawns
|
||||||
|
```
|
||||||
|
|
||||||
|
The `?` band is the sealed servery: walkable floor, and the wall at column 6
|
||||||
|
cuts it off from the spawn on row 4. Fix the wall, not the target score. Note
|
||||||
|
that a moving obstacle's `path` tiles stay `.` even when a mover currently
|
||||||
|
blocks them — movers move, so the validator only insists the path itself is
|
||||||
|
valid floor.
|
||||||
|
|
||||||
|
### Scripting it
|
||||||
|
|
||||||
|
`validate --json` is the machine-readable form of the same report — stable key
|
||||||
|
names, sorted hits, so a diff of two runs is only a diff of real changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run undercooked-levels validate --json | python -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": false,
|
||||||
|
"counts": {"errors": 4, "warnings": 0},
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"level": "99_broken_room",
|
||||||
|
"rule": "SERVE_REACHABLE",
|
||||||
|
"message": "serve station serve_0 at (8,1) is not reachable from any spawn",
|
||||||
|
"detail": {"station_id": "serve_0", "x": 8, "y": 1}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit codes are the whole scripting surface: `0` everything is fine, `1` the
|
||||||
|
content loaded and breaks at least one rule, `2` the tool could not do its job
|
||||||
|
(content would not load, an unknown level id, a file `new` refuses to overwrite,
|
||||||
|
or an argparse usage error). `.agents/validate.sh` runs `validate` unconditionally,
|
||||||
|
so a campaign that cannot validate cannot commit even when no test file changed.
|
||||||
|
|
||||||
|
### Which rule did I just hit?
|
||||||
|
|
||||||
|
The full semantics are in the **Validation rules** table above; this is the
|
||||||
|
"what do I type next" view of the same ids.
|
||||||
|
|
||||||
|
| Rule | Usually means |
|
||||||
|
|---|---|
|
||||||
|
| `GRID_SHAPE` | the row count/width you wrote and the built grid disagree — check for a ragged row or a stray character |
|
||||||
|
| `SPAWN_ON_FLOOR` | a `spawns:` coordinate lands on wall/counter — move it onto a `.` |
|
||||||
|
| `SPAWN_COUNT` | `spawns:` is empty — a kitchen nobody stands in |
|
||||||
|
| `STATION_REACHABLE` | a fixture sits behind a wall — `show --grid` and open a path |
|
||||||
|
| `SERVE_REACHABLE` | the window is unreachable, so the round cannot be won — same fix, and the level is unwinnable until then |
|
||||||
|
| `SPAWNS_MUTUALLY_REACHABLE` | the cooks start on islands — connect the spawn areas |
|
||||||
|
| `RECIPE_STATION_MISSING` | a pool dish needs a station the level lacks (or cannot reach) — add the fixture or trim the pool |
|
||||||
|
| `RECIPE_INGREDIENT_UNAVAILABLE` | a pool dish eats an item no reachable spawn shelf produces — add a shelf for it |
|
||||||
|
| `ORDER_POOL_UNKNOWN_RECIPE` | `orders.pool` names a dish `recipes.yaml` does not define — check the spelling |
|
||||||
|
| `TARGET_IMPLAUSIBLE` | `target_score` is out of reach of a perfect team — lower the target, lengthen `time_limit`, or raise the dish values |
|
||||||
|
| `TIME_LIMIT_INVALID` | `time_limit` under 30 s — a round that is not a round |
|
||||||
|
| `HAZARD_REF_UNKNOWN` | a hazard's `station:` id is not in the level — re-read the station ids the loader built |
|
||||||
|
| `HAZARD_ON_UNREACHABLE_TILE` | a hazard sits in a wall or a sealed pocket — move it where the cooks are |
|
||||||
|
| `STATION_LIMIT_ZERO` | `station_limits.plates`/`.pots` is 0, so no dish can ever be plated |
|
||||||
|
| `DUPLICATE_ID` | two files declare the same `id` — one of them is a copy-paste |
|
||||||
|
| `DIFFICULTY_ORDER` | the level moves fewer than two escalation axes up, or `difficulty` dropped — compare with `axes` |
|
||||||
|
| `TRAVEL_TOO_SHORT` | from difficulty 3, spawn-to-serve averages under 3 tiles — spread the kitchen out |
|
||||||
+12
-2
@@ -1,6 +1,6 @@
|
|||||||
# TESTING.md — how this project is verified
|
# TESTING.md — how this project is verified
|
||||||
|
|
||||||
**Status:** current as of phase 01 · **Filled in by phase 14**, which re-runs every layer
|
**Status:** current as of phase 02 · **Filled in by phase 14**, which re-runs every layer
|
||||||
below against the finished game and records the release-time suite inventory.
|
below against the finished game and records the release-time suite inventory.
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
@@ -20,7 +20,7 @@ layer live in `tests/conftest.py`, and layer-specific fixtures live in that laye
|
|||||||
| Directory | Contains | I/O allowed |
|
| Directory | Contains | I/O allowed |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `tests/unit/` | pure logic with no I/O: config parsing, header values, CLI precedence, protocol/validator helpers, and the doc/content guards that keep README, `.env.example`, `PLAN.md` and the code agreeing | none (in-process `TestClient` requests are fine) |
|
| `tests/unit/` | pure logic with no I/O: config parsing, header values, CLI precedence, protocol/validator helpers, and the doc/content guards that keep README, `.env.example`, `PLAN.md` and the code agreeing | none (in-process `TestClient` requests are fine) |
|
||||||
| `tests/sim/` | headless determinism and replay tests against the pure kernel (`tests/sim/test_replay_*.py`, golden state hashes) | none — the sim must stay callable without a server |
|
| `tests/sim/` | tests of the pure kernel that need no server: grid geometry, the entity model, the A5 import-purity guard, and the level validator (phase 02) — plus the headless determinism/replay suites with golden state hashes (`tests/sim/test_replay_*.py`) that arrive with the tick in phase 03 | none — the sim must stay callable without a server |
|
||||||
| `tests/integration/` | multi-module, in-process tests: app boot through `create_app()`, HTTP routes, static serving, room/protocol behaviour with real WebSocket clients (room + protocol integration is PLAN §9 layer 4, and lives here) | in-process app, loopback sockets |
|
| `tests/integration/` | multi-module, in-process tests: app boot through `create_app()`, HTTP routes, static serving, room/protocol behaviour with real WebSocket clients (room + protocol integration is PLAN §9 layer 4, and lives here) | in-process app, loopback sockets |
|
||||||
| `tests/e2e/` | one Playwright suite per user story, named `test_usNN_*.py`; multiplayer stories drive **two browser contexts** against one room | real browser, real socket |
|
| `tests/e2e/` | one Playwright suite per user story, named `test_usNN_*.py`; multiplayer stories drive **two browser contexts** against one room | real browser, real socket |
|
||||||
| `tests/fixtures/` | fixture data only. `tests/fixtures/levels/` is the content dir every test sees — an autouse session fixture points `UNDERCOOKED_LEVELS_DIR` at it so no test can silently read the shipped `levels/` | none |
|
| `tests/fixtures/` | fixture data only. `tests/fixtures/levels/` is the content dir every test sees — an autouse session fixture points `UNDERCOOKED_LEVELS_DIR` at it so no test can silently read the shipped `levels/` | none |
|
||||||
@@ -59,6 +59,16 @@ uv run pyright # types
|
|||||||
bash .agents/validate.sh # the pipeline gate, in that order + hygiene
|
bash .agents/validate.sh # the pipeline gate, in that order + hygiene
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The gate runs one check that is not a pytest command: `uv run undercooked-levels
|
||||||
|
validate` between pyright and pytest (docs/LEVELS.md **Authoring workflow**). It is
|
||||||
|
unconditional — a campaign that breaks a PLAN §8 rule stops the pipeline even when no
|
||||||
|
test file changed — and it is the same `load_content` + `validate_bundle` pair
|
||||||
|
`create_app()` runs at startup, so it can disagree with the server about nothing.
|
||||||
|
Content tests stay hermetic the same way: the autouse session fixture points
|
||||||
|
`UNDERCOOKED_LEVELS_DIR` at `tests/fixtures/levels/` (a recipe book and zero levels),
|
||||||
|
and a test that wants real levels builds one under `tmp_path` or names `levels/`
|
||||||
|
explicitly. `tests/unit/test_content_not_hardcoded.py` is the A8 lock on top of that.
|
||||||
|
|
||||||
Coverage is configured in `pyproject.toml`: `source = ["src/undercooked"]`, branch
|
Coverage is configured in `pyproject.toml`: `source = ["src/undercooked"]`, branch
|
||||||
coverage on, `--cov-fail-under=90` (AGENTS.md A20 asks for > 90 % on new/changed code; the
|
coverage on, `--cov-fail-under=90` (AGENTS.md A20 asks for > 90 % on new/changed code; the
|
||||||
floor is the mechanical half of that).
|
floor is the mechanical half of that).
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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]
|
||||||
@@ -9,7 +9,8 @@ exactly the same configuration as this process, and so an invalid ``--port`` is
|
|||||||
rejected by the same validator as an invalid `UNDERCOOKED_PORT`.
|
rejected by the same validator as an invalid `UNDERCOOKED_PORT`.
|
||||||
|
|
||||||
Exit codes: ``0`` after a clean shutdown, ``2`` for a configuration error
|
Exit codes: ``0`` after a clean shutdown, ``2`` for a configuration error
|
||||||
(matching argparse's own usage-error code), anything else comes from uvicorn.
|
(matching argparse's own usage-error code) *or* level content the startup gate
|
||||||
|
refused to build an app around, anything else comes from uvicorn.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -21,6 +22,7 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from undercooked.config import ConfigError, Settings, apply_cli_overrides
|
from undercooked.config import ConfigError, Settings, apply_cli_overrides
|
||||||
|
from undercooked.sim.recipe import ContentError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping, Sequence
|
from collections.abc import Mapping, Sequence
|
||||||
@@ -148,7 +150,19 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
print(f"undercooked: {exc}", file=sys.stderr)
|
print(f"undercooked: {exc}", file=sys.stderr)
|
||||||
return EXIT_CONFIG_ERROR
|
return EXIT_CONFIG_ERROR
|
||||||
|
|
||||||
_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
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ ENV_KEYS: Mapping[str, str] = {
|
|||||||
"max_players": f"{ENV_PREFIX}MAX_PLAYERS",
|
"max_players": f"{ENV_PREFIX}MAX_PLAYERS",
|
||||||
"max_rooms": f"{ENV_PREFIX}MAX_ROOMS",
|
"max_rooms": f"{ENV_PREFIX}MAX_ROOMS",
|
||||||
"levels_dir": f"{ENV_PREFIX}LEVELS_DIR",
|
"levels_dir": f"{ENV_PREFIX}LEVELS_DIR",
|
||||||
|
"recipes_path": f"{ENV_PREFIX}RECIPES_PATH",
|
||||||
"static_dir": f"{ENV_PREFIX}STATIC_DIR",
|
"static_dir": f"{ENV_PREFIX}STATIC_DIR",
|
||||||
"lobby_idle_timeout_s": f"{ENV_PREFIX}LOBBY_IDLE_TIMEOUT_S",
|
"lobby_idle_timeout_s": f"{ENV_PREFIX}LOBBY_IDLE_TIMEOUT_S",
|
||||||
"guest_idle_timeout_s": f"{ENV_PREFIX}GUEST_IDLE_TIMEOUT_S",
|
"guest_idle_timeout_s": f"{ENV_PREFIX}GUEST_IDLE_TIMEOUT_S",
|
||||||
@@ -76,6 +77,9 @@ DEFAULT_SNAPSHOT_HZ = 20
|
|||||||
DEFAULT_MAX_PLAYERS = 4
|
DEFAULT_MAX_PLAYERS = 4
|
||||||
DEFAULT_MAX_ROOMS = 200
|
DEFAULT_MAX_ROOMS = 200
|
||||||
DEFAULT_LEVELS_DIR = Path("levels")
|
DEFAULT_LEVELS_DIR = Path("levels")
|
||||||
|
# `None` means "the recipe book beside the levels directory" (see
|
||||||
|
# `server.routes_content.resolve_recipes_path`), i.e. `levels/recipes.yaml`.
|
||||||
|
DEFAULT_RECIPES_PATH: Path | None = None
|
||||||
# `None` means "derive it from the installed package layout" (see
|
# `None` means "derive it from the installed package layout" (see
|
||||||
# `server.app.STATIC_DIR`); containers set an explicit path instead.
|
# `server.app.STATIC_DIR`); containers set an explicit path instead.
|
||||||
DEFAULT_STATIC_DIR: Path | None = None
|
DEFAULT_STATIC_DIR: Path | None = None
|
||||||
@@ -119,6 +123,8 @@ class Settings:
|
|||||||
max_players: seats per room (PLAN 2: 1-4).
|
max_players: seats per room (PLAN 2: 1-4).
|
||||||
max_rooms: process-wide room cap before new rooms are refused.
|
max_rooms: process-wide room cap before new rooms are refused.
|
||||||
levels_dir: directory of level YAML files (A8); ``~`` is expanded.
|
levels_dir: directory of level YAML files (A8); ``~`` is expanded.
|
||||||
|
recipes_path: the recipe book file (A8); ``None`` resolves to
|
||||||
|
``levels_dir/recipes.yaml``, and ``~`` is expanded when set.
|
||||||
static_dir: directory served at ``/static``; ``None`` derives it from the
|
static_dir: directory served at ``/static``; ``None`` derives it from the
|
||||||
package layout (``<repo>/app/static``), which is wrong inside a
|
package layout (``<repo>/app/static``), which is wrong inside a
|
||||||
container image, so those set it explicitly.
|
container image, so those set it explicitly.
|
||||||
@@ -140,6 +146,7 @@ class Settings:
|
|||||||
max_players: int = DEFAULT_MAX_PLAYERS
|
max_players: int = DEFAULT_MAX_PLAYERS
|
||||||
max_rooms: int = DEFAULT_MAX_ROOMS
|
max_rooms: int = DEFAULT_MAX_ROOMS
|
||||||
levels_dir: Path = DEFAULT_LEVELS_DIR
|
levels_dir: Path = DEFAULT_LEVELS_DIR
|
||||||
|
recipes_path: Path | None = DEFAULT_RECIPES_PATH
|
||||||
static_dir: Path | None = DEFAULT_STATIC_DIR
|
static_dir: Path | None = DEFAULT_STATIC_DIR
|
||||||
lobby_idle_timeout_s: float = DEFAULT_LOBBY_IDLE_TIMEOUT_S
|
lobby_idle_timeout_s: float = DEFAULT_LOBBY_IDLE_TIMEOUT_S
|
||||||
guest_idle_timeout_s: float = DEFAULT_GUEST_IDLE_TIMEOUT_S
|
guest_idle_timeout_s: float = DEFAULT_GUEST_IDLE_TIMEOUT_S
|
||||||
@@ -286,6 +293,7 @@ def load_settings(env: Mapping[str, str] | None = None) -> Settings:
|
|||||||
max_players=max_players,
|
max_players=max_players,
|
||||||
max_rooms=_int(env, f"{prefix}MAX_ROOMS", DEFAULT_MAX_ROOMS, MIN_ROOMS),
|
max_rooms=_int(env, f"{prefix}MAX_ROOMS", DEFAULT_MAX_ROOMS, MIN_ROOMS),
|
||||||
levels_dir=_path(env, f"{prefix}LEVELS_DIR", DEFAULT_LEVELS_DIR),
|
levels_dir=_path(env, f"{prefix}LEVELS_DIR", DEFAULT_LEVELS_DIR),
|
||||||
|
recipes_path=_optional_path(env, f"{prefix}RECIPES_PATH"),
|
||||||
static_dir=_optional_path(env, f"{prefix}STATIC_DIR"),
|
static_dir=_optional_path(env, f"{prefix}STATIC_DIR"),
|
||||||
lobby_idle_timeout_s=_float(
|
lobby_idle_timeout_s=_float(
|
||||||
env, f"{prefix}LOBBY_IDLE_TIMEOUT_S", DEFAULT_LOBBY_IDLE_TIMEOUT_S, MIN_TIMEOUT_S
|
env, f"{prefix}LOBBY_IDLE_TIMEOUT_S", DEFAULT_LOBBY_IDLE_TIMEOUT_S, MIN_TIMEOUT_S
|
||||||
|
|||||||
@@ -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
|
all register on the app built here. Nothing game-like exists yet; the contract
|
||||||
is only:
|
is only:
|
||||||
|
|
||||||
* ``GET /healthz`` — liveness probe for containers and tests,
|
* ``GET /healthz`` — liveness probe for containers and tests,
|
||||||
* ``GET /`` — the client shell from ``app/static/index.html``,
|
* ``GET /`` — the client shell from ``app/static/index.html``,
|
||||||
* ``/static/*`` — the client's ES modules, CSS and art (never a CDN, A12),
|
* ``/static/*`` — the client's ES modules, CSS and art (never a CDN, A12),
|
||||||
|
* ``/api/levels``, ``/api/levels/{id}``, ``/api/config`` — the content API
|
||||||
|
(:mod:`undercooked.server.routes_content`), fed by the bundle loaded once
|
||||||
|
here: content that will not load or fails PLAN §8 validation refuses
|
||||||
|
startup with the report on stderr instead of serving a half-working lobby,
|
||||||
* security headers on every response (:mod:`undercooked.server.headers`).
|
* security headers on every response (:mod:`undercooked.server.headers`).
|
||||||
|
|
||||||
``create_app`` takes a :class:`~undercooked.config.Settings` as plain data, so a
|
``create_app`` takes a :class:`~undercooked.config.Settings` as plain data, so a
|
||||||
@@ -31,6 +35,7 @@ from undercooked import __version__
|
|||||||
from undercooked.config import ConfigError, Settings, get_settings
|
from undercooked.config import ConfigError, Settings, get_settings
|
||||||
from undercooked.debug import maybe_start_debugpy
|
from undercooked.debug import maybe_start_debugpy
|
||||||
from undercooked.server.headers import SecurityHeadersMiddleware
|
from undercooked.server.headers import SecurityHeadersMiddleware
|
||||||
|
from undercooked.server.routes_content import content_router, load_validated_content
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"STATIC_DIR",
|
"STATIC_DIR",
|
||||||
@@ -87,6 +92,9 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
Raises:
|
Raises:
|
||||||
ConfigError: the resolved static directory does not exist — better to
|
ConfigError: the resolved static directory does not exist — better to
|
||||||
fail at startup than to serve 500s for every asset.
|
fail at startup than to serve 500s for every asset.
|
||||||
|
ContentError: the level/recipe content will not load, or a level fails
|
||||||
|
a PLAN §8 validation rule — the full report goes to stderr and no
|
||||||
|
app is built (a broken kitchen must not open as a lobby).
|
||||||
"""
|
"""
|
||||||
resolved = settings if settings is not None else get_settings()
|
resolved = settings if settings is not None else get_settings()
|
||||||
static_dir = resolve_static_dir(resolved)
|
static_dir = resolve_static_dir(resolved)
|
||||||
@@ -95,6 +103,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
f"static directory {static_dir} does not exist "
|
f"static directory {static_dir} does not exist "
|
||||||
"(set UNDERCOOKED_STATIC_DIR to the directory holding index.html)"
|
"(set UNDERCOOKED_STATIC_DIR to the directory holding index.html)"
|
||||||
)
|
)
|
||||||
|
content = load_validated_content(resolved)
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||||
@@ -115,8 +124,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
openapi_url=None,
|
openapi_url=None,
|
||||||
)
|
)
|
||||||
application.state.settings = resolved
|
application.state.settings = resolved
|
||||||
|
application.state.content = content # loaded + validated once, above
|
||||||
application.state.rooms = None # phase 05 installs the RoomSupervisor here
|
application.state.rooms = None # phase 05 installs the RoomSupervisor here
|
||||||
application.add_middleware(SecurityHeadersMiddleware)
|
application.add_middleware(SecurityHeadersMiddleware)
|
||||||
|
application.include_router(content_router)
|
||||||
|
|
||||||
@application.get("/healthz")
|
@application.get("/healthz")
|
||||||
async def healthz() -> dict[str, object]:
|
async def healthz() -> dict[str, object]:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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})
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"""A5 purity guard: `sim/` imports nothing that touches the outside world.
|
||||||
|
|
||||||
|
`assert_pure_imports()` statically parses every module in this package (AST,
|
||||||
|
so the pytest process's own `asyncio`/`socket` imports cannot poison the
|
||||||
|
result) and fails on any import rooted in `FORBIDDEN_ROOTS`. This module's own
|
||||||
|
file reads are the guard's single exception — they only ever run from tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
__all__ = ["FORBIDDEN_ROOTS", "PurityError", "assert_pure_imports", "find_violations"]
|
||||||
|
|
||||||
|
#: Root package names no `sim` module may import (PLAN.md A5).
|
||||||
|
FORBIDDEN_ROOTS: Final[frozenset[str]] = frozenset(
|
||||||
|
{"asyncio", "fastapi", "socket", "time", "uvicorn"}
|
||||||
|
)
|
||||||
|
|
||||||
|
SIM_DIR: Final[Path] = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
class PurityError(RuntimeError):
|
||||||
|
"""Raised when a `sim` module breaks the purity contract."""
|
||||||
|
|
||||||
|
|
||||||
|
def _forbidden_roots_in(tree: ast.Module) -> set[str]:
|
||||||
|
"""Forbidden import roots appearing anywhere in the module's import statements."""
|
||||||
|
found: set[str] = set()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
found.update(alias.name.split(".")[0] for alias in node.names)
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
|
||||||
|
found.add(node.module.split(".")[0])
|
||||||
|
return found & FORBIDDEN_ROOTS
|
||||||
|
|
||||||
|
|
||||||
|
def find_violations(package_dir: Path = SIM_DIR) -> list[str]:
|
||||||
|
"""`'<file>: imports <root>'` lines for every forbidden import in the package."""
|
||||||
|
violations: list[str] = []
|
||||||
|
for path in sorted(package_dir.rglob("*.py")):
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
violations.extend(
|
||||||
|
f"{path.relative_to(package_dir)}: imports {root}"
|
||||||
|
for root in sorted(_forbidden_roots_in(tree))
|
||||||
|
)
|
||||||
|
return violations
|
||||||
|
|
||||||
|
|
||||||
|
def assert_pure_imports(package_dir: Path = SIM_DIR) -> None:
|
||||||
|
"""Raise `PurityError` listing every forbidden import found in the package."""
|
||||||
|
violations = find_violations(package_dir)
|
||||||
|
if violations:
|
||||||
|
msg = "sim/ breaks the A5 purity contract: " + "; ".join(violations)
|
||||||
|
raise PurityError(msg)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Recipe content: ingredients, steps, and the recipe book (PLAN §5/§8, A8).
|
||||||
|
|
||||||
|
Purity contract (PLAN.md A5, enforced by `undercooked.sim.purity`): standard
|
||||||
|
library plus `pydantic`/`PyYAML` and `undercooked.sim` imports only — no
|
||||||
|
asyncio, no sockets, no wall-clock, no FastAPI. Parsing content files is this
|
||||||
|
pipeline's whole job: it happens once at load time and never during `step()`.
|
||||||
|
|
||||||
|
Every model here is strict — unknown keys are errors (`extra="forbid"`) and
|
||||||
|
assignment re-validates — so a typo in `levels/recipes.yaml` fails loudly at
|
||||||
|
startup instead of silently defaulting. `load_recipe_book` turns the YAML file
|
||||||
|
into a validated `RecipeBook`; `min_seconds_to_complete` is the greedy solver
|
||||||
|
bound used by the target-plausibility validator (task 03) and phase 11's curve
|
||||||
|
work. The schema reference players of this file write against is
|
||||||
|
`docs/LEVELS.md`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final, Literal
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
||||||
|
|
||||||
|
from undercooked.sim.model import ItemForm, ItemKind, StationKind
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HANDLING_S",
|
||||||
|
"ContentError",
|
||||||
|
"Ingredient",
|
||||||
|
"Recipe",
|
||||||
|
"RecipeBook",
|
||||||
|
"describe_validation_error",
|
||||||
|
"load_recipe_book",
|
||||||
|
"read_yaml_mapping",
|
||||||
|
]
|
||||||
|
|
||||||
|
#: Seconds of handling (pick up, put down, plate transfer) added once to every
|
||||||
|
#: dish by `RecipeBook.min_seconds_to_complete` — the solver's floor for work
|
||||||
|
#: the authored step list does not itself time.
|
||||||
|
HANDLING_S: Final = 0.6
|
||||||
|
|
||||||
|
|
||||||
|
class ContentError(ValueError):
|
||||||
|
"""Content that cannot load: missing file, bad YAML, schema violation, duplicates.
|
||||||
|
|
||||||
|
Raised by the whole content pipeline (`recipe.py`, `level.py`,
|
||||||
|
`content.py`). Its message lists *every* problem found in the run, never
|
||||||
|
just the first, because an author fixing content one error per cycle is a
|
||||||
|
slow author.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class StrictModel(BaseModel):
|
||||||
|
"""Base for every content model: no unknown keys, revalidate on assignment."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Ingredient(StrictModel):
|
||||||
|
"""One ingredient a recipe consumes: which item, in which form, how many."""
|
||||||
|
|
||||||
|
kind: ItemKind
|
||||||
|
form: ItemForm
|
||||||
|
count: int = Field(default=1, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class Step(StrictModel):
|
||||||
|
"""One authored action of a recipe (PLAN §5 station pipelines).
|
||||||
|
|
||||||
|
`kind` is the interaction, `station` where it happens, and `seconds` the
|
||||||
|
work time it costs (0.0 for instant interactions like `pick`/`serve`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
kind: Literal["pick", "chop", "cook", "plate", "serve", "wash"]
|
||||||
|
station: StationKind
|
||||||
|
seconds: float = Field(default=0.0, ge=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class Recipe(StrictModel):
|
||||||
|
"""A dish: what it eats, how it is made, and what it is worth."""
|
||||||
|
|
||||||
|
id: str = Field(min_length=1)
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
base_score: int = Field(ge=0)
|
||||||
|
ingredients: list[Ingredient] = Field(min_length=1)
|
||||||
|
steps: list[Step] = Field(min_length=1)
|
||||||
|
requires_plating: bool = True
|
||||||
|
tags: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeBook(StrictModel):
|
||||||
|
"""The parsed `levels/recipes.yaml`: every recipe, keyed by id.
|
||||||
|
|
||||||
|
The mapping key and `Recipe.id` must agree — the key is what level order
|
||||||
|
pools reference, and a silent mismatch would be a level nobody can cook.
|
||||||
|
`ingredients` is the optional top-level catalog of item kinds the content
|
||||||
|
uses; it documents intent and lets tooling catch unknown kinds early.
|
||||||
|
"""
|
||||||
|
|
||||||
|
recipes: dict[str, Recipe]
|
||||||
|
ingredients: list[ItemKind] = Field(default_factory=list)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _keys_match_ids(self) -> RecipeBook:
|
||||||
|
mismatched = sorted(
|
||||||
|
f"{key!r} names recipe {recipe.id!r}"
|
||||||
|
for key, recipe in self.recipes.items()
|
||||||
|
if recipe.id != key
|
||||||
|
)
|
||||||
|
if mismatched:
|
||||||
|
msg = "recipes mapping keys must equal each recipe's id: " + "; ".join(mismatched)
|
||||||
|
raise ValueError(msg)
|
||||||
|
return self
|
||||||
|
|
||||||
|
def get(self, recipe_id: str) -> Recipe:
|
||||||
|
"""The recipe with this id.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
KeyError: the id is not in the book (level pools must only name
|
||||||
|
recipes that exist; this is where that becomes loud).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self.recipes[recipe_id]
|
||||||
|
except KeyError:
|
||||||
|
known = ", ".join(sorted(self.recipes))
|
||||||
|
msg = f"unknown recipe {recipe_id!r} (known: {known})"
|
||||||
|
raise KeyError(msg) from None
|
||||||
|
|
||||||
|
def ids(self) -> tuple[str, ...]:
|
||||||
|
"""Every recipe id, in file (declaration) order."""
|
||||||
|
return tuple(self.recipes)
|
||||||
|
|
||||||
|
def required_station_kinds(self, recipe_id: str) -> frozenset[StationKind]:
|
||||||
|
"""Every station kind the recipe's steps must touch, deduplicated."""
|
||||||
|
return frozenset(step.station for step in self.get(recipe_id).steps)
|
||||||
|
|
||||||
|
def required_item_kinds(self, recipe_id: str) -> frozenset[ItemKind]:
|
||||||
|
"""Every item kind the dish consumes: its ingredients, plus `PLATE`
|
||||||
|
when `requires_plating` — a plated dish is not completable without one.
|
||||||
|
"""
|
||||||
|
recipe = self.get(recipe_id)
|
||||||
|
kinds = frozenset(ingredient.kind for ingredient in recipe.ingredients)
|
||||||
|
if recipe.requires_plating:
|
||||||
|
kinds |= {ItemKind.PLATE}
|
||||||
|
return kinds
|
||||||
|
|
||||||
|
def min_seconds_to_complete(self, recipe_id: str) -> float:
|
||||||
|
"""Greedy lower bound on cooking the dish: summed step seconds + `HANDLING_S`.
|
||||||
|
|
||||||
|
Never a promise (travel is the validator's job, task 03); it is the
|
||||||
|
per-order floor that keeps `target_score` plausibility checks honest.
|
||||||
|
"""
|
||||||
|
recipe = self.get(recipe_id)
|
||||||
|
return sum(step.seconds for step in recipe.steps) + HANDLING_S
|
||||||
|
|
||||||
|
|
||||||
|
def describe_validation_error(exc: ValidationError, source: str) -> str:
|
||||||
|
"""Flatten a pydantic error into one `<source>: <field.path>: <message>` per problem."""
|
||||||
|
parts: list[str] = []
|
||||||
|
for problem in exc.errors():
|
||||||
|
location = ".".join(str(part) for part in problem["loc"]) or "<root>"
|
||||||
|
parts.append(f"{source}: {location}: {problem['msg']}")
|
||||||
|
return "; ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def read_yaml_mapping(path: Path | str) -> dict[str, object]:
|
||||||
|
"""Read a content file as a mapping; every failure is a `ContentError`.
|
||||||
|
|
||||||
|
An empty file reads as `{}` so the model reports its own
|
||||||
|
"field required" errors against the same source path.
|
||||||
|
"""
|
||||||
|
file = Path(path)
|
||||||
|
try:
|
||||||
|
text = file.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
reason = exc.strerror or str(exc)
|
||||||
|
msg = f"{file}: cannot read content file: {reason}"
|
||||||
|
raise ContentError(msg) from exc
|
||||||
|
try:
|
||||||
|
data = yaml.safe_load(text)
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
msg = f"{file}: invalid YAML: {exc}"
|
||||||
|
raise ContentError(msg) from exc
|
||||||
|
if data is None:
|
||||||
|
return {}
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
msg = f"{file}: expected a YAML mapping at the top level, got {type(data).__name__}"
|
||||||
|
raise ContentError(msg)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def load_recipe_book(path: Path | str) -> RecipeBook:
|
||||||
|
"""Load and validate `levels/recipes.yaml` (or any replacement book).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ContentError: the file is missing, is not valid YAML, is not a mapping,
|
||||||
|
or violates the models above — with every field problem named.
|
||||||
|
"""
|
||||||
|
data = read_yaml_mapping(path)
|
||||||
|
try:
|
||||||
|
return RecipeBook.model_validate(data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ContentError(describe_validation_error(exc, str(path))) from exc
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
"""``undercooked-levels`` — the author-facing content CLI (PLAN §8, A8).
|
||||||
|
|
||||||
|
Everything here is a thin shell over the *same* code the server runs at startup:
|
||||||
|
`sim.content.load_content` (YAML -> campaign-ordered levels), `sim.validation`
|
||||||
|
(PLAN §8's named rules) and `server.routes_content.resolve_recipes_path` (the
|
||||||
|
recipe book's location). There is no second implementation of the content
|
||||||
|
contract, so a level that validates here validates in `create_app` — and the
|
||||||
|
tool never enumerates levels or recipes either: it prints whatever the loaded
|
||||||
|
bundle contains.
|
||||||
|
|
||||||
|
Subcommands (``--help`` for the full text):
|
||||||
|
|
||||||
|
``validate``
|
||||||
|
one line per level, then `ValidationReport.render()` (or `--json`), exit 1
|
||||||
|
on any violation. This is the gate `.agents/validate.sh` runs.
|
||||||
|
``list``
|
||||||
|
one line per level: order, id, difficulty, target, time, order pool.
|
||||||
|
``show ID``
|
||||||
|
the resolved level summary; ``--grid`` adds the ASCII map plus a
|
||||||
|
reachability overlay (``.`` reachable, ``?`` unreachable, ``#`` blocking).
|
||||||
|
``new ID``
|
||||||
|
writes a scaffolded level (built from the recipe book, never from a
|
||||||
|
hard-coded template kitchen) and validates that one file.
|
||||||
|
``axes``
|
||||||
|
the escalation-axis table for the whole campaign (feeds phase 11).
|
||||||
|
|
||||||
|
Exit codes: ``0`` success, ``1`` content violates at least one rule, ``2`` the
|
||||||
|
tool could not do its job (content would not load, an unknown level id, a file
|
||||||
|
`new` refuses to overwrite). argparse's own usage errors are ``2`` too.
|
||||||
|
|
||||||
|
Path resolution is `Settings`-driven, never cwd-driven: `--dir` beats
|
||||||
|
`UNDERCOOKED_LEVELS_DIR`, which beats the built-in ``levels`` — and a *relative*
|
||||||
|
path that does not exist under the current directory is anchored to this
|
||||||
|
checkout, so the command works from `/tmp` exactly as it does from the repo root
|
||||||
|
(the same trick `server.app.STATIC_DIR` plays for the browser assets).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
from collections import Counter
|
||||||
|
from collections.abc import Iterator, Mapping, Sequence
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from undercooked.config import Settings, load_settings
|
||||||
|
from undercooked.server.routes_content import resolve_recipes_path
|
||||||
|
from undercooked.sim.content import (
|
||||||
|
ContentBundle,
|
||||||
|
ContentError,
|
||||||
|
load_content,
|
||||||
|
load_level_file,
|
||||||
|
)
|
||||||
|
from undercooked.sim.level import Level
|
||||||
|
from undercooked.sim.model import ItemForm, ItemKind, StationKind
|
||||||
|
from undercooked.sim.recipe import RecipeBook
|
||||||
|
from undercooked.sim.validation import (
|
||||||
|
AXIS_NAMES,
|
||||||
|
LevelViolation,
|
||||||
|
ValidationReport,
|
||||||
|
escalation_axes,
|
||||||
|
reachable_cells,
|
||||||
|
validate_bundle,
|
||||||
|
validate_level,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"EXIT_OK",
|
||||||
|
"EXIT_UNUSABLE",
|
||||||
|
"EXIT_VIOLATIONS",
|
||||||
|
"build_parser",
|
||||||
|
"main",
|
||||||
|
"render_starter_yaml",
|
||||||
|
"report_json",
|
||||||
|
"resolve_content_paths",
|
||||||
|
]
|
||||||
|
|
||||||
|
EXIT_OK: Final = 0
|
||||||
|
#: Content exists but breaks a PLAN §8 rule (`validate`, `new`).
|
||||||
|
EXIT_VIOLATIONS: Final = 1
|
||||||
|
#: The tool could not act: load failure, unknown id, refused overwrite.
|
||||||
|
EXIT_UNUSABLE: Final = 2
|
||||||
|
|
||||||
|
#: This checkout's root (`src/undercooked/tools/levels.py` -> repo root), the
|
||||||
|
#: anchor for a relative content path when the current directory has none.
|
||||||
|
CHECKOUT_ROOT: Final = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
#: A new level id is a filename-safe slug: `NN_name` by campaign convention.
|
||||||
|
LEVEL_ID_PATTERN: Final = re.compile(r"^[a-z0-9][a-z0-9_]*$")
|
||||||
|
|
||||||
|
#: Column width of the `axes` table.
|
||||||
|
AXIS_WIDTH: Final = 20
|
||||||
|
|
||||||
|
#: Tunables of a scaffolded level: deliberately gentle, so the first thing an
|
||||||
|
#: author sees is their own content error and not a plausibility complaint.
|
||||||
|
STARTER_TIME_LIMIT_S: Final = 150
|
||||||
|
STARTER_TARGET_SCORE: Final = 60
|
||||||
|
STARTER_CONCURRENT: Final = 1
|
||||||
|
STARTER_SPAWN_INTERVAL: Final = (12.0, 16.0)
|
||||||
|
STARTER_DEADLINE_S: Final = 60
|
||||||
|
STARTER_HEIGHT: Final = 7
|
||||||
|
|
||||||
|
#: Grid letters the scaffold prefers per item kind (shelves stand on row 1).
|
||||||
|
_ITEM_CHARS: Final[Mapping[str, str]] = {
|
||||||
|
ItemKind.TOMATO.value: "T",
|
||||||
|
ItemKind.ONION.value: "O",
|
||||||
|
ItemKind.LETTUCE.value: "L",
|
||||||
|
ItemKind.RICE.value: "R",
|
||||||
|
ItemKind.FISH.value: "F",
|
||||||
|
ItemKind.DOUGH.value: "H",
|
||||||
|
ItemKind.PATTY.value: "Y",
|
||||||
|
ItemKind.PLATE.value: "E",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Grid letters the scaffold prefers per station kind (fixtures stand on row 4).
|
||||||
|
_STATION_CHARS: Final[Mapping[str, str]] = {
|
||||||
|
StationKind.CHOP_BOARD.value: "C",
|
||||||
|
StationKind.STOVE.value: "S",
|
||||||
|
StationKind.PREP.value: "P",
|
||||||
|
StationKind.SERVE.value: "X",
|
||||||
|
StationKind.SINK.value: "K",
|
||||||
|
StationKind.TRASH.value: "Z",
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Fallback letters, used when a preferred one is already taken.
|
||||||
|
_SPARE_CHARS: Final = "ABDGIJMNQUVW"
|
||||||
|
|
||||||
|
|
||||||
|
class _CliError(Exception):
|
||||||
|
"""A failure the CLI reports as one stderr line plus a fixed exit code."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, code: int = EXIT_UNUSABLE) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
# --- paths ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _anchor(path: Path) -> Path:
|
||||||
|
"""Make a content path absolute without trusting the current directory.
|
||||||
|
|
||||||
|
Absolute wins; else the current directory if the path is real there; else
|
||||||
|
this checkout (so the documented `undercooked-levels validate` works from
|
||||||
|
anywhere); else the path unchanged, so the error names what was configured.
|
||||||
|
"""
|
||||||
|
expanded = path.expanduser()
|
||||||
|
if expanded.is_absolute():
|
||||||
|
return expanded
|
||||||
|
from_cwd = Path.cwd() / expanded
|
||||||
|
if from_cwd.exists():
|
||||||
|
return from_cwd
|
||||||
|
anchored = CHECKOUT_ROOT / expanded
|
||||||
|
return anchored if anchored.exists() else expanded
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_content_paths(
|
||||||
|
settings: Settings,
|
||||||
|
levels_override: Path | None = None,
|
||||||
|
recipes_override: Path | None = None,
|
||||||
|
) -> tuple[Path, Path]:
|
||||||
|
"""Resolve ``(levels_dir, recipes_path)``: flag > environment > derived.
|
||||||
|
|
||||||
|
The derived half is `server.routes_content.resolve_recipes_path` — the same
|
||||||
|
resolution `create_app` uses — so the CLI and the server can never look for
|
||||||
|
`recipes.yaml` in two different places.
|
||||||
|
"""
|
||||||
|
levels_dir = _anchor(levels_override if levels_override is not None else settings.levels_dir)
|
||||||
|
recipes_path = resolve_recipes_path(replace(settings, levels_dir=levels_dir))
|
||||||
|
if recipes_override is not None:
|
||||||
|
recipes_path = _anchor(recipes_override)
|
||||||
|
return levels_dir, recipes_path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_bundle(levels_dir: Path, recipes_path: Path) -> ContentBundle:
|
||||||
|
"""`load_content` with the load failure turned into a reported exit 2."""
|
||||||
|
try:
|
||||||
|
return load_content(levels_dir, recipes_path)
|
||||||
|
except ContentError as exc:
|
||||||
|
raise _CliError(str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle_for(args: argparse.Namespace) -> ContentBundle:
|
||||||
|
levels_dir, recipes_path = resolve_content_paths(load_settings(), args.dir, args.recipes)
|
||||||
|
return _load_bundle(levels_dir, recipes_path)
|
||||||
|
|
||||||
|
|
||||||
|
# --- shared rendering -----------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _sorted_items(items: Sequence[LevelViolation]) -> tuple[LevelViolation, ...]:
|
||||||
|
"""Deterministic order for JSON output (the text report sorts in `render`)."""
|
||||||
|
return tuple(sorted(items, key=lambda item: (item.level_id, item.rule, item.message)))
|
||||||
|
|
||||||
|
|
||||||
|
def _field(label: str, values: Sequence[str]) -> Iterator[str]:
|
||||||
|
"""One aligned ``label value`` line per value (labels are 17 columns wide)."""
|
||||||
|
for index, value in enumerate(values):
|
||||||
|
yield f"{label:<18}{value}" if index == 0 else f"{'':<18}{value}"
|
||||||
|
|
||||||
|
|
||||||
|
def _wrapped(text: str, *, placeholder: str) -> list[str]:
|
||||||
|
"""Author prose, wrapped; an empty field says so instead of printing nothing."""
|
||||||
|
return textwrap.wrap(text, width=76) or [placeholder]
|
||||||
|
|
||||||
|
|
||||||
|
def _station_summary(level: Level) -> str:
|
||||||
|
counts = Counter(station.kind.value for station in level.stations)
|
||||||
|
return ", ".join(f"{kind} x{counts[kind]}" for kind in sorted(counts)) or "none"
|
||||||
|
|
||||||
|
|
||||||
|
def _hazard_summary(level: Level) -> str:
|
||||||
|
counts = Counter(hazard.type.value for hazard in level.hazards)
|
||||||
|
return ", ".join(f"{kind} x{counts[kind]}" for kind in sorted(counts)) or "none"
|
||||||
|
|
||||||
|
|
||||||
|
def _recipe_line(book: RecipeBook, recipe_id: str) -> str:
|
||||||
|
"""One order-pool entry resolved against the book (or its absence)."""
|
||||||
|
recipe = book.recipes.get(recipe_id)
|
||||||
|
if recipe is None:
|
||||||
|
return f"{recipe_id}: MISSING from the recipe book"
|
||||||
|
kinds = ", ".join(sorted(kind.value for kind in book.required_station_kinds(recipe_id)))
|
||||||
|
return f"{recipe_id}: {recipe.name}, {recipe.base_score} pts, needs {kinds}"
|
||||||
|
|
||||||
|
|
||||||
|
def describe_level(level: Level, book: RecipeBook) -> list[str]:
|
||||||
|
"""The `show` summary: the resolved level, one labelled line per fact."""
|
||||||
|
orders = level.orders
|
||||||
|
low, high = orders.spawn_interval
|
||||||
|
reachable = len(reachable_cells(level))
|
||||||
|
walkable = len(level.grid.walkable_cells())
|
||||||
|
limits = level.station_limits
|
||||||
|
facts: list[tuple[str, list[str]]] = [
|
||||||
|
("order", [str(level.order)]),
|
||||||
|
("difficulty", [str(level.difficulty)]),
|
||||||
|
("time_limit", [f"{level.time_limit:g} s"]),
|
||||||
|
("target_score", [str(level.target_score)]),
|
||||||
|
("grid", [f"{level.grid.width}x{level.grid.height}"]),
|
||||||
|
("spawns", [", ".join(f"({x}, {y})" for x, y in level.spawns) or "none"]),
|
||||||
|
("stations", [_station_summary(level)]),
|
||||||
|
(
|
||||||
|
"orders",
|
||||||
|
[
|
||||||
|
f"pool={','.join(orders.pool)} concurrent={orders.concurrent} "
|
||||||
|
f"interval={low:g}-{high:g}s deadline={orders.deadline:g}s "
|
||||||
|
f"penalty={orders.penalty}"
|
||||||
|
],
|
||||||
|
),
|
||||||
|
("limits", [f"plates={limits.plates} pots={limits.pots}"]),
|
||||||
|
("hazards", [_hazard_summary(level)]),
|
||||||
|
(
|
||||||
|
"reachability",
|
||||||
|
[f"{reachable}/{walkable} walkable tiles reachable from {len(level.spawns)} spawn(s)"],
|
||||||
|
),
|
||||||
|
("recipes", [_recipe_line(book, recipe_id) for recipe_id in dict.fromkeys(orders.pool)]),
|
||||||
|
("blurb", _wrapped(level.blurb, placeholder="(none)")),
|
||||||
|
("escalation_notes", _wrapped(level.escalation_notes, placeholder="(none)")),
|
||||||
|
]
|
||||||
|
lines = [f"{level.id} - {level.name}"]
|
||||||
|
for label, values in facts:
|
||||||
|
lines.extend(_field(label, values))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def reachability_overlay(level: Level) -> list[str]:
|
||||||
|
"""The grid re-drawn as terrain: ``.``, ``?`` (walkable, unreachable), ``#``."""
|
||||||
|
grid = level.grid
|
||||||
|
reachable = reachable_cells(level)
|
||||||
|
return [
|
||||||
|
"".join(
|
||||||
|
"#"
|
||||||
|
if not grid.is_walkable(x, y)
|
||||||
|
else "."
|
||||||
|
if y * grid.width + x in reachable
|
||||||
|
else "?"
|
||||||
|
for x in range(grid.width)
|
||||||
|
)
|
||||||
|
for y in range(grid.height)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _grid_lines(level: Level) -> list[str]:
|
||||||
|
"""The authored ASCII map plus the overlay under it."""
|
||||||
|
overlay = reachability_overlay(level)
|
||||||
|
reachable = sum(row.count(".") for row in overlay)
|
||||||
|
walkable = sum(row.count(c) for row in overlay for c in ".?")
|
||||||
|
return [
|
||||||
|
"",
|
||||||
|
"map (as authored)",
|
||||||
|
*(f" {row}" for row in level.spec.grid),
|
||||||
|
"",
|
||||||
|
"reachability . reachable ? walkable but cut off # blocking",
|
||||||
|
*(f" {row}" for row in overlay),
|
||||||
|
"",
|
||||||
|
f" {reachable}/{walkable} walkable tiles are reachable from the spawns",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- validate -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def report_json(report: ValidationReport, bundle: ContentBundle) -> dict[str, object]:
|
||||||
|
"""The machine-readable report: `errors`/`warnings` lists plus per level.
|
||||||
|
|
||||||
|
Key names are part of the tool's contract with scripts
|
||||||
|
(`undercooked-levels validate --json | python -m json.tool`): `errors` and
|
||||||
|
`warnings` are the rule hits themselves — `"errors": []` means green — and
|
||||||
|
`counts` keeps the totals readable without counting list entries.
|
||||||
|
"""
|
||||||
|
violations = _sorted_items(report.violations)
|
||||||
|
warnings = _sorted_items(report.warnings)
|
||||||
|
return {
|
||||||
|
"ok": report.ok,
|
||||||
|
"counts": {"errors": len(violations), "warnings": len(warnings)},
|
||||||
|
"levels": [
|
||||||
|
{
|
||||||
|
"id": level.id,
|
||||||
|
"order": level.order,
|
||||||
|
"difficulty": level.difficulty,
|
||||||
|
"errors": len(report.errors_for(level.id)),
|
||||||
|
"warnings": sum(1 for w in report.warnings if w.level_id == level.id),
|
||||||
|
}
|
||||||
|
for level in bundle.levels
|
||||||
|
],
|
||||||
|
"errors": [_violation_json(violation) for violation in violations],
|
||||||
|
"warnings": [_violation_json(warning) for warning in warnings],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _violation_json(violation: LevelViolation) -> dict[str, object]:
|
||||||
|
"""One rule hit as stable JSON (tuples in `detail` become arrays)."""
|
||||||
|
return {
|
||||||
|
"level": violation.level_id,
|
||||||
|
"rule": violation.rule,
|
||||||
|
"message": violation.message,
|
||||||
|
"detail": dict(violation.detail),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _level_status(level: Level, report: ValidationReport) -> str:
|
||||||
|
errors = len(report.errors_for(level.id))
|
||||||
|
warnings = sum(1 for warning in report.warnings if warning.level_id == level.id)
|
||||||
|
verdict = "ok" if not errors else f"{errors} error(s)"
|
||||||
|
if warnings:
|
||||||
|
verdict += f", {warnings} warning(s)"
|
||||||
|
geometry = f"{level.grid.width}x{level.grid.height}, {len(level.stations)} stations"
|
||||||
|
return f"{level.id:<28} {verdict:<24} {geometry}"
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_validate(args: argparse.Namespace) -> int:
|
||||||
|
bundle = _bundle_for(args)
|
||||||
|
report = validate_bundle(bundle)
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(report_json(report, bundle), indent=2, sort_keys=True))
|
||||||
|
else:
|
||||||
|
for level in bundle.levels:
|
||||||
|
print(_level_status(level, report))
|
||||||
|
print(report.render())
|
||||||
|
return EXIT_OK if report.ok else EXIT_VIOLATIONS
|
||||||
|
|
||||||
|
|
||||||
|
# --- list -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
LIST_HEADER: Final = f"{'ORDER':>5} {'ID':<28}{'DIFF':>4} {'TARGET':>6} {'TIME':>6} RECIPES"
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_list(args: argparse.Namespace) -> int:
|
||||||
|
bundle = _bundle_for(args)
|
||||||
|
print(LIST_HEADER)
|
||||||
|
for level in bundle.levels:
|
||||||
|
print(
|
||||||
|
f"{level.order:>5} {level.id:<28}{level.difficulty:>4} "
|
||||||
|
f"{level.target_score:>6} {level.time_limit:>6.1f} {', '.join(level.orders.pool)}"
|
||||||
|
)
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
# --- show -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_show(args: argparse.Namespace) -> int:
|
||||||
|
bundle = _bundle_for(args)
|
||||||
|
level = bundle.by_id.get(args.id)
|
||||||
|
if level is None:
|
||||||
|
known = ", ".join(sorted(bundle.by_id)) or "(no levels loaded)"
|
||||||
|
raise _CliError(f"no level {args.id!r} in this campaign; known ids: {known}")
|
||||||
|
print("\n".join(describe_level(level, bundle.recipe_book)))
|
||||||
|
if args.grid:
|
||||||
|
print("\n".join(_grid_lines(level)))
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
# --- axes -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _axes_header() -> str:
|
||||||
|
return f"{'LEVEL':<28}{'ORDER':>6}" + "".join(f"{name:>{AXIS_WIDTH}}" for name in AXIS_NAMES)
|
||||||
|
|
||||||
|
|
||||||
|
def _axes_row(level: Level) -> str:
|
||||||
|
axes = escalation_axes(level)
|
||||||
|
cells = "".join(f"{axes[name]:>{AXIS_WIDTH}.4f}" for name in AXIS_NAMES)
|
||||||
|
return f"{level.id:<28}{level.order:>6}{cells}"
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_axes(args: argparse.Namespace) -> int:
|
||||||
|
bundle = _bundle_for(args)
|
||||||
|
print(_axes_header())
|
||||||
|
for level in bundle.levels:
|
||||||
|
print(_axes_row(level))
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
# --- new ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _char_for(preferred: str | None, used: set[str]) -> str:
|
||||||
|
"""The preferred grid letter when it is free, else the first free spare."""
|
||||||
|
if preferred is not None and preferred not in used:
|
||||||
|
return preferred
|
||||||
|
return next(char for char in _SPARE_CHARS if char not in used)
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_chars(keys: Sequence[str], table: Mapping[str, str], used: set[str]) -> dict[str, str]:
|
||||||
|
"""One unique grid character per key, deterministic in the key order given."""
|
||||||
|
assigned: dict[str, str] = {}
|
||||||
|
for key in keys:
|
||||||
|
char = _char_for(table.get(key), used)
|
||||||
|
used.add(char)
|
||||||
|
assigned[key] = char
|
||||||
|
return assigned
|
||||||
|
|
||||||
|
|
||||||
|
def _display_name(level_id: str) -> str:
|
||||||
|
"""`07_riverboat` -> `Riverboat`; the author edits it immediately anyway."""
|
||||||
|
return level_id.split("_", 1)[-1].replace("_", " ").title()
|
||||||
|
|
||||||
|
|
||||||
|
def _starter_content(
|
||||||
|
book: RecipeBook,
|
||||||
|
) -> tuple[str, list[ItemKind], list[StationKind]]:
|
||||||
|
"""(recipe id, raw ingredients to shelf, station kinds to place) for the scaffold.
|
||||||
|
|
||||||
|
The recipe book decides the template kitchen — the first dish it defines and
|
||||||
|
exactly the stations that dish's steps name — because nothing in Python may
|
||||||
|
enumerate content (A8).
|
||||||
|
"""
|
||||||
|
recipe_ids = book.ids()
|
||||||
|
if not recipe_ids:
|
||||||
|
raise _CliError("the recipe book defines no recipes, so there is nothing to scaffold")
|
||||||
|
recipe_id = recipe_ids[0]
|
||||||
|
recipe = book.get(recipe_id)
|
||||||
|
items = list(dict.fromkeys(
|
||||||
|
ingredient.kind
|
||||||
|
for ingredient in recipe.ingredients
|
||||||
|
if ingredient.form is ItemForm.RAW
|
||||||
|
))
|
||||||
|
required = book.required_station_kinds(recipe_id) | {StationKind.SERVE}
|
||||||
|
kinds = sorted(required - {StationKind.SPAWN_SHELF}, key=lambda kind: kind.value)
|
||||||
|
return recipe_id, items, kinds
|
||||||
|
|
||||||
|
|
||||||
|
def render_starter_yaml(*, level_id: str, order: int, difficulty: int, book: RecipeBook) -> str:
|
||||||
|
"""The `new` template: a valid, minimal kitchen for the book's first dish.
|
||||||
|
|
||||||
|
Geometry is generated, not pasted: one spawn shelf per raw ingredient on
|
||||||
|
row 1, one station per required kind on row 4, and two spawns on the bottom
|
||||||
|
row far enough from the serve window to clear the `TRAVEL_TOO_SHORT` floor.
|
||||||
|
"""
|
||||||
|
recipe_id, items, kinds = _starter_content(book)
|
||||||
|
used = {"#", "."}
|
||||||
|
shelf_chars = _assign_chars([item.value for item in items], _ITEM_CHARS, used)
|
||||||
|
station_chars = _assign_chars([kind.value for kind in kinds], _STATION_CHARS, used)
|
||||||
|
width = max(13, 2 + 2 * max(len(items), len(kinds), 1))
|
||||||
|
rows = [["."] * width for _ in range(STARTER_HEIGHT)]
|
||||||
|
for index in range(width):
|
||||||
|
rows[0][index] = rows[STARTER_HEIGHT - 1][index] = "#"
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
rows[1][2 + 2 * index] = shelf_chars[item.value]
|
||||||
|
for index, kind in enumerate(kinds):
|
||||||
|
rows[4][2 + 2 * index] = station_chars[kind.value]
|
||||||
|
spawns = [(1, STARTER_HEIGHT - 2), (width - 2, STARTER_HEIGHT - 2)]
|
||||||
|
|
||||||
|
tiles: dict[str, dict[str, str]] = {"#": {"kind": "wall"}, ".": {"kind": "floor"}}
|
||||||
|
for item in items:
|
||||||
|
tiles[shelf_chars[item.value]] = {
|
||||||
|
"kind": "floor",
|
||||||
|
"station": StationKind.SPAWN_SHELF.value,
|
||||||
|
"item": item.value,
|
||||||
|
}
|
||||||
|
for kind in kinds:
|
||||||
|
tiles[station_chars[kind.value]] = {"kind": "floor", "station": kind.value}
|
||||||
|
|
||||||
|
low, high = STARTER_SPAWN_INTERVAL
|
||||||
|
return "\n".join([
|
||||||
|
f"# levels/{level_id}.yaml — scaffolded by `undercooked-levels new`.",
|
||||||
|
"# Schema, every field and every rule: docs/LEVELS.md.",
|
||||||
|
"# Next steps: edit the grid and the prose, then re-run",
|
||||||
|
"# uv run undercooked-levels validate --dir <this directory>",
|
||||||
|
"# uv run undercooked-levels axes --dir <this directory>",
|
||||||
|
"# The campaign rule (DIFFICULTY_ORDER) wants >= 2 escalation axes up",
|
||||||
|
"# versus the previous level, and a difficulty that never drops.",
|
||||||
|
"",
|
||||||
|
f"id: {level_id}",
|
||||||
|
f"name: {_display_name(level_id)}",
|
||||||
|
f"order: {order}",
|
||||||
|
f"difficulty: {difficulty}",
|
||||||
|
f"time_limit: {STARTER_TIME_LIMIT_S}",
|
||||||
|
f"target_score: {STARTER_TARGET_SCORE}",
|
||||||
|
"blurb: >-",
|
||||||
|
f" A scaffolded kitchen around one dish ({recipe_id}). Rewrite this",
|
||||||
|
" paragraph for the level a player will actually read.",
|
||||||
|
"grid:",
|
||||||
|
*(f' - "{"".join(row)}"' for row in rows),
|
||||||
|
"tiles:",
|
||||||
|
*(f' "{char}": {json.dumps(spec)}' for char, spec in tiles.items()),
|
||||||
|
f"spawns: [{', '.join(f'[{x}, {y}]' for x, y in spawns)}]",
|
||||||
|
"orders:",
|
||||||
|
f" pool: [{recipe_id}]",
|
||||||
|
f" concurrent: {STARTER_CONCURRENT}",
|
||||||
|
f" spawn_interval: [{low:g}, {high:g}]",
|
||||||
|
f" deadline: {STARTER_DEADLINE_S}",
|
||||||
|
"hazards: []",
|
||||||
|
"escalation_notes: >-",
|
||||||
|
" TODO: name the axes this level moves up versus its predecessor.",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _new_level_id(candidate: str) -> str:
|
||||||
|
if not LEVEL_ID_PATTERN.match(candidate):
|
||||||
|
raise _CliError(
|
||||||
|
f"level id {candidate!r} is not a filename-safe slug "
|
||||||
|
"(lowercase letters, digits and underscores, starting with a letter or digit)"
|
||||||
|
)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_new(args: argparse.Namespace) -> int:
|
||||||
|
settings = load_settings()
|
||||||
|
levels_dir, recipes_path = resolve_content_paths(settings, args.dir, args.recipes)
|
||||||
|
level_id = _new_level_id(args.id)
|
||||||
|
if args.order is not None and args.order < 1:
|
||||||
|
raise _CliError(f"--order must be at least 1, got {args.order}")
|
||||||
|
bundle = _load_bundle(levels_dir, recipes_path)
|
||||||
|
target = levels_dir / f"{level_id}.yaml"
|
||||||
|
if target.exists():
|
||||||
|
raise _CliError(f"{target} already exists; refusing to overwrite an author's level")
|
||||||
|
orders = [level.order for level in bundle.levels]
|
||||||
|
difficulties = [level.difficulty for level in bundle.levels]
|
||||||
|
order = args.order if args.order is not None else max(orders, default=0) + 1
|
||||||
|
difficulty = min(10, max(difficulties, default=0) + 1)
|
||||||
|
text = render_starter_yaml(
|
||||||
|
level_id=level_id,
|
||||||
|
order=order,
|
||||||
|
difficulty=difficulty,
|
||||||
|
book=bundle.recipe_book,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
target.write_text(text, encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
reason = exc.strerror or str(exc)
|
||||||
|
raise _CliError(f"cannot write {target}: {reason}") from exc
|
||||||
|
print(f"wrote {target}")
|
||||||
|
try:
|
||||||
|
level = load_level_file(target)
|
||||||
|
except ContentError as exc:
|
||||||
|
raise _CliError(str(exc)) from exc
|
||||||
|
# The new file on its own: the campaign's escalation rule needs neighbours,
|
||||||
|
# and a scaffolded level is not tuned against them yet by definition.
|
||||||
|
report = validate_level(level, bundle.recipe_book)
|
||||||
|
print(report.render())
|
||||||
|
return EXIT_OK if report.ok else EXIT_VIOLATIONS
|
||||||
|
|
||||||
|
|
||||||
|
# --- entry point -----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _add_content_flags(parser: argparse.ArgumentParser) -> None:
|
||||||
|
"""The `--dir` / `--recipes` pair every subcommand needs to find content."""
|
||||||
|
parser.add_argument(
|
||||||
|
"--dir",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
metavar="PATH",
|
||||||
|
help="levels directory (default: $UNDERCOOKED_LEVELS_DIR, else levels/)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--recipes",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
metavar="PATH",
|
||||||
|
help="recipe book (default: $UNDERCOOKED_RECIPES_PATH, else <dir>/recipes.yaml)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
"""The subcommand surface of `undercooked-levels`."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="undercooked-levels",
|
||||||
|
description=(
|
||||||
|
"Inspect and validate Undercooked level content: the same loader and "
|
||||||
|
"the same PLAN §8 rules the server runs at startup."
|
||||||
|
),
|
||||||
|
epilog=(
|
||||||
|
"exit codes: 0 ok, 1 a level breaks a validation rule, "
|
||||||
|
"2 nothing usable to work with (load failure, unknown id, refused overwrite)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
subcommands = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
|
||||||
|
|
||||||
|
validate = subcommands.add_parser(
|
||||||
|
"validate",
|
||||||
|
help="validate every level and print the named-rule report",
|
||||||
|
description="Validate the campaign: one line per level, then the rule report.",
|
||||||
|
)
|
||||||
|
_add_content_flags(validate)
|
||||||
|
validate.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="emit a machine-readable report instead of text",
|
||||||
|
)
|
||||||
|
validate.set_defaults(handler=_cmd_validate)
|
||||||
|
|
||||||
|
listing = subcommands.add_parser(
|
||||||
|
"list",
|
||||||
|
help="one line per level, in campaign order",
|
||||||
|
description="List the campaign: order, id, difficulty, target, time, order pool.",
|
||||||
|
)
|
||||||
|
_add_content_flags(listing)
|
||||||
|
listing.set_defaults(handler=_cmd_list)
|
||||||
|
|
||||||
|
show = subcommands.add_parser(
|
||||||
|
"show",
|
||||||
|
help="resolved summary of one level",
|
||||||
|
description="Show one resolved level; --grid adds the ASCII map and overlay.",
|
||||||
|
)
|
||||||
|
show.add_argument("id", help="level id to inspect (see the `list` subcommand)")
|
||||||
|
show.add_argument(
|
||||||
|
"--grid",
|
||||||
|
action="store_true",
|
||||||
|
help="print the ASCII map plus the reachability overlay",
|
||||||
|
)
|
||||||
|
_add_content_flags(show)
|
||||||
|
show.set_defaults(handler=_cmd_show)
|
||||||
|
|
||||||
|
new = subcommands.add_parser(
|
||||||
|
"new",
|
||||||
|
help="scaffold a starter level into the levels directory",
|
||||||
|
description="Write a starter level (from the recipe book) and validate that file.",
|
||||||
|
)
|
||||||
|
new.add_argument("id", help="new level id, e.g. 03_riverboat")
|
||||||
|
new.add_argument(
|
||||||
|
"--order",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
metavar="N",
|
||||||
|
help="campaign order (default: one past the highest in the directory)",
|
||||||
|
)
|
||||||
|
_add_content_flags(new)
|
||||||
|
new.set_defaults(handler=_cmd_new)
|
||||||
|
|
||||||
|
axes = subcommands.add_parser(
|
||||||
|
"axes",
|
||||||
|
help="escalation-axis table for the whole campaign",
|
||||||
|
description="Print every level's escalation axes (higher = harder), campaign order.",
|
||||||
|
)
|
||||||
|
_add_content_flags(axes)
|
||||||
|
axes.set_defaults(handler=_cmd_axes)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
|
"""Entry point of the `undercooked-levels` console script."""
|
||||||
|
try:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
except SystemExit as exc: # usage error -> 2, `--help` -> 0
|
||||||
|
return int(exc.code or EXIT_OK)
|
||||||
|
handler = args.handler
|
||||||
|
try:
|
||||||
|
return int(handler(args))
|
||||||
|
except _CliError as exc:
|
||||||
|
print(f"undercooked-levels: {exc}", file=sys.stderr)
|
||||||
|
return exc.code
|
||||||
|
|
||||||
|
|
||||||
|
# `python -m undercooked.tools.levels` runs this; coverage measures the parent
|
||||||
|
# process only (tests/unit/test_levels_cli.py drives `main` directly).
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
raise SystemExit(main())
|
||||||
+6
-3
@@ -42,10 +42,13 @@ def _as_env_value(value: object) -> str:
|
|||||||
|
|
||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
def isolated_levels_dir() -> Iterator[None]:
|
def isolated_levels_dir() -> Iterator[None]:
|
||||||
"""Point `UNDERCOOKED_LEVELS_DIR` at an empty fixture dir for every test.
|
"""Point `UNDERCOOKED_LEVELS_DIR` at this level-free fixture dir for every test.
|
||||||
|
|
||||||
No test may read the real `levels/` content implicitly; the shipped
|
No test may read the real `levels/` content implicitly. Since phase 02
|
||||||
content pipeline only arrives with phase 02.
|
task 04 `create_app()` also needs a recipe book, and the dir carries a
|
||||||
|
committed minimal `recipes.yaml` (zero levels — a legal campaign) so
|
||||||
|
fixture-isolated apps still boot; tests that need real levels build one
|
||||||
|
under `tmp_path` or point explicitly at `levels/`.
|
||||||
"""
|
"""
|
||||||
FIXTURE_LEVELS_DIR.mkdir(parents=True, exist_ok=True)
|
FIXTURE_LEVELS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
previous = os.environ.get("UNDERCOOKED_LEVELS_DIR")
|
previous = os.environ.get("UNDERCOOKED_LEVELS_DIR")
|
||||||
|
|||||||
+46
@@ -0,0 +1,46 @@
|
|||||||
|
# A deliberately broken level for `undercooked-levels validate` (phase 02 task 05).
|
||||||
|
#
|
||||||
|
# It *loads* (the schema is satisfied) and then breaks three PLAN §8 rules, so
|
||||||
|
# the CLI's exit-1 path, its named-rule report and the `show --grid` "?" overlay
|
||||||
|
# all have a stable input to be tested against:
|
||||||
|
#
|
||||||
|
# * the right-hand box (x6..x9, y1..y2) is sealed on all four sides, so the
|
||||||
|
# serve window inside it is cut off from the spawn -> SERVE_REACHABLE, and
|
||||||
|
# those same cells show up as `?` in `show --grid`;
|
||||||
|
# * the recipe's steps name a stove this kitchen does not have
|
||||||
|
# -> RECIPE_STATION_MISSING;
|
||||||
|
# * target_score 999 is far past 60 % of the theoretical maximum
|
||||||
|
# -> TARGET_IMPLAUSIBLE.
|
||||||
|
#
|
||||||
|
# The `_bad` directory name opts out of content discovery (`_is_draft`), so this
|
||||||
|
# campaign is only loaded when something points `--dir` straight at it — the
|
||||||
|
# server and the rest of the test suite never see it.
|
||||||
|
id: 99_broken_room
|
||||||
|
name: Broken Room
|
||||||
|
order: 1
|
||||||
|
difficulty: 5
|
||||||
|
time_limit: 60
|
||||||
|
target_score: 999
|
||||||
|
blurb: A kitchen with a sealed-off servery, the fixture for the validator itself.
|
||||||
|
grid:
|
||||||
|
- "###########"
|
||||||
|
- "#T..C#..V.#"
|
||||||
|
- "#....#....#"
|
||||||
|
- "#.#########"
|
||||||
|
- "#S........#"
|
||||||
|
- "###########"
|
||||||
|
tiles:
|
||||||
|
"#": {kind: wall}
|
||||||
|
".": {kind: floor}
|
||||||
|
"T": {kind: floor, station: spawn_shelf, item: tomato}
|
||||||
|
"C": {kind: floor, station: chop_board}
|
||||||
|
"V": {kind: floor, station: serve}
|
||||||
|
"S": {kind: floor, station: spawn_shelf, item: onion}
|
||||||
|
spawns: [[1, 4]]
|
||||||
|
orders:
|
||||||
|
pool: [soup]
|
||||||
|
concurrent: 1
|
||||||
|
spawn_interval: [12, 16]
|
||||||
|
deadline: 60
|
||||||
|
hazards: []
|
||||||
|
escalation_notes: Fixture only, never shipped and never part of a real campaign.
|
||||||
+23
@@ -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}
|
||||||
Vendored
+18
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -204,9 +204,14 @@ def test_no_doc_file_exists_that_nothing_promises() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_the_docs_on_disk_are_the_three_phase_01_documents() -> None:
|
def test_the_docs_on_disk_are_the_shipped_documents() -> None:
|
||||||
"""Pins today's tree so that adding a document is a deliberate, named act."""
|
"""Pins today's tree so that adding a document is a deliberate, named act."""
|
||||||
assert written_docs() == {"docs/DESIGN.md", "docs/DEPLOYMENT.md", "docs/TESTING.md"}
|
assert written_docs() == {
|
||||||
|
"docs/DESIGN.md",
|
||||||
|
"docs/DEPLOYMENT.md",
|
||||||
|
"docs/LEVELS.md",
|
||||||
|
"docs/TESTING.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def stub_without_owner(relative: str, text: str) -> str | None:
|
def stub_without_owner(relative: str, text: str) -> str | None:
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ def test_fixture_levels_dir_exists(isolated_levels_dir: None) -> None:
|
|||||||
levels_dir = Path(os.environ["UNDERCOOKED_LEVELS_DIR"])
|
levels_dir = Path(os.environ["UNDERCOOKED_LEVELS_DIR"])
|
||||||
assert levels_dir == REPO_ROOT / "tests" / "fixtures" / "levels"
|
assert levels_dir == REPO_ROOT / "tests" / "fixtures" / "levels"
|
||||||
assert levels_dir.is_dir()
|
assert levels_dir.is_dir()
|
||||||
assert not list(levels_dir.glob("*.yaml"))
|
# Hermeticity is about *levels*: a default test must never see the shipped
|
||||||
|
# campaign. The committed `recipes.yaml` is not one (the loader never treats
|
||||||
|
# it as a level) — startup has required a recipe book since phase 02 task 04,
|
||||||
|
# and this minimal one is what lets fixture-isolated apps boot at all.
|
||||||
|
levels = [p.name for p in levels_dir.glob("*.yaml") if p.name != "recipes.yaml"]
|
||||||
|
assert not levels
|
||||||
|
|
||||||
|
|
||||||
def test_e2e_tests_follow_the_story_naming_convention() -> None:
|
def test_e2e_tests_follow_the_story_naming_convention() -> None:
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""docs/LEVELS.md must document exactly the fields the models have.
|
||||||
|
|
||||||
|
The doc is the content author's contract; the Pydantic models are its
|
||||||
|
executable form. This test parses every ``### <Model>`` section's *Field*
|
||||||
|
table (the first table whose header row starts with ``Field``, so the
|
||||||
|
HazardSpec placement table is not mistaken for a field list) and requires the
|
||||||
|
back-quoted field names to equal ``model_fields`` — no undocumented field,
|
||||||
|
no documented ghost. A planted-drift case proves the extraction can fail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from undercooked.sim.level import (
|
||||||
|
HazardSpec,
|
||||||
|
LevelDef,
|
||||||
|
OrdersSpec,
|
||||||
|
StationLimits,
|
||||||
|
TileSpec,
|
||||||
|
)
|
||||||
|
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
DOC_PATH = Path(__file__).resolve().parents[2] / "docs" / "LEVELS.md"
|
||||||
|
|
||||||
|
MODELS: tuple[type[BaseModel], ...] = (
|
||||||
|
Ingredient,
|
||||||
|
Step,
|
||||||
|
Recipe,
|
||||||
|
RecipeBook,
|
||||||
|
TileSpec,
|
||||||
|
OrdersSpec,
|
||||||
|
HazardSpec,
|
||||||
|
StationLimits,
|
||||||
|
LevelDef,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _section(lines: list[str], model_name: str) -> list[str]:
|
||||||
|
"""Lines of the `### model_name` section, up to the next heading."""
|
||||||
|
heading = f"### {model_name}"
|
||||||
|
collected: list[str] = []
|
||||||
|
in_section = False
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith("#"):
|
||||||
|
if in_section:
|
||||||
|
break
|
||||||
|
in_section = line.strip() == heading
|
||||||
|
continue
|
||||||
|
if in_section:
|
||||||
|
collected.append(line)
|
||||||
|
return collected
|
||||||
|
|
||||||
|
|
||||||
|
def _table_fields(section: list[str]) -> set[str]:
|
||||||
|
"""Back-quoted first-column entries of the section's `Field` table."""
|
||||||
|
start = None
|
||||||
|
for index, line in enumerate(section):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("|") and stripped.strip("|").split("|")[0].strip() == "Field":
|
||||||
|
start = index
|
||||||
|
break
|
||||||
|
if start is None:
|
||||||
|
return set()
|
||||||
|
fields: set[str] = set()
|
||||||
|
for line in section[start + 2 :]: # skip header + |---| separator
|
||||||
|
stripped = line.strip()
|
||||||
|
if not stripped.startswith("|"):
|
||||||
|
break
|
||||||
|
first = stripped.strip("|").split("|")[0].strip()
|
||||||
|
if len(first) > 2 and first.startswith("`") and first.endswith("`"):
|
||||||
|
fields.add(first[1:-1])
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def doc_lines() -> list[str]:
|
||||||
|
assert DOC_PATH.is_file(), f"{DOC_PATH} is missing"
|
||||||
|
return DOC_PATH.read_text(encoding="utf-8").splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("model", MODELS, ids=lambda model: model.__name__)
|
||||||
|
def test_every_model_has_an_exact_field_table(doc_lines: list[str], model: type[BaseModel]) -> None:
|
||||||
|
documented = _table_fields(_section(doc_lines, model.__name__))
|
||||||
|
assert documented, f"docs/LEVELS.md has no Field table for {model.__name__}"
|
||||||
|
assert documented == set(model.model_fields), (
|
||||||
|
f"docs/LEVELS.md vs {model.__name__}: "
|
||||||
|
f"undocumented={sorted(set(model.model_fields) - documented)}, "
|
||||||
|
f"not-in-model={sorted(documented - set(model.model_fields))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_comparison_catches_planted_drift(doc_lines: list[str]) -> None:
|
||||||
|
planted = [
|
||||||
|
line.replace("| `penalty` |", "| `reward` |") for line in doc_lines
|
||||||
|
]
|
||||||
|
documented = _table_fields(_section(planted, "OrdersSpec"))
|
||||||
|
assert "reward" in documented
|
||||||
|
assert documented != set(OrdersSpec.model_fields)
|
||||||
@@ -37,6 +37,7 @@ from undercooked.config import (
|
|||||||
get_settings,
|
get_settings,
|
||||||
reset_settings_cache,
|
reset_settings_cache,
|
||||||
)
|
)
|
||||||
|
from undercooked.sim.recipe import ContentError
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
@@ -214,6 +215,39 @@ def test_main_reports_an_invalid_environment(
|
|||||||
assert "UNDERCOOKED_TICK_HZ=99" in capsys.readouterr().err
|
assert "UNDERCOOKED_TICK_HZ=99" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_exits_two_when_the_content_gate_refuses_startup(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
"""Unusable levels are a startup error, not a traceback.
|
||||||
|
|
||||||
|
Content is read when uvicorn imports the app string, so `create_app`'s
|
||||||
|
`ContentError` surfaces out of `uvicorn.run` — and the CLI answers with exit 2,
|
||||||
|
the same code an unusable `UNDERCOOKED_PORT` gets. The named-rule report itself
|
||||||
|
is written by `create_app` before it raises, so this only adds the closing line.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def refusing_run(_target: str, **_kwargs: Any) -> None:
|
||||||
|
raise ContentError("undercooked: level content failed validation:\nBOGUS_RULE: nope")
|
||||||
|
|
||||||
|
monkeypatch.setattr(uvicorn, "run", refusing_run)
|
||||||
|
assert main([]) == EXIT_CONFIG_ERROR
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "refusing to start" in err
|
||||||
|
assert "undercooked-levels validate" in err
|
||||||
|
assert "Traceback" not in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_lets_any_other_serving_failure_propagate(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Only the content gate is translated: a real crash stays a real crash."""
|
||||||
|
|
||||||
|
def crashing_run(_target: str, **_kwargs: Any) -> None:
|
||||||
|
raise OSError("port in use")
|
||||||
|
|
||||||
|
monkeypatch.setattr(uvicorn, "run", crashing_run)
|
||||||
|
with pytest.raises(OSError, match="port in use"):
|
||||||
|
main([])
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_flag_is_an_argument_error() -> None:
|
def test_unknown_flag_is_an_argument_error() -> None:
|
||||||
with pytest.raises(SystemExit) as excinfo:
|
with pytest.raises(SystemExit) as excinfo:
|
||||||
main(["--nope"])
|
main(["--nope"])
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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}"
|
||||||
Reference in New Issue
Block a user