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.
27 KiB
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
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:
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
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)→ theRecipe;KeyErrornaming all known ids otherwise.ids()→ declaration order, as a tuple.required_station_kinds(recipe_id)→frozenset[StationKind]of every station the steps name. TheRECIPE_STATION_MISSINGrule consumes this; nothing hard-codes "soup needs a stove".required_item_kinds(recipe_id)→ every ingredient kind plusPLATEwhenrequires_platingis true.min_seconds_to_complete(recipe_id)→ the theoretical minimum for a perfect cook: the sum of all stepsecondsplus one flat handling allowance (HANDLING_S = 0.6s per recipe, the constant insim/recipe.py). TheTARGET_IMPLAUSIBLErule 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
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
idshould equal the file's stem; the loader keys everything byid, and the bundle ruleDUPLICATE_IDrefuses a bundle that reuses one.- Two characters may share a
TileSpecmeaning only by writing it twice; characters are single characters — a multi-character legend key can never match a grid cell, so the load fails withlegend character 'VV' is never used by the grid, while a grid character with no entry at all fails withgrid 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.mdmust gain a row in the same change —tests/unit/test_levels_doc.pyfails if the doc table andmodel_fieldsever 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.
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.
-
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 clearTRAVEL_TOO_SHORT.--orderdefaults to one past the highest in the directory;difficultystarts one above the campaign's hardest. It refuses to overwrite an existing level, and prints the single-level validation report of what it wrote. -
Edit the grid, the tunables and the prose.
idshould equal the file's stem. Fillescalation_noteswith the axes you moved: the campaign rule (DIFFICULTY_ORDER) wants at least two axes up versus the level that precedes yours byorder, and adifficultythat never drops. -
listandshow ID [--grid]read the level back the way the server sees it — campaign order and resolved geometry, not the text you typed.showprints the tunables, the station and hazard counts, how much of the floor is reachable, and each pool dish resolved against the book (orMISSING from the recipe book). -
validateprints one status line per level and thenValidationReport.render(). Warnings print and stay green — a warning is a tuning question, a violation is a broken level: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 -
axesprints 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: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:
uv run undercooked-levels show 99_broken_room --grid --dir tests/fixtures/levels/_bad
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:
uv run undercooked-levels validate --json | python -m json.tool
{
"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 |