Files
undercooked/docs/LEVELS.md
T
ducoterra 3426264ffb 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.
2026-10-06 11:03:28 -04:00

27 KiB
Raw Blame History

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) → 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

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.

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:

    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:

    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