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,56 @@
# Phase 10 — Hazards and pressure mechanics
**Story:** `US-08_hazards_under_pressure`
**Context:** `.agents/PLAN.md` §5 (hazards), §6 (`hazards` level block), `docs/SIMULATION.md`
## Objective
Add the mechanics that create the panic: fire that spreads and is extinguishable, spills
that make cooks slip, moving obstacles that stun, and rush waves that shorten the clock —
each with a warning window, a counter-play, and an explicit reduced-motion presentation.
Every mechanic is authored through the level YAML `hazards` block, never hard-coded per
level.
## Dependencies
- `09_co_op_two_player_e2e` (complete) — arbitration, station locks, multi-context harness
- `04_kitchen_pipeline_and_scoring` (complete) — order/score/spoil systems hazards plug into
- `07_canvas_client_and_hud` (complete) — event→FX pipeline, reduced-motion mode
## Tasks
1. `01_fire.md` — ignition, spread rules, extinguishing (sink/trash), damage to items and orders
2. `02_spills_and_slipping.md` — spill creation, slip stun, recovery, cleaning loop
3. `03_moving_obstacles.md` — path-driven obstacles, telegraph, stun, item knock-off
4. `04_rush_waves_and_pressure_modifiers.md` — timed waves, shortened order timers, spawn-rate ramps, level-declared modifier table
5. `05_hazard_presentation_and_warnings.md` — telegraphs, FX, audio-free cues, reduced-motion alternates, onboarding tooltip for the first hazard
6. `06_hazard_determinism_and_validator_hardening.md` — determinism under hazard fuzz, validator rules for the new hazard fields, `docs/SIMULATION.md` rules
## Testing & Quality
- Unit (sim): every hazard rule with a fake clock; spread/stun/wave boundaries; interaction
matrix (fire × carried item, spill × soft push, obstacle × parked disconnected cook).
- Sim determinism: 3 000-tick hazard-heavy replay reproduces hash exactly; a hazard seeded
run is bit-identical across two `Room` instances.
- E2E (`test_us08_hazards_under_pressure.py`): fire visible + extinguishable by a player,
slip visibly stuns and is recoverable, obstacle telegraph appears ≥ its warning window
before contact, rush wave visibly shortens the timer, and reduced-motion mode still shows
every hazard as a static marker.
- Safety floor: hazards never kill, never permanently block the serve window, and never make
a level unwinnable (asserted by the phase-11 solver reuse here on the hazard levels).
- 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 update `docs/SIMULATION.md` (hazard rules), `docs/LEVELS.md` (every hazard field and
its defaults), `docs/CLIENT.md` (warning/FX and reduced-motion mapping), `README.md`
(tips: extinguish, mop, watch the telegraph).
## Completion Criteria
- [ ] `uv run pytest tests/sim -q -k "fire or spill or obstacle or rush"` → green
- [ ] `uv run pytest tests/e2e/test_us08_hazards_under_pressure.py -q` → green (all mechanics, both motion modes)
- [ ] 3 000-tick hazard-heavy replay is hash-identical to the golden and across two room instances
- [ ] every hazard field in `levels/*.yaml` is documented in `docs/LEVELS.md` and rejected by the validator when out of range (schema test)
- [ ] no level becomes unwinnable with its hazards enabled (solver bound re-run)
- [ ] 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,53 @@
# Task 01 — Fire
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
## Objective
Fire as a fair, readable threat: it starts from burnt food, spreads to adjacent
combustible tiles on a schedule, damages what is on it, and is put out with water.
## Work
1. `src/undercooked/sim/hazards/fire.py`:
- `IgnitionSource`: a `BURNT` item on a `stove`/`chop`/`prep` tile ignites that tile with
probability 1 (deterministic, no RNG — reproducibility beats realism here);
- `HazardState {kind=FIRE, cell, stage, ticks_in_stage, fuel}`; stages
`SPARKING` (warning, `spark_ticks` from level YAML, default 30) → `BURNING` →
`OUT`/`EXTINGUISHED`;
- spread: while `BURNING`, every `spread_ticks` (level field, default 40) ignite one
adjacent `COMBUSTIBLE` tile (counters, not walls/void) in a deterministic order
(N, E, S, W) — cap the simultaneous fire count per level (`max_fires`, default 4) so a
kitchen can't cascade into an unwinnable state;
- effects: items on a `BURNING` tile are destroyed (`WASTED` counter, `ITEM_BURNED`
event); a cook entering takes a `STUNNED(short)` and drops the carried item onto the
nearest safe tile (never deleted); orders are never destroyed by fire directly — the
player loses the ingredients, not the order;
- extinguish: interacting with a `sink` (or `trash`) while `action_held` and adjacent to
the fire cell puts it out (`FIRE_OUT` event), costs one `dirty` dish if the cook is
holding nothing (the bucket is dirty — keeps the sink loop meaningful); water has a
cooldown (`water_cooldown_ticks`, default 10) so it cannot be spam-toggled;
- disconnected/parked cooks never spread or extinguish fire.
2. Level fields (`level.py` + validator + `docs/LEVELS.md`): `hazards.fire.{enabled,
spark_ticks, spread_ticks, max_fires, water_cooldown_ticks}` with bounds, and the
validator rule: if `fire.enabled`, the level must have ≥ 1 `sink` reachable from every
fire-capable tile (BFS) — no fire without a way to fight it.
3. Events: `FIRE_IGNITED`, `FIRE_SPREAD`, `FIRE_OUT`, `ITEM_BURNED` (+ client FX mapping in
`render/fx.js`, with static markers under reduced motion).
4. `tests/sim/test_fire.py` — ignition from burnt food, stage timing at each boundary,
spread order and cap, item destruction + counter, cook stun + item relocation (never
lost), extinguish path + cooldown, no spread to walls/void/serve window, parked
disconnected cook is inert, 3 000-tick determinism with fire enabled.
5. `tests/unit/test_level_validator_fire.py` — the "fire requires a reachable sink" rule
(positive + two negatives), out-of-range field rejection, defaults filled.
## Testing & Quality
- Unit (sim) + validator tests; boundaries at `spark_ticks-1/spark_ticks/+1`.
- Coverage: **>90%** on `sim/hazards/fire.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_fire.py tests/unit/test_level_validator_fire.py -q` → green
- [ ] fire never exceeds `max_fires` and never spreads to non-combustible tiles (asserted)
- [ ] a carried item displaced by fire always lands on a reachable tile or is counted in `lost_items` (asserted, never silently dropped)
- [ ] `uv run undercooked-levels validate` passes shipped levels with fire enabled
- [ ] 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,50 @@
# Task 02 — Spills and slipping
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
## Objective
Wet floors: dropped/burst contents make a spill, stepping in it slips you, and mopping is a
real chore that competes with cooking.
## Work
1. `src/undercooked/sim/hazards/spill.py`:
- creation: `ITEM_BURNED`, a dropped `pot`/`pan` with contents, an ingredient destroyed by
an obstacle, and level-declared `hazards.spills.seed_cells` (used by level 5) each create
a `HazardState {kind=SPILL, cell, life_ticks}`; multiple spills do not stack on one cell
(life refreshes instead);
- slip: a cook entering a spill cell rolls a slip (`slip_chance` expressed deterministically
as `every_n_th_entry`, default 1 — a spill always slips you on the level that has them, so
the mechanic is legible; per-level tunable) → `STUNNED(slip_stun_ticks, default 20)` plus a
short scripted slide in the entry direction until a blocking tile (slide never enters
`VOID`, never crosses a wall, capped at `max_slide_cells`, default 2);
- slide carries the carried item with the cook (it does not fall off mid-slide) unless the
slide ends adjacent to a `BURNING` tile;
- cleanup: interacting with the spill while `action_held` cleans it (`SPILL_CLEANED`) and adds
one `dirty` dish to the sink pool (mop/bucket realism), or the cook can walk the dirty-dish
route — both are viable; cleanup has a `clean_cooldown_ticks` (default 6);
- spills decay after `life_ticks` (default 300) so an ignored kitchen does not become
permanently unplayable; decay emits `SPILL_GONE`.
2. Level fields: `hazards.spills.{enabled, every_n_th_entry, slip_stun_ticks,
max_slide_cells, life_ticks, clean_cooldown_ticks, seed_cells[]}`; validator: `seed_cells`
must be `FLOOR` and reachable from ≥ 2 spawn points (a spill you can never clean is a bug).
3. Events `SPILL_CREATED`, `SLIPPED`, `SPILL_CLEANED`, `SPILL_GONE` + FX (decal, slide dust,
static marker under reduced motion).
4. `tests/sim/test_spills.py` — creation sources, no stacking, slip + slide boundaries
(`max_slide_cells`, wall stop, void stop), carried item stays through the slide, cleanup +
cooldown + dirty-dish accounting, decay, deterministic entry counting, 3 000-tick replay
with spills seeded.
5. `tests/unit/test_level_validator_spills.py` — `seed_cells` must be floor and reachable;
bounds rejection.
## Testing & Quality
- Unit (sim) + validator; slide math tested with a wall and void fixture map.
- Coverage: **>90%**.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_spills.py tests/unit/test_level_validator_spills.py -q` → green
- [ ] a slide never enters a wall or void and never exceeds `max_slide_cells` (asserted on a fixture map)
- [ ] cleanup always adds exactly one dirty dish and respects the cooldown (asserted)
- [ ] an ignored spill decays and the round stays winnable (asserted)
- [ ] 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,55 @@
# Task 03 — Moving obstacles
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
## Objective
Things that move through the kitchen on rails: a telegraph, a stun on contact, and a clear
way to avoid them.
## Work
1. `src/undercooked/sim/hazards/obstacle.py`:
- authored as `hazards.obstacles[]: {id, path: [[x,y],...], speed_cells_s, mode:
pingpong|loop, period_s?, offset_s?, stun_ticks}`; position is a pure function of the
tick (`pos_at(tick)`) — no integration drift, trivially replayable and testable;
- telegraph: `TELEGRAPH_TICKS` (default 12) before entering a cell, that cell is flagged
in the snapshot (`hazard_cells` with intensity) so the client can draw the warning tile
before the obstacle is there;
- contact: a cook in the obstacle's cell (or the telegraph cell at contact time) takes
`STUNNED(stun_ticks)` and drops the carried item one tile back along their movement
vector (onto a reachable tile, else `lost_items` + `ITEM_LOST`); the obstacle never
moves a cook through a wall;
- items on the path are knocked, not destroyed (destroy only if the destination is
`BURNING`, reusing the fire rule);
- orders and stations are unaffected; an obstacle never occupies the serve-window tile or
a spawn tile while a cook is spawning;
- two obstacles on intersecting paths pass through each other (documented, avoids
deadlock); obstacle vs. parked disconnected cook = stun on the ghost, no effect on state
beyond the counter.
2. Snapshot/protocol: `hazard_cells` array (`{x, y, kind, intensity}`) added to the state
encoding — a wire change, so bump `PROTOCOL_VERSION`, update `docs/PROTOCOL.md`,
`tests/unit/test_protocol_doc_sync.py`, and the client renderer's hazard layer.
3. Level fields + validator: `path` cells in bounds and traversable (floor, not wall/void),
`speed_cells_s` within `0.5..4`, at least one cell on the path reachable by no cook at
spawn, obstacles cannot fully separate the serve window from the spawns at any tick
(BFS over the union of blocked cells across one full period) — this is the rule that keeps
levels fair and is the interesting part of the validator.
4. `tests/sim/test_obstacles.py` — `pos_at` periodicity/ping-pong correctness, telegraph
timing, stun + knockback placement, no wall traversal, path never disconnects the kitchen
(property check over one period), two obstacles passing, determinism across two rooms.
5. `tests/unit/test_level_validator_obstacles.py` — bad path, bad speed, unreachable-path,
kitchen-severing path (each rejected with the specific message).
6. `tests/e2e/test_us08_obstacle_telegraph.py` — the warning tile is visible ≥ telegraph
window before contact, contact stuns visibly, reduced motion still shows the marker.
## Testing & Quality
- Unit (sim) + validator + one e2e; the path/BFS checks are property-tested over a full period.
- Coverage: **>90%** on `obstacle.py` and the new validator rules.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_obstacles.py tests/unit/test_level_validator_obstacles.py -q` → green
- [ ] no obstacle path ever disconnects the serve window from the spawns (asserted over a full period)
- [ ] the telegraph precedes contact by exactly `TELEGRAPH_TICKS` (asserted) and the e2e sees it
- [ ] `PROTOCOL_VERSION` bump + doc sync test green
- [ ] 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,49 @@
# Task 04 — Rush waves and pressure modifiers
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
## Objective
Escalate inside a single round: timed waves that shorten clocks and raise the spawn rate,
declared in level YAML so every level composes pressure from the same vocabulary.
## Work
1. `src/undercooked/sim/hazards/rush.py`:
- `hazards.rush.waves[]: {at_s, duration_s?, order_time_mult, spawn_mult?, max_orders_delta?,
label?}`; effects applied as multipliers over the level's base order budget, computed in
`orders.py` via a `PressureModel` the round step consults (no ad-hoc timer edits);
- deterministic by tick: `pressure_at(tick)` is a pure function; overlapping waves compose
multiplicatively with a documented clamp (`order_time_mult ≥ 0.35`, `spawn_mult ≤ 3.0`) so
a level cannot author an impossible squeeze;
- `RUSH_START` / `RUSH_END` events with the `label` for the HUD banner; the HUD shows the
active wave name and the modified timer scale;
- `hazards.modifiers` (existing level field) wired to the same hook: `slow_cook`,
`fast_spoil`, `no_plates_until_wash`, `dark_kitchen` (vision-limited client overlay,
server unchanged), each a named, tested rule with a `docs/SIMULATION.md` entry — a
modifier that is declared but unimplemented fails the validator.
2. Validator + `docs/LEVELS.md`: wave bounds, monotonic `at_s`, clamp documentation, and a
rule that a level's total `order_time_mult` schedule cannot drop below the solver's minimum
feasible window (reuses the phase-11 solver hook if present; otherwise the static bound
`time_limit × min(order_time_mult) ≥ shortest_recipe_seconds × 2`).
3. `tests/sim/test_rush_waves.py` — `pressure_at` step boundaries at `at_s`, `at_s+durations`,
clamp behaviour, overlap composition, event emission once per wave, no wave mutating
already-spawned orders (documented: pressure affects new orders only), determinism.
4. `tests/sim/test_modifiers.py` — one test per modifier, plus "declared-but-unimplemented
fails".
5. `tests/e2e/test_us08_rush_wave.py` — HUD banner appears at the wave, the order time bar
visibly shorter for a post-wave order, and the round remains winnable.
6. `docs/SIMULATION.md` — pressure model as a formula, and the "pressure never retroactively
shortens a live order" rule.
## Testing & Quality
- Unit (sim) + validator + e2e; boundary tests at every `at_s`.
- Coverage: **>90%**.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_rush_waves.py tests/sim/test_modifiers.py -q` → green
- [ ] pressure affects only orders spawned after the wave (asserted)
- [ ] clamps hold for a deliberately over-aggressive test level (asserted, not clamped away silently)
- [ ] declaring an unknown/unimplemented modifier fails `uv run undercooked-levels validate`
- [ ] `uv run pytest tests/e2e/test_us08_rush_wave.py -q` → green
- [ ] 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,49 @@
# Task 05 — Hazard presentation, warnings, and reduced motion
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`, `US-12_play_accessibly`
## Objective
Make every hazard readable at a glance on any screen and without motion — a player must be
able to survive hazards they cannot animate.
## Work
1. `app/static/js/render/hazards.js` — draw from `hazard_cells`: intensity ramp, telegraph
(dashed outline + 3-pip countdown), fire stages, spill decal, obstacle body; obstacles
drawn on the player layer so they are never hidden behind a counter.
2. Non-color, non-motion redundancy (required, not optional):
- each hazard kind has a distinct glyph (flame / droplet / arrow) drawn at the cell,
- a textual status strip in the HUD (`FIRE ×2`, `WET FLOOR`, `CONVEYOR`, `RUSH: ORDER RUSH`)
with `aria-live="polite"` on appearance only (not every tick),
- `data-motion="reduced"`: no pulsing, no screen shake, no particle storm — static markers,
full-opacity glyphs, telegraph shown as a filled tile instead of an animation,
- colorblind palette keeps hazards distinguishable (guard: hazard glyph set must be drawn
even when colors are forced to grayscale in the test).
3. First-hazard onboarding: the first time a level's hazard appears, a one-time caption
("Fire! Use the sink to put it out") for 3 s, dismissible, suppressed for repeats and
stored per session in `sessionStorage` — no tutorial screens.
4. `assets_src/fx.svg` extensions (flame stages, droplet, arrow, telegraph pips) regenerated
into `index.json` by `tools/gen_art_index.py` (extend `EXPECTED_IDS`).
5. `tests/e2e/test_us08_hazard_presentation.py`:
- telegraph pixel region changes before contact (variance sample, not screenshot equality),
- HUD status strip lists the active hazard kinds within 200 ms,
- reduced-motion context: no CSS `transform: translate` shake on the canvas wrapper, glyphs
still present (probe: draw the reduced-motion frame and assert non-background pixels at
the hazard cell),
- caption appears once and not again after a retry,
- grayscale filter context: hazard cells still differ from non-hazard cells by luminance.
6. `docs/CLIENT.md` + `docs/SIMULATION.md` cross-reference: the redundancy contract (glyph +
text + color, never color alone).
## Testing & Quality
- E2E with four contexts (normal, reduced motion, forced grayscale, colorblind palette).
- Coverage: **>90%** on any server-side change (none expected beyond event payloads).
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us08_hazard_presentation.py -q` → green across all four contexts
- [ ] every hazard kind has a glyph, a HUD text label, and a distinct color (a test walks the hazard enum against the mapping)
- [ ] reduced motion removes shake/pulses but keeps every hazard visible (asserted by pixel probe)
- [ ] the first-hazard caption shows once per session and is dismissible
- [ ] 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,46 @@
# Task 06 — Hazard determinism, fuzz, and validator hardening
**Phase:** `10_hazards_and_escalation_mechanics` · **Story:** `US-08_hazards_under_pressure`
## Objective
Prove the hazard suite cannot desync, cannot wedge a round, and cannot be authored into an
unwinnable level.
## Work
1. `tests/sim/test_hazard_determinism.py`:
- property-based (hypothesis-free, seeded random generators are enough) fuzz: 200 generated
hazard configurations (random fire/spill/obstacle/rush combos on the shipped maps) ×
600 ticks with 4 scripted bots; assert two runs of the same config+seed produce identical
state hashes and identical event logs;
- no-crash sweep: NaN/inf checks on every numeric field, `stun` never negative, hazard
counts never exceed caps, no exception escapes `step()`;
- invariants every run: serve window reachable, ≥ 1 spawn tile free, item count + wasted +
lost conserved, dirty-dish pool ≤ plates total.
2. `tests/sim/test_hazard_fairness.py` — for each hazard level, the solver bound re-runs with
hazards enabled: the level is still finishable within `time_limit` and the star thresholds
stay achievable (assert the same invariant phase 11 establishes; if the solver hook does not
exist yet, use a scripted greedy bot that must reach `target_orders`).
3. Validator extension (`level.py`) + tests: cross-field rules that only make sense once all
hazards exist — fire enabled but no sink (phase 10 task 01) plus: spill seeds plus obstacles
may not block the only path to the sink; `max_fires` ≤ combustible tiles; a level with ≥ 2
hazard kinds must have `time_limit ≥ 100` (pressure needs room); `rush` waves must not start
before `t=20 s`.
4. `docs/SIMULATION.md` — "Hazard invariants" section listing exactly the properties the fuzz
asserts (this is the contract future phases must not break).
5. Replay golden: commit `tests/sim/goldens/hazards_3000s.json` (config, seed, hash chain
sample, final hash) produced by a documented `uv run python tools/record_hazard_golden.py`.
## Testing & Quality
- Fuzz + invariant + golden; the suite must run in < 90 s (mark `slow` if it exceeds that, and
still run it in `validate.sh`).
- Coverage: **>90%** on new/modified code; hazard modules ≥ 95%.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_hazard_determinism.py tests/sim/test_hazard_fairness.py -q` → green
- [ ] `uv run python tools/record_hazard_golden.py` regenerates the golden byte-identically (run twice, compare hashes)
- [ ] corrupting one generated hazard config in a scratch copy makes a validator rule fire (demonstrated, then reverted)
- [ ] every invariant in `docs/SIMULATION.md` names the test that enforces it
- [ ] 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