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.
500 lines
27 KiB
Markdown
500 lines
27 KiB
Markdown
# 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 |
|