Scaffold project and author 14-phase roadmap

Browser co-op kitchen game: FastAPI + native WebSockets, server-authoritative
20 Hz simulation, in-memory anonymous lobbies, data-driven YAML levels, Canvas 2D
vanilla ES-module client with locally authored SVG art and no external CDNs.

.agents/PLAN.md records the LOCKED decisions; .agents/user_stories/ holds the 12
flow contracts; .agents/phases/todo/ breaks them into 14 sequential, test-gated
phases (71 task files), each leaving the app launchable and the suite green.

LLM-Generated: true
This commit is contained in:
2026-10-05 17:05:56 -04:00
commit f84c6ebaef
105 changed files with 5752 additions and 0 deletions
@@ -0,0 +1,50 @@
# Phase 11 — The ten-level campaign and progression
**Story:** `US-07_campaign_progression`
**Context:** `.agents/PLAN.md` §6 (levels), §12 (escalation axes), `docs/LEVELS.md`
## Objective
Ship the campaign: author levels 3–10 on the escalation axes, make progression (unlock,
best score, next-level flow) real across a session, and prove the curve climbs — every
level measurably harder than the last by the metrics, not by opinion.
## Dependencies
- `10_hazards_and_escalation_mechanics` (complete) — hazards, pressure model, solver/fairness hooks
- `05_anonymous_lobbies_and_room_lifecycle` (complete) — session progression store
- `07_canvas_client_and_hud` (complete) — level picker, result screen `Next level`
## Tasks
1. `01_author_levels_03_to_06.md` — two-room split, first conveyor, tight galley, spill kitchen
2. `02_author_levels_07_to_10.md` — firehouse, rush gauntlet, dark kitchen, the finale
3. `03_escalation_curve_gate.md` — per-level metrics computed and asserted monotonic non-decreasing
4. `04_campaign_flow_and_level_picker.md` — unlock gating, next/retry flow, best scores, host picker UX
5. `05_campaign_playthrough_suite.md` — scripted 10-level playthrough proving unlock order and persistence
## Testing & Quality
- Content: `uv run undercooked-levels validate` clean; manifest count test never hard-codes the
number of levels; every level's `id`, `order_book`, `hazards`, and `stars` documented in
`docs/LEVELS.md` with a doc-coverage test.
- Curve: `tests/sim/test_escalation_curve.py` — for consecutive levels, the composite difficulty
score (defined in the task) must be non-decreasing, with at least 7 of 9 strict increases
across the campaign; solver completion time must rise or the star target must rise.
- E2E: full campaign playthrough (fast-forced via the test clock), unlock gating enforced
server-side (a client cannot start a locked level), best score round-trip.
- Coverage: **>90%** on new/modified code.
- 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 complete `docs/LEVELS.md` (authoring guide + per-level sheet), `README.md` campaign
list, `docs/DESIGN.md` difficulty model.
## Completion Criteria
- [ ] 10 shipped levels validate clean, and `tests/` still contains no hard-coded level list
- [ ] `uv run pytest tests/sim/test_escalation_curve.py -q` → green (non-decreasing composite, ≥ 7 strict increases)
- [ ] `uv run pytest tests/e2e/test_us07_campaign_progression.py -q` → green (locked level refused server-side)
- [ ] a scripted session completes levels 1→10 in order with best scores persisted and displayed
- [ ] `docs/LEVELS.md` documents every level and every field (doc-coverage test green)
- [ ] 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
@@ -0,0 +1,47 @@
# Task 01 — Author levels 3–6
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
## Objective
Build the middle of the curve: four levels that each introduce exactly one new pressure and
teach it before testing it.
## Work
1. Author `levels/03_two_rooms.yaml` … `levels/06_spill_kitchen.yaml` to the schema, each with
`id`, `name`, `blurb`, `difficulty`, `time_limit`, `target_orders`, `order_book`,
`stations`, `hazards`, `stars`, `spawn_points`, `grid`:
- **03 Two Rooms** — kitchen split by a wall with two doorways; one side holds the chop +
pot, the other the stove + serve window; teaches route cost. Hazards: none. Difficulty 3.
- **04 Conveyor** — first `mode: conveyor` counter row (deterministic 1-tile-per-
`conveyor_period` shift, wraps at the end into a `drop_slot` that returns items to the
spawn shelf): teaches timing your grab. Difficulty 4.
- **05 Galley** — 14 × 6 corridor map, 2 spawn points, tight reach overlaps, no hazards but
`max_orders: 5` and a 2-recipe book; teaches contested space (the phase-09 arbitration
shows up here naturally). Difficulty 5.
- **06 Spill Kitchen** — `hazards.spills.enabled` with 3 `seed_cells`, `every_n_th_entry: 1`,
one sink shared by both halves; mop-or-lose tension. Difficulty 6.
2. Each level gets a fixture test in `tests/sim/test_level_playability.py` (parametrized by the
manifest, never by a hard-coded list): a greedy scripted bot reaches `target_orders` within
`time_limit` for 1–4 player counts, the star thresholds are strictly ordered
(`1 < 2 < 3` and 3-star requires beating the target by the documented margin), and the
reachable-set assertions from phase 02 hold.
3. `docs/LEVELS.md` — one sheet per level: intent, the one new pressure, layout ASCII map, the
metric snapshot from task 03 (filled by task 03, placeholder until then), and authoring notes.
4. Balance pass: create `tools/curve.py` now (stdlib + the sim package only) — it loads the
manifest, runs the greedy bot per player count, and prints the per-level metric table
defined in task 03; task 03 adds the monotonicity gate on top. Adjust `time_limit`,
`target_orders`, and `order_book` weights until the composite score is non-decreasing for
1→6, and record the numbers in the phase report.
## Testing & Quality
- Sim playability fixtures per level + validator; no hard-coded level lists anywhere.
- Coverage: **>90%** on any changed sim/validator code.
## Completion Criteria
- [ ] `uv run undercooked-levels validate` → clean with 6 levels
- [ ] `uv run pytest tests/sim/test_level_playability.py -q` → green for 1, 2, and 4 players on every shipped level
- [ ] each new level introduces exactly one new pressure (stated in `docs/LEVELS.md` and checkable from the YAML's diff vs the previous level)
- [ ] composite difficulty is non-decreasing from level 1 through 6 (numbers in the phase report)
- [ ] 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
@@ -0,0 +1,42 @@
# Task 02 — Author levels 7–10
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
## Objective
The back half: layered hazards and pressure, ending on a level that uses everything the
campaign taught.
## Work
1. Author `levels/07_firehouse.yaml` … `levels/10_finale.yaml`:
- **07 Firehouse** — high-burn recipes (`burn_ticks` tightened), 2 stoves, sink central,
`hazards.fire.enabled` with `max_fires: 3`; teaches extinguish-vs-cook triage. Difficulty 7.
- **08 Rush Gauntlet** — `hazards.rush.waves` (3 waves at 40 s / 95 s / 140 s,
`order_time_mult` 0.8 / 0.65 / 0.5, labels `LUNCH`, `RUSH`, `LAST CALL`), no physical
hazards; pure time pressure. Difficulty 7.
- **09 Obstacle Course** — 2 conveyor rows + 1 `pingpong` obstacle crossing the only bridge;
`hazards.spills` disabled to keep one variable legible. Difficulty 8.
- **10 Finale** — two rooms + conveyor + fire + a 2-wave rush + `dark_kitchen` modifier for
the final 30 s; 3 recipes; `time_limit: 200`; the everything-at-once test. Difficulty 10.
2. Same playability fixtures as task 01 (the parametrized file picks them up automatically) —
if the greedy bot cannot finish a level, that is a level bug (fix the level), unless the bot
genuinely lacks the mechanics (then say so in the report and add the missing bot behaviour to
`tests/sim/bots.py`, which is allowed).
3. `dark_kitchen` modifier: server effect is already defined in phase 10 task 04 — verify the
client overlay (`render/veil.js`) is driven **only** by the modifier flag in state (no
position hiding on the server; this stays a presentation effect, documented).
4. `docs/LEVELS.md` sheets for 7–10, plus an "everything the campaign teaches" table mapping
each mechanic to the level that introduces it and the level that tests it under pressure.
## Testing & Quality
- Sim playability + validator + curve numbers.
- Coverage: **>90%**.
- Full suite green.
## Completion Criteria
- [ ] `uv run undercooked-levels validate` → clean with 10 levels
- [ ] `uv run pytest tests/sim/test_level_playability.py -q` → green on all 10 levels, 1/2/4 players
- [ ] level 10 is completable by the bot with all four hazard systems active (asserted)
- [ ] `docs/LEVELS.md` has the mechanic-introduction table complete for all 10 levels
- [ ] 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
@@ -0,0 +1,45 @@
# Task 03 — Escalation curve gate
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
## Objective
Turn "harder" into a number, and make CI fail if the campaign stops climbing.
## Work
1. Define the composite difficulty score in `tools/curve.py` as an explicit, documented formula
over measurable axes (no vibes):
- `throughput_demand = target_orders / time_limit` (normalized),
- `route_cost` = mean bot path length per served order (cells),
- `contention` = `contest_losses + lock_waits` per 100 ticks from the bot run,
- `pipeline_depth` = mean recipe steps across the `order_book`,
- `hazard_load` = weighted count of active hazard systems × their intensity fields,
- `dirty_pressure` = sink round-trips per served order,
- `score = Σ w_i · axis_i` with the weights declared once in `tools/curve.py` (and mirrored in
`docs/DESIGN.md`), so a weight change is a deliberate, reviewable edit.
2. `tests/sim/test_escalation_curve.py`:
- loads the manifest, runs the bot at 2 players for every level, computes the axes,
asserts the composite is non-decreasing across `difficulty` order, with ≥ 7 of 9 strict
increases (equal scores allowed only for adjacent levels explicitly annotated
`difficulty_tie: <reason>` in the YAML — the annotation is validated too),
- asserts `stars` thresholds are strictly increasing per level and that 3 stars requires
beating `target_orders` by ≥ the documented margin,
- prints a table on failure (so a balance regression names the offending axis).
3. `tools/curve.py --report` writes the current table to `docs/LEVELS.md`'s metrics section
between `<!-- curve:begin -->` / `<!-- curve:end -->` markers; `tests/unit/test_docs_index.py`
extended to assert the markers exist and the table is not stale (regenerating produces
identical bytes).
4. Fix any level the gate flags by editing YAML, not the weights (weights move only with a
documented rationale in `docs/DESIGN.md`).
## Testing & Quality
- The gate itself is the deliverable; runtime < 120 s (mark `slow`, run in `validate.sh`).
- Coverage: **>90%** on `tools/curve.py`.
## Completion Criteria
- [ ] `uv run pytest -m slow tests/sim/test_escalation_curve.py -q` → green
- [ ] temporarily editing one level's `time_limit` to flatten the curve makes the test fail and names the axis (demonstrated, then reverted)
- [ ] `uv run python tools/curve.py --report` is idempotent (byte-identical on a second run)
- [ ] weights and axes documented in `docs/DESIGN.md` with the rationale for each
- [ ] 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
@@ -0,0 +1,46 @@
# Task 04 — Campaign flow, unlock gating, and the level picker
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
## Objective
Make progression real and tamper-proof: the server decides what is unlocked, the picker shows
it, and finishing a level offers the right next step.
## Work
1. Server (`rooms/session.py`, `server/rooms.py`):
- `unlocked_levels(session_id)` derived from `best_scores` (a level unlocks when the previous
one is completed at ≥ 1 star) with `levels/campaign.yaml` order as the source of truth;
- `set_level` **and** `start` reject a level the starting player's session has not unlocked
→ `err(level_locked)` with the payload naming the highest unlocked id; spectating/host
changes never bypass it, and a room whose members have different progress uses the **host's**
progress (documented, and shown in the picker as "host progress");
- `/api/session` gains `unlocked: [...]`, `next_level`, `campaign_complete: bool`;
- `result` payload gains `unlocked_next` (`id | null`) and `campaign_complete`.
2. Client:
- level picker: locked levels visible-but-dimmed with the unlock condition as a tooltip,
best score + stars per level, `difficulty` pips, and the `blurb`; keyboard navigable,
`aria-disabled` rather than hidden;
- result screen: `Next level` only when `unlocked_next`, focus moves to the primary action,
`Retry` keeps the room and level, `Back to lobby` returns to `LOBBY`, and a
`campaign_complete` celebration panel (non-blocking, dismissible, announced);
- "host progress" badge plus a note when your own progress differs.
3. `tests/server/test_unlock_gating.py` — unlock derivation, `set_level`/`start` rejection with
the exact error code, host-progress rule, `campaign_complete`, a spoofed `set_level` for a
locked id over a real socket refused.
4. `tests/e2e/test_us07_picker.py` — locked level not startable (control disabled + server
refusal if forced), best score/stars render, `Next level` appears only when unlocked,
keyboard-only navigation reaches every control, screen-reader text present for locked state.
## Testing & Quality
- Server + e2e; the spoofed-locked-level test is the security-relevant one.
- Coverage: **>90%** on new/modified code.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/server/test_unlock_gating.py tests/e2e/test_us07_picker.py -q` → green
- [ ] forcing a locked `set_level` over the wire is refused with `level_locked` (asserted)
- [ ] `Next level` / `Retry` / `Back to lobby` behave correctly and are keyboard reachable
- [ ] campaign completion state is visible and announced once
- [ ] 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
@@ -0,0 +1,48 @@
# Task 05 — Full campaign playthrough suite
**Phase:** `11_campaign_levels_and_progression` · **Story:** `US-07_campaign_progression`
## Objective
One suite that plays the whole campaign start to finish and proves the progression story holds
end to end, in a browser, with real sockets.
## Work
1. `tests/e2e/test_us07_campaign_progression.py` (marked `slow`):
- a single browser context + session token plays levels 1→10 via the bot driver
(`tests/e2e/bot_driver.py`: reuses the sim bot, replaying its input intents through the
real input channel rather than poking the server), finishing each level, then clicking
`Next level`;
- asserts after every level: result screen shows stars and best score, the next level became
unlocked (picker state + `/api/session`), `unlocked_next` matches, and the session record
advanced;
- asserts a retry after a "lost" round (forced by a test-only `?fail=1` flag that makes the
bot idle) does not lose progress and does not re-unlock anything;
- asserts mid-campaign disconnect (socket kill between levels) preserves progress on rejoin;
- asserts a second browser context with a fresh token sees level 1 only, and that the two
sessions do not leak into each other;
- finishes the campaign and asserts the completion panel + `campaign_complete` in
`/api/session`.
2. Runtime discipline: the suite uses the test clock (`UNDERCOOKED_TIME_SCALE=8`, honored only
when `UNDERCOOKED_TEST_MODE=1`) so 10 levels run in < 4 minutes; if `TIME_SCALE` does not
exist yet, implement it as a room-level tick-source change (the sim's `STEP_DT` never
changes — only how fast ticks are pulled), documented in `docs/SIMULATION.md`, with unit
tests that a scaled round produces identical state to an unscaled one.
3. `tests/server/test_time_scale.py` — scaling changes wall-clock pacing only: same input
script, same tick count, same final hash, half the wall time (± tolerance).
4. `tests/e2e/README.md` — how to run just the campaign suite, and how to shorten it
(`-k "level_0"` for the first three).
## Testing & Quality
- E2E `slow` campaign suite + time-scale unit/server tests; the whole thing must be deterministic
(fixed seeds, no wall-clock assertions tighter than 3× tolerance).
- Coverage: **>90%** on new/modified code.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest -m slow tests/e2e/test_us07_campaign_progression.py -q` → green in < 6 minutes
- [ ] `uv run pytest tests/server/test_time_scale.py -q` → green with identical final state hash scaled vs unscaled
- [ ] progress survives a between-levels socket kill (asserted)
- [ ] a fresh session sees only level 1 (asserted), and sessions are isolated
- [ ] 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