# LEVELS.md — level & recipe YAML reference **Status:** current as of phase 02 (schema, parser, loader, validator, and the `undercooked-levels` author CLI). Phase 11 extends this document with the campaign-wide difficulty-curve notes; until then every field of every content model is documented here, and `tests/unit/test_levels_doc.py` keeps the two in lockstep. ## Purpose This document defines the data-driven content contract for Undercooked: the recipe book, one YAML file per level, the ASCII-grid legend conventions, and every schema field with its type, default, and meaning. Content authors write files against this reference and the loader validates them at startup — the Python source never enumerates levels or recipes, and any document that fails these rules is a load error naming its file, never a silently-defaulted guess. The models live in `src/undercooked/sim/recipe.py` (book, recipes, steps, ingredients), `src/undercooked/sim/level.py` (levels, tiles, orders, hazards) and `src/undercooked/sim/content.py` (discovery and loading). All models are strict (`extra="forbid"`) and revalidate on assignment: an unknown key is an error, not a typo someone lives with. ## Files on disk ```text levels/ recipes.yaml <- the recipe book (see "recipes.yaml" below) 01_first_kitchen.yaml <- one level per file 02_double_shift.yaml _draft.yaml <- ignored: a leading underscore opts a file out notes.md <- ignored: only .yaml / .yml are content ``` `undercooked.sim.content.load_content(levels_dir, recipes_path)` is the whole loader. Its discovery rules: | Rule | Behaviour | |---|---| | Level discovery | every `*.yaml` / `*.yml` file under `levels_dir`, at any depth | | Drafts | files whose name — or any ancestor directory's name — starts with `_` are ignored | | The recipe book | `recipes.yaml` / `recipes.yml` inside `levels_dir` is never treated as a level; the book path is passed separately | | Ordering | the campaign order is `(order, id)` — never filename order | | `by_id` | derived from the ordered tuple; cannot disagree with it | | Duplicate ids | a load error listing every duplicated id | | Unknown pool recipes | a level whose `orders.pool` names a recipe the book does not define fails the load | | Error reporting | every file-level problem is aggregated into one `ContentError` naming each offending file | Every problem found in one run is aggregated into a single `ContentError` whose message is one line per offending file — no prefix, because the caller adds its own (`undercooked-levels` prefixes `undercooked-levels:`, `create_app` prefixes `undercooked: content failed to load from :`). It reads like: ```text undercooked-levels: /…/levels/02_ghost.yaml: orders.pool names unknown recipes: ghost /…/levels/03_wreck.yaml: : Value error, ragged grid: row 1 is 10 chars, want 9; grid character 'Z' has no tiles: legend entry ``` ## recipes.yaml — the recipe book ```yaml ingredients: [tomato, onion, plate] # optional catalog of the campaign's items recipes: soup: # key must equal the recipe's id id: soup name: Tomato Soup base_score: 40 ingredients: - {kind: tomato, form: raw} - {kind: onion, form: raw, count: 2} steps: - {kind: chop, station: chop_board, seconds: 0.9} - {kind: cook, station: stove, seconds: 5.0} ``` ### RecipeBook | Field | Type | Default | Meaning | |---|---|---|---| | `ingredients` | `list[ItemKind]` | `[]` | Catalog of item kinds the campaign uses. Informational in phase 02; item respawning uses it from phase 03 on. | | `recipes` | `map[id → Recipe]` | required, non-empty | The recipes. Each key must equal the recipe's own `id`. | Methods (deterministic, side-effect free): - `get(recipe_id)` → the `Recipe`; `KeyError` naming all known ids otherwise. - `ids()` → declaration order, as a tuple. - `required_station_kinds(recipe_id)` → `frozenset[StationKind]` of every station the steps name. The `RECIPE_STATION_MISSING` rule consumes this; nothing hard-codes "soup needs a stove". - `required_item_kinds(recipe_id)` → every ingredient kind plus `PLATE` when `requires_plating` is true. - `min_seconds_to_complete(recipe_id)` → the theoretical minimum for a perfect cook: the sum of all step `seconds` plus one flat handling allowance (`HANDLING_S = 0.6` s per recipe, the constant in `sim/recipe.py`). The `TARGET_IMPLAUSIBLE` rule uses this as the greedy per-order floor of what a round can score (see "Validation rules"). ### Recipe | Field | Type | Default | Meaning | |---|---|---|---| | `id` | non-empty `str` | required | Stable identifier level order pools reference. | | `name` | non-empty `str` | required | Display name. | | `base_score` | `int ≥ 0` | required | Points for a perfect delivery before tips/combo (phase 04's quality, streak and time bonuses scale them). | | `ingredients` | `list[Ingredient]`, ≥ 1 | required | What the dish consumes. | | `steps` | `list[Step]`, ≥ 1 | required | The ordered procedure. | | `requires_plating` | `bool` | `true` | Whether a plain dish needs the plating step before serving. | | `tags` | `list[str]` | `[]` | Free-form labels for scoring/difficulty hints. | ### Ingredient | Field | Type | Default | Meaning | |---|---|---|---| | `kind` | `ItemKind` | required | Which item (enum in `sim/model.py`; YAML uses the lowercase value). | | `form` | `ItemForm` | required | Form required at the moment of use: `raw`, `chopped`, `cooking`, `cooked`, `burnt`, `plated`, `dirty`. | | `count` | `int ≥ 1` | `1` | How many units the recipe consumes. | ### Step | Field | Type | Default | Meaning | |---|---|---|---| | `kind` | `pick` \| `chop` \| `cook` \| `wash` \| `plate` \| `serve` | required | The verb. A closed vocabulary — anything else is a load error. Trashing is an interaction, not a recipe step, so it has no verb here. | | `seconds` | `float ≥ 0` | `0.0` | How long the action takes when performed. `pick`/`serve` are instantaneous (`0.0`). | | `station` | `StationKind` | required | Where the verb happens (`spawn_shelf`, `chop_board`, `stove`, `prep`, `serve`, `sink`, `trash`). | ## A level file ```yaml id: 01_first_kitchen # convention: the file's stem (see Working conventions) name: First Kitchen order: 1 # campaign position difficulty: 1 # 1..10; feeds DIFFICULTY_ORDER + TRAVEL_TOO_SHORT, tuned in phase 11 time_limit: 150 # seconds of round time, > 0 target_score: 45 # win threshold blurb: Two cooks, one pot. # optional flavour text grid: # ASCII map, all rows equal length - "#########" - "#S..C...#" - "#.......#" - "#..V....#" - "#########" tiles: # every grid char maps to exactly one TileSpec ".": {kind: floor} "#": {kind: wall} "S": {kind: floor, station: spawn_shelf, item: tomato} "C": {kind: floor, station: chop_board} "V": {kind: floor, station: serve} spawns: [[1, 1]] # player spawns as [x, y] grid coords orders: pool: [soup] concurrent: 1 spawn_interval: [16, 20] deadline: 60 hazards: [] # optional, see HazardSpec station_limits: {plates: 2, pots: 1} # optional, default 99 / 99 ``` ### LevelDef | Field | Type | Default | Meaning | |---|---|---|---| | `id` | non-empty `str` | required | Unique across the campaign; duplicate ids fail `load_content`. | | `name` | non-empty `str` | required | Display name. | | `order` | `int ≥ 1` | required | Campaign position. Levels are sorted by `(order, id)`. | | `difficulty` | `int` 1–10 | `1` | Author-declared difficulty. Feeds `DIFFICULTY_ORDER` (the campaign may not declare an easier level after a harder one) and switches on `TRAVEL_TOO_SHORT` from 3 upward; phase 11 tunes the curve against it. | | `time_limit` | `float > 0` | required | Round length in seconds. | | `target_score` | `int ≥ 1` | required | Score needed to win the round. | | `grid` | `list[str]`, ≥ 1 row | required | The ASCII map. Every row the same length, at most 64×64 (`MAX_GRID`), every character present in `tiles`. | | `tiles` | `map[char → TileSpec]`, ≥ 1 | required | The legend. Every legend character must be used by the grid — unused entries are typos. | | `spawns` | `list[[x, y]]`, ≥ 1 | required | Player spawn coordinates, each inside the grid. | | `orders` | `OrdersSpec` | required | The order engine configuration for this level. | | `hazards` | `list[HazardSpec]` | `[]` | Placed and periodic hazards. | | `station_limits` | `StationLimits` | `99 / 99` | Concurrent capacity of shared station resources. | | `blurb` | `str` | `""` | Flavour text for the level card. | | `escalation_notes` | `str` | `""` | Free-form note to the phase 11 difficulty-curve pass. | Schema-level rules (enforced right here, at load): - **rectangular grid** — differing row lengths are a load error naming the row; - **legend completeness both ways** — no grid character without a legend entry, no legend character the grid never uses; - **spawn inside the grid**, at least one spawn; - **exactly one serve legend character** in the tiles map — the validator rejects levels where the win condition cannot be reached by definition (the validator's flood-fill rules below go further and reject pockets no cook can walk into); - numeric sanity on every counter, interval, and limit listed above. ### TileSpec | Field | Type | Default | Meaning | |---|---|---|---| | `kind` | `TileKind` (`floor` \| `counter` \| `wall` \| `void`) | required | Walkability class; only `floor` is walkable (`WALKABLE` in `sim/model.py`). | | `station` | `StationKind` or `null` | `null` | The station installed on this tile, if any. | | `item` | `ItemKind` or `null` | `null` | What this tile restocks — legal **only** on a `spawn_shelf` station tile, and **mandatory** there. | | `id_prefix` | non-empty `str` or `null` | `null` | Replaces the station-kind stem in generated ids (see "Station ids"). Allowed only on station tiles. | Stations sit on tiles of any `kind`; the schema allows it so hazards and decorations can mount stations, but the `STATION_REACHABLE` rule demands a walkable (`floor`) tile for every station players must touch. ### OrdersSpec | Field | Type | Default | Meaning | |---|---|---|---| | `pool` | `list[str]`, ≥ 1 | required | Recipe ids this level draws orders from. Every entry must exist in the recipe book (`load_content` checks; the validator re-checks hand-built levels). | | `concurrent` | `int ≥ 1` | required | Maximum simultaneously active orders. | | `spawn_interval` | `[min, max]` seconds, both > 0 | required | Uniform range the round RNG draws from. `min ≤ max` is validated — reversed intervals are the classic misconfiguration. | | `deadline` | `float > 0` | required | Seconds an order may live before it burns. | | `penalty` | `int ≥ 0` | `10` | Points lost when an order burns out. | ### HazardSpec One shape for placed and periodic hazards. Placement rules by `type`: | `type` | Placement | Meaning | |---|---|---| | `fire` | `station` **or** `x`/`y` (exactly one) | Ignites that station or that cell. | | `spill` | `station` **or** `x`/`y` (exactly one) | Grease puddle at that location. | | `moving_obstacle` | neither; `path` + `period_s` instead | Travels the path back and forth on a loop. | | `rush` | nowhere (level-wide) | A dining-rush surge; needs a positive `intensity`. | | Field | Type | Default | Meaning | |---|---|---|---| | `type` | `HazardKind` (`fire` \| `spill` \| `moving_obstacle` \| `rush`) | required | Which hazard. | | `station` | `str` or `null` | `null` | Station id to attach to (e.g. `stove_2`). Mutually exclusive with `x`/`y`. | | `x` | `int ≥ 0` or `null` | `null` | Cell x. Must be given together with `y`. | | `y` | `int ≥ 0` or `null` | `null` | Cell y. Must be given together with `x`. | | `chance_per_s` | `float ≥ 0` or `null` | `null` | Periodic re-arm probability per second; `null` = placed once at round start. | | `respawn_s` | `float > 0` or `null` | `null` | Delay before a cleared hazard can re-arm. | | `path` | `list[[x, y]]`, ≥ 2 points, or `null` | `null` | Route for `moving_obstacle` only. | | `period_s` | `float > 0` or `null` | `null` | Loop period for `moving_obstacle`; required when it has a `path`. | | `intensity` | `float` (must be > 0 when `type: rush`) | `1.0` | Magnitude (`rush` surge strength; severity multiplier elsewhere). | ### StationLimits | Field | Type | Default | Meaning | |---|---|---|---| | `plates` | `int ≥ 0` | `99` | Clean plates available at once. `0` loads and is reported by `STATION_LIMIT_ZERO` — the rule, not a schema error, so the report names it. | | `pots` | `int ≥ 0` | `99` | Pots the stoves share. | ## Station ids and the built `Level` `build_grid(spec)` walks the grid in reading order (row by row, left to right) and assigns each station tile an id: `{id_prefix or station-kind value}_{n}`, where `n` counts **per station kind** across the whole level. Two legend characters of the same kind therefore share one counter — a level with `T` (stove) and `t` (stove, `id_prefix: burner`) produces `stove_0` and `burner_1`. The same walk yields the `Station` list and `build_grid`'s `Grid.station_at` (cell index → station id), and `Grid.from_rows` re-checks geometry one last time so the grid cannot disagree with the spec. `Level.build(spec)` pairs the validated `LevelDef` with what the parser produces — `grid` (the `sim/grid.py` `Grid`), `stations`, `spawns` (as `(x, y)` tuples) and `station_items` (spawn-shelf id → restocked `ItemKind`) — and delegates `id`, `order`, `difficulty`, `time_limit`, `target_score`, `orders`, `hazards`, `station_limits`, `blurb` and `escalation_notes` to the spec, so simulation code reads one object. The `ContentBundle` returned by `load_content` holds the `recipe_book`, the campaign-ordered `levels`, and the derived `by_id` map. ## Validation rules `sim/validation.py` proves a built `Level` is playable before anyone joins it — the PLAN §8 content gate. `validate_level(level, book, prev=None)` runs the per-level rules (`prev` is the campaign predecessor and enables the escalation check); `validate_levels(levels, book)` adds the bundle-wide rules over an `(order, id)`-ordered list and `validate_bundle(bundle)` runs that over a loaded bundle. Each hit is a `LevelViolation` naming a stable **rule id** with a human message carrying coordinates and a machine-readable `detail` mapping. `ValidationReport.render()` prints a deterministic, ANSI-free, level-grouped report, and **warnings never fail a level** — `report.ok` reads violations only. | Rule | Fires when | |---|---| | `GRID_SHAPE` | the built `Grid` disagrees with the spec's ASCII row count or width (hand-built levels; `LevelDef` validation already rejects ragged grids) | | `SPAWN_ON_FLOOR` | a spawn is not on a walkable floor tile | | `SPAWN_COUNT` | the level has no player spawns | | `STATION_REACHABLE` | a station cell cannot be walked to from any spawn | | `SERVE_REACHABLE` | the same for the serve window specifically — no reachable window means no round can ever be won | | `SPAWNS_MUTUALLY_REACHABLE` | a spawn cannot be walked to from the first spawn | | `RECIPE_STATION_MISSING` | a pool recipe needs a station kind the level does not have within the reachable area | | `RECIPE_INGREDIENT_UNAVAILABLE` | a pool recipe needs a `raw` ingredient no reachable spawn shelf produces (spawn shelves are currently the only item source the content model expresses) | | `ORDER_POOL_UNKNOWN_RECIPE` | `orders.pool` names a recipe the recipe book does not define | | `TARGET_IMPLAUSIBLE` | `target_score` exceeds the plausibility bound below — above 45% of the bound it is a **warning**, above 60% an error | | `TIME_LIMIT_INVALID` | `time_limit` is under 30 s | | `HAZARD_REF_UNKNOWN` | a hazard references a station id that does not exist | | `HAZARD_ON_UNREACHABLE_TILE` | a hazard tile or `moving_obstacle` path point is off-grid, not walkable, or cut off from the cooks | | `STATION_LIMIT_ZERO` | `station_limits.plates` or `.pots` is below 1 | | `DUPLICATE_ID` | two levels in the bundle share an `id` (bundle rule) | | `DIFFICULTY_ORDER` | a level raises fewer than two escalation axes over its predecessor, or the campaign's declared `difficulty` drops between consecutive levels (bundle rule) | | `TRAVEL_TOO_SHORT` | from difficulty 3 up, the mean spawn→serve path is under 3 tiles — sprint-to-serve layouts are not campaigns | **Target plausibility** (`theoretical_max_score`) is the greedy bound `floor(time_limit / mean_complete) × mean_order_value` over the level's order pool, where `mean_complete` is the recipe book's `min_seconds_to_complete()` average plus a travel bound of `2 × mean spawn→serve tiles ÷ 3.5 tiles/s` — one walk to the window and one back at max cook speed (PLAN §5). The bound is deliberately generous: it rejects targets a perfect team could never hit, not targets a sloppy one misses. Levels with an invalid time limit or no known pool recipes return no bound — those conditions have their own rules. **Escalation axes** (`escalation_axes`, every axis oriented *higher = harder*): | Axis | Measure | |---|---| | `recipe_complexity` | number of distinct recipes in the order pool | | `order_pressure` | `orders.concurrent` ÷ mean spawn interval | | `time_budget` | `target_score` ÷ `time_limit` | | `station_scarcity` | 1 ÷ station count | | `layout_hostility` | blocking tiles ÷ total tiles | | `hazard_density` | number of hazards | | `travel_distance` | mean BFS tiles from a spawn to the nearest serve cell | Reachability is a BFS flood fill over walkable tiles from every spawn. `moving_obstacle` tiles are deliberately *not* subtracted from the reachable set — movers move, so only their path points must be valid floor. When the serve window is unreachable the travel metric falls back to Manhattan distance so the numbers stay defined; the reachability rules report the break itself. `undercooked-levels validate` prints this report for the levels directory and exits `1` on any violation — see **Authoring workflow** below. The same report stops the game server: `create_app` runs that exact pair at startup, so `uv run undercooked` prints it, refuses to build an app around it and exits `2` rather than open a lobby nobody can cook in. ## Working conventions - `id` should equal the file's stem; the loader keys everything by `id`, and the bundle rule `DUPLICATE_ID` refuses a bundle that reuses one. - Two characters may share a `TileSpec` meaning only by writing it twice; characters are single characters — a multi-character legend key can never match a grid cell, so the load fails with `legend character 'VV' is never used by the grid`, while a grid character with no entry at all fails with `grid character 'Z' has no tiles: legend entry`. - Spawn shelves are the only item source at schema level; deeper restocking semantics belong to phase 03's simulation work. - Everything strict: when a field is added to a model, `docs/LEVELS.md` must gain a row in the same change — `tests/unit/test_levels_doc.py` fails if the doc table and `model_fields` ever disagree. ## Authoring workflow `undercooked-levels` is the author's half of this contract, and a thin shell over the code above: `validate` is exactly `load_content` + `validate_bundle` (the same pair `create_app` runs at startup), so green here means a server that boots there. It ships with the project (`[project.scripts]` in `pyproject.toml`) and never enumerates content itself — it prints whatever the directory it was pointed at contains. `tests/unit/test_content_not_hardcoded.py` holds the CLI, the loader and the content API to that rule. ```bash uv run undercooked-levels new 03_riverboat # 1. scaffold a level, validate that file uv run undercooked-levels list # 2. the campaign, in campaign order uv run undercooked-levels show 03_riverboat --grid # 3. read the resolved level + overlay uv run undercooked-levels validate # 4. the gate .agents/validate.sh runs uv run undercooked-levels axes # 5. the escalation table phase 11 tunes ``` Every subcommand takes `--dir PATH` (levels) and `--recipes PATH` (the book), which beat `UNDERCOOKED_LEVELS_DIR` / `UNDERCOOKED_RECIPES_PATH`, which beat the defaults `levels` and `/recipes.yaml`. Path resolution is settings-driven, never cwd-driven: a *relative* path with nothing under the current directory is anchored to this checkout, so the commands above work from `/tmp` exactly as they do from the repo root. 1. **`new ID [--order N]`** writes `/ID.yaml` — a valid, minimal kitchen *generated from the recipe book*: one spawn shelf per raw ingredient of the book's first dish, one station per kind that dish's steps name, and two spawns on the floor row far enough from the window to clear `TRAVEL_TOO_SHORT`. `--order` defaults to one past the highest in the directory; `difficulty` starts one above the campaign's hardest. It refuses to overwrite an existing level, and prints the single-level validation report of what it wrote. 2. **Edit** the grid, the tunables and the prose. `id` should equal the file's stem. Fill `escalation_notes` with the axes you moved: the campaign rule (`DIFFICULTY_ORDER`) wants at least two axes up versus the level that precedes yours by `order`, and a `difficulty` that never drops. 3. **`list` and `show ID [--grid]`** read the level back the way the server sees it — campaign order and resolved geometry, not the text you typed. `show` prints the tunables, the station and hazard counts, how much of the floor is reachable, and each pool dish resolved against the book (or `MISSING from the recipe book`). 4. **`validate`** prints one status line per level and then `ValidationReport.render()`. Warnings print and stay green — a warning is a tuning question, a violation is a broken level: ```text 99_broken_room 4 error(s) 11x6, 4 stations 4 error(s), 0 warning(s) level 99_broken_room ERROR RECIPE_STATION_MISSING: recipe 'soup' needs a reachable prep station; the level has none ERROR RECIPE_STATION_MISSING: recipe 'soup' needs a reachable stove station; the level has none ERROR SERVE_REACHABLE: serve station serve_0 at (8,1) is not reachable from any spawn ERROR TARGET_IMPLAUSIBLE: target_score 999 is above 60% of the theoretical maximum 160; no one could ever reach it ``` 5. **`axes`** prints the seven escalation axes per level in campaign order (higher = harder), which is how you check a level really got harder instead of only *feeling* harder: ```text LEVEL ORDER recipe_complexity order_pressure time_budget station_scarcity layout_hostility hazard_density travel_distance 01_first_kitchen 1 1.0000 0.0556 0.3000 0.1429 0.3519 0.0000 7.5000 02_double_shift 2 2.0000 0.1429 0.6154 0.1250 0.4786 0.0000 5.5000 ``` ### The overlay debugging trick `STATION_REACHABLE`, `SERVE_REACHABLE` and `HAZARD_ON_UNREACHABLE_TILE` all come down to one question: which walkable floor can the cooks actually get to? `show --grid` re-draws the authored map as terrain so you see the answer instead of deducing it from a BFS you cannot see: ```bash uv run undercooked-levels show 99_broken_room --grid --dir tests/fixtures/levels/_bad ``` ```text map (as authored) <- the file, exactly as written ########### #T..C#..V.# #....#....# #.######### #S........# ########### reachability . reachable ? walkable but cut off # blocking ########### #....#????# #....#????# #.######### #.........# ########### 18/26 walkable tiles are reachable from the spawns ``` The `?` band is the sealed servery: walkable floor, and the wall at column 6 cuts it off from the spawn on row 4. Fix the wall, not the target score. Note that a moving obstacle's `path` tiles stay `.` even when a mover currently blocks them — movers move, so the validator only insists the path itself is valid floor. ### Scripting it `validate --json` is the machine-readable form of the same report — stable key names, sorted hits, so a diff of two runs is only a diff of real changes: ```bash uv run undercooked-levels validate --json | python -m json.tool ``` ```json { "ok": false, "counts": {"errors": 4, "warnings": 0}, "errors": [ { "level": "99_broken_room", "rule": "SERVE_REACHABLE", "message": "serve station serve_0 at (8,1) is not reachable from any spawn", "detail": {"station_id": "serve_0", "x": 8, "y": 1} } ] } ``` Exit codes are the whole scripting surface: `0` everything is fine, `1` the content loaded and breaks at least one rule, `2` the tool could not do its job (content would not load, an unknown level id, a file `new` refuses to overwrite, or an argparse usage error). `.agents/validate.sh` runs `validate` unconditionally, so a campaign that cannot validate cannot commit even when no test file changed. ### Which rule did I just hit? The full semantics are in the **Validation rules** table above; this is the "what do I type next" view of the same ids. | Rule | Usually means | |---|---| | `GRID_SHAPE` | the row count/width you wrote and the built grid disagree — check for a ragged row or a stray character | | `SPAWN_ON_FLOOR` | a `spawns:` coordinate lands on wall/counter — move it onto a `.` | | `SPAWN_COUNT` | `spawns:` is empty — a kitchen nobody stands in | | `STATION_REACHABLE` | a fixture sits behind a wall — `show --grid` and open a path | | `SERVE_REACHABLE` | the window is unreachable, so the round cannot be won — same fix, and the level is unwinnable until then | | `SPAWNS_MUTUALLY_REACHABLE` | the cooks start on islands — connect the spawn areas | | `RECIPE_STATION_MISSING` | a pool dish needs a station the level lacks (or cannot reach) — add the fixture or trim the pool | | `RECIPE_INGREDIENT_UNAVAILABLE` | a pool dish eats an item no reachable spawn shelf produces — add a shelf for it | | `ORDER_POOL_UNKNOWN_RECIPE` | `orders.pool` names a dish `recipes.yaml` does not define — check the spelling | | `TARGET_IMPLAUSIBLE` | `target_score` is out of reach of a perfect team — lower the target, lengthen `time_limit`, or raise the dish values | | `TIME_LIMIT_INVALID` | `time_limit` under 30 s — a round that is not a round | | `HAZARD_REF_UNKNOWN` | a hazard's `station:` id is not in the level — re-read the station ids the loader built | | `HAZARD_ON_UNREACHABLE_TILE` | a hazard sits in a wall or a sealed pocket — move it where the cooks are | | `STATION_LIMIT_ZERO` | `station_limits.plates`/`.pots` is 0, so no dish can ever be plated | | `DUPLICATE_ID` | two files declare the same `id` — one of them is a copy-paste | | `DIFFICULTY_ORDER` | the level moves fewer than two escalation axes up, or `difficulty` dropped — compare with `axes` | | `TRAVEL_TOO_SHORT` | from difficulty 3, spawn-to-serve averages under 3 tiles — spread the kitchen out |