phase: 02_level_content_and_validation

Phase `02_level_content_and_validation` — final verification **green**.

- **Gate:** `bash .agents/validate.sh` → ruff ✅ · pyright 0 errors ✅ · `undercooked-levels validate` OK (2 levels) ✅ · **668 passed** (incl. 10 Playwright e2e) ✅ · coverage **100%** ✅ · repo hygiene ✅
- **Criteria:** `validate` exit 0, one line per level; `list` = `01_first_kitchen`, `02_double_shift` in campaign order; live `:8000` `/healthz` 200, `/api/levels` (+detail) carries both levels with numeric `target_score`/`time_limit` and no grid/station leakage, `/api/config` correct, unknown id → 404, startup log `content loaded: 2 level(s), 4 recipe(s)`; `validate --dir tests/fixtures/levels/_bad/` → exit 1 with named `RECIPE_STATION_MISSING` / `SERVE_REACHABLE` / `TARGET_IMPLAUSIBLE`; A8 lock test proves a YAML-only level reaches loader+API+CLI.
- **Defect fixed (pre-existing path, phase-02-triggered):** `undercooked` with unusable content printed the report then a raw traceback and exited `1`. `src/undercooked/__main__.py` now catches `ContentError` → one closing line, exit `2` (like a bad setting); 2 new tests in `tests/unit/test_main.py` (incl. non-content failures still propagate).
- **Docs pass (full, project-wide):** README (content gate + exit `2`, config section), AGENTS.md §3, `docs/LEVELS.md` (startup refusal + exact CLI exit codes), `docs/DEPLOYMENT.md` (crash-loop diagnosis bullet); DESIGN/TESTING re-read, consistent.
- **Container re-verified (no phase-01 regression):** `podman build -f Containerfile` succeeds; the image ships `levels/` and serves `/healthz` 200, `/api/levels` (2 levels), `/` 200 as non-root.
- **Deviation to note:** the phase overview names `tests/fixtures/levels/bad/`; the actual broken-fixture dir is `tests/fixtures/levels/_bad/` (the `_` prefix is what keeps it out of normal discovery). Overview files are read-only to me — README/LEVELS.md document the real path.
- No git index/history commands run; all changes left unstaged for the harness. ntfy notification sent.
This commit is contained in:
2026-10-06 11:03:28 -04:00
parent 6fd83446ec
commit 3426264ffb
48 changed files with 8094 additions and 54 deletions
@@ -1,50 +0,0 @@
# Phase 02 — Level content, schema, and validation
**Story:** `US-07_campaign_progression` (content foundation)
**Context:** `.agents/PLAN.md` §5 (entity model), §8 (content + escalation), A5, A8
## Objective
Define the pure simulation's data model and the data-driven content pipeline: YAML →
validated Pydantic level/recipe definitions → in-memory `LevelDef`, plus a CLI that
proves every shipped level loads, is reachable, and is solvable. No networking, no
movement yet — but the app can already list levels over HTTP.
## Dependencies
- `01_runnable_service_scaffold` (complete) — `create_app()`, `Settings.levels_dir`,
validate gate, test tree
## Tasks
1. `01_sim_core_data_model.md` — enums, `Vec2`, `Item`, `Station`, `Player`, `Order`, `Hazard`, `KitchenState`, `Grid`
2. `02_recipe_and_level_schema.md` — Pydantic models for `recipes.yaml` + level YAML, ASCII grid parser
3. `03_level_validation_rules.md` — reachability, solvability, target plausibility, structured `LevelError` report
4. `04_seed_recipes_and_two_levels.md` — `levels/recipes.yaml`, `levels/01_…yaml`, `levels/02_…yaml`, `/api/levels`
5. `05_levels_cli_and_manifest_test.md` — `undercooked-levels validate|list|show`, content regression tests
## Testing & Quality
- Unit: grid geometry and walkability; ASCII parser (ragged grids, unknown legend chars,
spawn off-floor); validator rules (each rule needs at least one failing fixture and one
passing fixture); loader ordering (campaign order by `order` field, ties broken by id);
duplicate id detection; recipe referencing an unknown ingredient fails.
- Integration: `GET /api/levels` returns both seeded levels with id/name/order/target/
time_limit/difficulty pips and nothing else (no leak of internal grid text).
- Coverage: **>90%** on new/modified code (`sim/level.py`, `sim/validation.py`, `sim/grid.py`
at ≥ 95% — they carry the content contract).
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks update `README.md`/`AGENTS.md` immediately when their own changes are
user-visible: this phase adds `docs/LEVELS.md` (schema reference) and must keep the
README layout/commands table aligned with it.
## Completion Criteria
- [ ] `uv run undercooked-levels validate` exits 0 and prints one line per level
- [ ] `uv run undercooked-levels list` prints both levels in campaign order
- [ ] `curl -fsS http://127.0.0.1:8000/api/levels | python -m json.tool` lists both levels with numeric target/time
- [ ] `uv run undercooked-levels validate --dir tests/fixtures/levels/bad/` reports the expected named rule failures (non-zero exit)
- [ ] adding a fixture YAML file to `tests/fixtures/levels/` makes it appear in the loader output with no Python change (test-enforced)
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -1,73 +0,0 @@
# Task 01 — Core simulation data model
**Phase:** `02_level_content_and_validation` · **Story:** `US-04_move_and_cook`, `US-07_campaign_progression`
## Objective
Write the vocabulary every later phase shares: pure dataclasses/enums for the kitchen,
and the grid type with its geometry and walkability rules.
## Work
1. `src/undercooked/sim/model.py` (pure: no imports outside stdlib + this package; add a
module docstring stating the A5 purity contract):
- `Facing` (`str, Enum`: `UP/RIGHT/DOWN/LEFT`) with `delta()` and `OPPOSITE`.
- `Vec2` frozen dataclass `x, y` with `+`, `-`, `scale`, `length`, `normalized`,
`quantized(digits=2)` (used by the wire encoder later), `KEY = (int(round(x*64)), int(round(y*64)))`
for hashing.
- `TileKind` (`FLOOR, COUNTER, WALL, VOID`), `WALKABLE: frozenset[TileKind] = {FLOOR}`.
- `StationKind` (`SPAWN_SHELF, CHOP_BOARD, STOVE, PREP, SERVE, SINK, TRASH`),
`StationState` (`IDLE, WORKING, READY, BUSY, DIRTY, BLOCKED`).
- `ItemKind` (`TOMATO, ONION, LETTUCE, DOUGH, PATTY, RICE, FISH, PLATE`) and
`ItemForm` (`RAW, CHOPPED, COOKING, COOKED, BURNT, PLATED, DIRTY`).
- `Item` dataclass: `kind`, `form`, `quality: float = 1.0`, `ttl: float = 0.0`,
`dish_id: int | None = None`; `is_trash()`, `with_form()` helpers (returns new
instance — keep value semantics).
- `Station` dataclass: `id: str`, `kind`, `x`, `y`, `contents: tuple[Item, ...]`,
`state`, `progress: float`, `duration: float`, `cooldown: float`,
`worker_id: str | None`, `dirty: bool`.
- `PlayerState` (`IDLE, MOVING, USING, STUNNED, DISCONNECTED`), `Player` dataclass:
`id`, `pos: Vec2`, `vel: Vec2`, `facing`, `carrying: Item | None`, `state`,
`stun: float`, `score: int`, `dishes_served: int`, `connected: bool`, `seat: int`.
- `Order` dataclass: `id: int`, `recipe_id: str`, `time_left: float`, `deadline: float`,
`value: int`.
- `HazardKind` (`FIRE, SPILL, MOVING_OBSTACLE, RUSH`), `Hazard` dataclass: `id`, `kind`,
`pos`, `life`, `path: tuple[Vec2, ...]`, `period_s`, `station_id`, `chance_per_s`.
- `RoundResult` (`WON, LOST, ABORTED`), `KitchenState` dataclass: `level_id`, `seed`,
`tick`, `grid`, `stations: tuple[Station, ...]`, `players: tuple[Player, ...]`,
`orders: tuple[Order, ...]`, `hazards`, `score`, `target_score`, `time_left`,
`streak`, `result: RoundResult | None`, `counters: Mapping[str, int]`,
`rng_state: int`. Provide `state_hash() -> str` (sha256 over a canonical
repr of the fields that matter — used by replay tests) and `with_*` helpers so
mutation stays centralized.
- `Event` dataclass: `kind: str`, `x`, `y`, `payload: Mapping`, plus `EventKind`
string constants (`CHOPPED`, `SERVED`, `BURNT`, `ORDER_MISSED`, `FIRE_STARTED`,
`DENIED`, `SCORE`, `STUNNED`, `WASHED`, `TRASHED`, `TICK_SLOW`).
2. `src/undercooked/sim/grid.py`:
- `Grid` frozen dataclass `width`, `height`, `tiles: tuple[TileKind, ...]`
(row-major), `station_at: Mapping[int, str]`.
- `in_bounds`, `tile_at(x, y)`, `is_walkable(x, y)`, `walkable_cells()`,
`blocking_mask()`, `neighbours(cell, four_way=True)`, `bfs_reachable(start,
extra_blocked=frozenset()) -> frozenset[int]`, `manhattan(a, b)`.
- `Grid.from_rows(rows: Sequence[str], legend: Mapping[str, TileKind]) -> Grid` —
raises `GridError` on ragged rows, empty grid, unknown legend char, or size 0/out of
range (`MAX_GRID = 64`).
3. `src/undercooked/sim/purity.py` — `assert_pure_imports()` helper that imports every
`sim` module and asserts none pulled in `fastapi`, `uvicorn`, `asyncio`, `socket`, or
`time` (allow `time.perf_counter` nowhere in sim; check module-level, keep it a
20-line guard used by a test).
4. `tests/sim/test_model.py`, `tests/sim/test_grid.py`, `tests/sim/test_purity.py` —
geometry, walkability, BFS around walls, ragged-grid errors, `state_hash` stability
across field insertion order for mappings, helpers return new instances.
## Testing & Quality
- Unit: every helper listed above; `Vec2` arithmetic incl. zero-length normalization;
`state_hash` changes when a scored field changes and ignores cosmetic ones.
- Coverage: **>90%** on `model.py`/`grid.py`/`purity.py` (target 95%+).
## Completion Criteria
- [ ] `uv run python -c "from undercooked.sim.grid import Grid"` succeeds
- [ ] `uv run pytest tests/sim -q` → all green
- [ ] `uv run pytest tests/sim/test_purity.py -q` proves no forbidden import in `sim/`
- [ ] `uv run ruff check src/undercooked/sim && uv run pyright` → zero errors
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,69 +0,0 @@
# Task 02 — Recipe and level schema (YAML → typed)
**Phase:** `02_level_content_and_validation` · **Story:** `US-07_campaign_progression`
## Objective
Make content files the only source of levels and recipes: strict Pydantic models, an
ASCII-grid parser wired to a tile legend, and a loader that returns campaign-ordered
`LevelDef`s.
## Work
1. `src/undercooked/sim/recipe.py` — Pydantic models:
- `Ingredient(kind: ItemKind, form: ItemForm, count: int = 1)`
- `Step(kind: Literal["pick","chop","cook","plate","serve","wash"], station:
StationKind, seconds: float = 0.0)`
- `Recipe(id: str, name: str, base_score: int, ingredients: list[Ingredient],
steps: list[Step], requires_plating: bool = True, tags: list[str] = [])`
- `RecipeBook(recipes: dict[str, Recipe])` with `get(id)`, `ids()`,
`required_station_kinds(recipe_id) -> frozenset[StationKind]`,
`required_item_kinds(recipe_id) -> frozenset[ItemKind]`,
`min_seconds_to_complete(recipe_id) -> float` (sum of step seconds + handling
constant `HANDLING_S = 0.6`) — the solver bound used by validation.
- `load_recipe_book(path: Path) -> RecipeBook` (YAML, `extra="forbid"`,
`validate_assignment=True`).
2. `src/undercooked/sim/level.py` — Pydantic models mirroring PLAN §8:
- `TileSpec(kind: TileKind, station: StationKind | None = None, item: ItemKind | None = None,
id_prefix: str | None = None)`
- `OrdersSpec(pool: list[str], concurrent: int, spawn_interval: tuple[float, float],
deadline: float, penalty: int = 10)`
- `HazardSpec(type: HazardKind, x: int | None, y: int | None, station: str | None,
chance_per_s: float | None, respawn_s: float | None, path: list[tuple[int,int]] | None,
period_s: float | None, intensity: float = 1.0)` — model_validator: `station` XOR
`x/y`; `MOVING_OBSTACLE` requires `path` ≥ 2 points + `period_s`; `FIRE` requires
`station` or coordinates; `RUSH` requires `intensity`.
- `StationLimits(plates: int = 99, pots: int = 99)`
- `LevelDef(id: str, name: str, order: int, time_limit: float, target_score: int,
grid: list[str], tiles: dict[str, TileSpec], spawns: list[tuple[int,int]],
orders: OrdersSpec, hazards: list[HazardSpec] = [], station_limits: StationLimits = …,
difficulty: int = 1, blurb: str = "", escalation_notes: str = "")`
with `model_validator`s: rectangular grid, spawns inside bounds, ≥ 1 spawn,
`difficulty` 1–10, exactly one `SERVE` legend char per level, every legend char used.
- `build_grid(level: LevelDef) -> Grid` (legend → `TileKind`, station ids assigned
deterministically as `f"{kind.value}_{n}"` in reading order) and
- `Level` dataclass pairing `LevelDef` with its `Grid` + station list + spawn list.
3. `src/undercooked/sim/content.py` — `ContentBundle(recipe_book, levels: tuple[Level, …],
by_id: Mapping[str, Level])`, `load_content(levels_dir: Path, recipes_path: Path)
-> ContentBundle`; sorts by `(order, id)`; raises `ContentError` listing **all**
file-level errors at once (not the first only); tolerates subdirectories and
non-matching `*.yml`/`*.yaml`; ignores files starting with `_` (fixtures/overrides).
4. `tests/unit/test_recipe_schema.py`, `tests/unit/test_level_schema.py`,
`tests/unit/test_content_loader.py` — happy paths plus one failing YAML per rule
(ragged grid, unused legend char, two serve legends, unknown recipe in pool, hazard
with neither station nor coords, `min` > `max` spawn interval, non-rectangular grid,
out-of-bounds spawn, `difficulty=11`, duplicate level ids, unknown extra key).
5. `docs/LEVELS.md` — schema reference: every field, type, default, meaning, plus the
legend conventions and a complete annotated example. README Layout + Contributing get
a pointer to it.
## Testing & Quality
- Unit: every validator branch; loader aggregation ordering; `min_seconds_to_complete`
and `required_station_kinds` correctness for a 2-step and a 4-step recipe.
- Coverage: **>90%** on `recipe.py`, `level.py`, `content.py`.
## Completion Criteria
- [ ] `uv run pytest tests/unit/test_level_schema.py tests/unit/test_content_loader.py tests/unit/test_recipe_schema.py -q` → green
- [ ] `uv run python -c "from undercooked.sim.content import load_content; print(len(load_content('levels','levels/recipes.yaml').levels))"` works once task 04 adds content (skip-note allowed before that; must work at phase end)
- [ ] `docs/LEVELS.md` documents every field present in the models (a test compares the model field sets to the doc headings)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,62 +0,0 @@
# Task 03 — Level validation rules
**Phase:** `02_level_content_and_validation` · **Story:** `US-07_campaign_progression`
## Objective
Prove a level is playable before anyone plays it: reachability, recipe solvability,
target plausibility, and hazard references, reported as a structured, named-rule error
list.
## Work
1. `src/undercooked/sim/validation.py`:
- `RuleId` string constants: `GRID_SHAPE`, `SPAWN_ON_FLOOR`, `SPAWN_COUNT`,
`STATION_REACHABLE`, `SERVE_REACHABLE`, `SPAWNS_MUTUALLY_REACHABLE`,
`RECIPE_STATION_MISSING`, `RECIPE_INGREDIENT_UNAVAILABLE`, `ORDER_POOL_UNKNOWN_RECIPE`,
`TARGET_IMPLAUSIBLE`, `TIME_LIMIT_INVALID`, `HAZARD_REF_UNKNOWN`,
`HAZARD_ON_UNREACHABLE_TILE`, `STATION_LIMIT_ZERO`, `DUPLICATE_ID`,
`DIFFICULTY_ORDER`, `TRAVEL_TOO_SHORT`.
- `LevelViolation(rule: RuleId, level_id: str, message: str, detail: Mapping = {})`
and `ValidationReport(violations, warnings)` with `.ok`, `.errors_for(level_id)`,
`.render() -> str` (grouped, stable ordering, no ANSI).
- `validate_level(level: Level, book: RecipeBook, prev: Level | None = None) -> ValidationReport`
implementing PLAN §8:
- BFS from each spawn over walkable tiles → one reachable set; every station cell,
serve window, and spawn must be in it (movers ignored here, they are dynamic).
- For each recipe in the order pool: every required station kind exists and is
reachable; every `RAW` ingredient kind is produced by a reachable spawn shelf
(or a station that yields it).
- `theoretical_max = floor(time_limit / mean_complete)` where
`mean_complete = mean(book.min_seconds_to_complete(r) for r in pool) + travel_bound`
and reject when `target_score > 0.6 * theoretical_max * mean_order_value`
(so a target is never mathematically unreachable). Emit a warning when
`target_score > 0.45 * …` (tight-but-fair).
- Hazards reference existing station ids / in-bounds reachable tiles.
- `time_limit ≥ 30`, `station_limits` positive, ids unique across the bundle
(bundle-level check in `validate_bundle`).
- `DIFFICULTY_ORDER` when `prev` is given: at least two escalation axes must move
vs. the predecessor (axes computed by `escalation_axes(level)` → dict of
`recipe_complexity`, `order_pressure`, `time_budget`, `station_scarcity`,
`layout_hostility`, `hazard_density`, `travel_distance`).
- `TRAVEL_TOO_SHORT`: mean spawn→serve path length ≥ 3 tiles for `difficulty ≥ 3`.
- `validate_bundle(bundle: ContentBundle) -> ValidationReport` — per level plus
duplicate ids and campaign monotonicity for `difficulty`.
- Warnings never fail; every error must name the rule id and a human message with
coordinates.
2. `tests/sim/test_validation.py` — one test per rule id with a minimal fixture level
built by a `make_level(**overrides)` factory (unreachable station behind a wall,
missing sink, ingredient with no shelf, `target_score` 10× too high, hazard pointing at
a nonexistent station id, level 2 with fewer axes moved than level 1, …), and a test
asserting a clean level reports zero errors/warnings.
3. `tests/sim/test_validation_render.py` — `render()` is deterministic (two runs equal),
mentions rule ids, and stays ≤ 40 lines for a 10-violation level.
## Testing & Quality
- Unit: every rule id has a failing and a passing case; report ordering/rendering stable.
- Coverage: **>90%** on `validation.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_validation.py -q` → green, one test per rule id (assert the count matches `len(RuleId.ALL)`)
- [ ] planting an unreachable station in a temp level produces exactly the `STATION_REACHABLE` violation (test asserts this)
- [ ] `uv run pytest -q` → full suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,60 +0,0 @@
# Task 04 — Seed recipes and the first two levels, expose `/api/levels`
**Phase:** `02_level_content_and_validation` · **Story:** `US-07_campaign_progression`
## Objective
Ship real content: an ingredient/recipe book and the first two campaign levels, loaded at
startup, validated, and readable by the client over HTTP.
## Work
1. `levels/recipes.yaml` — ingredients (`tomato, onion, lettuce, dough, patty, rice,
fish, plate`) and recipes:
- `soup` (tomato + onion → chop → pot/stove → plate) base 40
- `salad` (lettuce + tomato → chop → plate) base 30
- `fried_rice` (rice + onion → stove → plate) base 50
- `burger` (patty + dough + lettuce → pan/stove + chop → plate) base 70 (defined now,
used by later levels)
Keep `steps` consistent with the station pipelines in PLAN §5.
2. `levels/01_first_kitchen.yaml` — order 1, difficulty 1, 12×9 open kitchen, single
spawn shelf per ingredient used by `soup`, one chop board, one stove, one prep, one
serve, one sink, `time_limit: 150`, `target_score: 45`, `orders.pool: [soup]`,
`concurrent: 1`, `spawn_interval: [16, 20]`, `deadline: 60`, no hazards, 2 spawns,
`blurb` written for a first-timer.
3. `levels/02_double_shift.yaml` — order 2, difficulty 2, counters that create a mild
pinch, `pool: [soup, salad]`, `concurrent: 2`, `spawn_interval: [12, 16]`,
`deadline: 52`, `time_limit: 130`, `target_score: 80`, 2 spawns. Must move ≥ 2
escalation axes versus level 1 (order pressure + time budget + recipe complexity).
4. Startup wiring: `create_app()` loads the content bundle once
(`app.state.content`), running `validate_bundle`; on errors, the app **refuses to
start** with the rendered report on stderr (content bugs must not surface as a
half-working lobby). `Settings.levels_dir` drives the directory; `levels/recipes.yaml`
resolved beside it (overridable with `UNDERCOOKED_RECIPES_PATH`, added to `Settings`,
`.env.example`, and the README table).
5. `GET /api/levels` in `src/undercooked/server/app.py` (new
`src/undercooked/server/routes_content.py` router) returning
`[{id, name, order, difficulty, blurb, target_score, time_limit, recipes: [names]}]`
in campaign order — no grids, no internal station ids.
`GET /api/levels/{id}` returns the same plus `hazards: [{type}]` only (never layout).
`GET /api/config` returns client-safe values (`max_players`, `tick_hz`, `snapshot_hz`,
`protocol_version`) and nothing else.
6. `tests/integration/test_content_api.py` — lists exactly the shipped levels in order,
shape-validated by an assertable response model, no `grid` key anywhere in the body;
`/api/levels/01_first_kitchen` 200, unknown id 404; `/api/config` exposes no setting
name containing `secret`/`password`/`origins`.
7. `tests/unit/test_shipped_content.py` — for **every** file in `levels/`: validates
clean, has a `blurb` (≥ 20 chars), `order` unique and contiguous from 1, difficulty in
range, and the recipes in its pool exist. This is the content regression test.
## Testing & Quality
- Integration: content API shape/ordering/leaks.
- Unit: shipped content validates; startup refuses on a broken content dir
(`tmp_path` copy of a bad level + assert `ConfigError`/`ContentError` and stderr report).
- Coverage: **>90%** on `routes_content.py`.
## Completion Criteria
- [ ] `uv run undercooked --port 8139 &` → `curl -fsS http://127.0.0.1:8139/api/levels` lists both levels, no grid data
- [ ] `uv run pytest tests/unit/test_shipped_content.py tests/integration/test_content_api.py -q` → green
- [ ] removing `serve` from a copied level makes startup fail with `SERVE_*`/`STATION_REACHABLE` named in stderr
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,56 +0,0 @@
# Task 05 — `undercooked-levels` CLI and manifest tests
**Phase:** `02_level_content_and_validation` · **Story:** `US-07_campaign_progression`
## Objective
Give authors a fast, scriptable feedback loop over content and lock in the "no
hard-coded content lists" rule with a test that adds a level file and expects it to show
up.
## Work
1. `src/undercooked/tools/levels.py` — argparse CLI (stdlib only; A10/A18 style, exit
codes 0/1/2):
- `validate [--dir PATH] [--recipes PATH] [--json]` → runs `validate_bundle`, prints
`report.render()` (or JSON), exit 1 on any error, 2 on a load failure.
- `list [--dir PATH]` → one line per level: `order id difficulty target time recipes`.
- `show ID [--grid]` → resolved level summary; `--grid` prints the ASCII map plus a
reachability overlay (`.` reachable, `?` unreachable) to debug a failing level.
- `new ID [--order N]` → writes a starter YAML from a built-in template into the
levels dir, then runs validation on it (author onboarding).
- `axes [--dir PATH]` → escalation-axis table for all levels (feeds the curve work in
phase 11).
Module is importable: `main(argv: Sequence[str] | None = None) -> int`.
2. Make the console script real: confirm `undercooked-levels` in
`[project.scripts]` points at `undercooked.tools.levels:main`; verify
`uv run undercooked-levels validate` works from the repo root **and** from another cwd
(path resolution via `Settings.levels_dir`, never `cwd`).
3. Wire the CLI into `.agents/validate.sh` unconditionally (remove the phase-01 skip
notice).
4. `tests/unit/test_levels_cli.py` — each subcommand's exit code and stdout, including:
`validate` green on shipped content; `validate --dir <bad fixture>` exit 1 naming the
rule; `show 01_first_kitchen --grid` overlay marks an unreachable station; `axes`
table ordering; `--json` output parses.
5. `tests/unit/test_content_not_hardcoded.py` — the rule lock:
- copy the shipped levels to a `tmp_path`, add `90_zerocool.yaml` (valid, order 99),
and assert `load_content`, `/api/levels` (via `TestClient` with
`UNDERCOOKED_LEVELS_DIR` pointed there), and `undercooked-levels list` **all**
include it, with no Python edited.
- assert no Python source under `src/undercooked/` contains a literal level id
(`grep`-style scan for `"01_first_kitchen"` etc.) — content lives in YAML only.
6. `docs/LEVELS.md` — append the "Authoring workflow" section: `new` → edit → `validate`
→ `axes`, with the overlay debugging trick and the rule-id table.
## Testing & Quality
- Unit: all subcommands, exit codes, cwd-independence, hardcoding scan.
- Coverage: **>90%** on `tools/levels.py`.
## Completion Criteria
- [ ] `uv run undercooked-levels validate` exits 0 from `/tmp` (not just the repo root)
- [ ] `uv run undercooked-levels list` prints both shipped levels in campaign order
- [ ] `uv run undercooked-levels show 01_first_kitchen --grid` prints a reachability overlay
- [ ] `uv run undercooked-levels validate --json | python -m json.tool` parses, `errors: []`
- [ ] `uv run pytest tests/unit/test_content_not_hardcoded.py -q` → green
- [ ] `bash .agents/validate.sh` runs the content gate and exits 0
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases