phase: 03_simulation_movement_and_interaction

**Final verification pass — phase 03 `simulation_movement_and_interaction` (all 5 task files already in `complete/`)**

- Verified all phase-03 deliverables exist and work: `sim/` kernel (`step`, `state_build`, `rng`, `movement`, `collision`, `interact`, `carry`, `replay`), replay fixtures + 3 committed goldens, `docs/SIMULATION.md`.
- `bash .agents/validate.sh` → **passed**: ruff `All checks passed!`, pyright `0 errors`, `undercooked-levels validate` green, full `pytest` **1033 passed** (coverage **99.71%** ≥ 90% floor), slow sim stage green, repo-hygiene clean.
- Perf smoke (4 players, 2 000 ticks): mean **0.049 ms/tick, 20 423 ticks/s** — ≥ 10 000 ticks/s criterion met (test lives in `test_perf_smoke.py`, which the gate's slow stage runs; the overview names it `test_replay.py`).
- Completion criteria: import `undercooked.sim.step.step` ✅ · `tests/sim` green incl. determinism + goldens ✅ · perf ✅ · constant-change-fails-by-design ✅ (committed-hash equality + mutation test naming first bad tick/field; goldens never rewritten by tests) · `docs/SIMULATION.md` ✅ (constants, 8-step order, purity/determinism, re-bless) · full suite + coverage ✅ · app launches: `/healthz` 200, `/` 200, `/api/levels` serves content ✅ · replay CLI `check walk_solo.json` exit 0 ✅ · no regression in phases 01/02 (their suites green) ✅.
- Documentation: README.md — fixed one stale inconsistency (Status opening listed only 01/02 complete with 03 pending; now lists 01–03 complete, next pending `04_kitchen_pipeline_and_scoring`); all other README claims (CLI flags, config table, shell text, replay CLI exit codes) verified against code. AGENTS.md — verified consistent with the project as it stands, no changes needed.
- No deviations. Next pending phase: **`04_kitchen_pipeline_and_scoring`**.
This commit is contained in:
2026-10-07 02:46:10 -04:00
parent 3426264ffb
commit 4febdfe780
41 changed files with 17690 additions and 23 deletions
@@ -1,52 +0,0 @@
# Phase 03 — Simulation: fixed-step kernel, movement, interaction
**Story:** `US-04_move_and_cook` (server-side half)
**Context:** `.agents/PLAN.md` §5 (movement, interaction model), A4, A5, A21
## Objective
Stand up the deterministic simulation kernel: a pure `step(state, dt)` driven at 20 Hz
with seeded randomness, tile+body collision, reach-based targeting, and pick/carry/drop
semantics — all reproducible from a seed plus an input script, proven by golden hashes.
## Dependencies
- `02_level_content_and_validation` (complete) — `Level`, `Grid`, `RecipeBook`,
`KitchenState`, `Vec2`, `Event`
## Tasks
1. `01_fixed_step_kernel.md` — `build_state`, `step(state, dt)`, subsystem order, seeded pure RNG
2. `02_movement_and_collision.md` — velocity integration, tile AABB + player soft-push resolution, facing, stun
3. `03_interaction_targeting.md` — front-tile + forgiveness-radius target resolution, verb selection, `DENIED` reasons
4. `04_pickup_carry_drop.md` — one-item carry rules, handoff via counters, trash, deterministic contested resolution
5. `05_replay_harness_and_goldens.md` — input-script format, golden hash fixtures, perf smoke
## Testing & Quality
- Unit: integration/clamping math, collision against every tile kind and another player,
facing rules, target priority ordering, every `DeniedReason`, RNG reproducibility and
independence of two states with the same seed.
- Determinism (`tests/sim/test_determinism.py`): same seed + same script → identical
`state_hash()`; **different** seed → different hash; running 2×50 ticks equals running
1×100 ticks at the same total time within the documented tolerance, and exactly equals
it when using the same substep count (documented, asserted).
- Replay (`tests/sim/test_replay.py`): scripted 600-tick walk + pickup + drop matches a
committed golden hash; the golden regenerates only when the sim contract intentionally
changes, and the test prints the diff hint when it breaks.
- Perf smoke (`slow`): 4 players, 2000 ticks ≤ 400 ms total (≥ 20 Hz headroom).
- Coverage: **>90%** on new/modified code; `sim/movement.py` and `sim/interact.py` ≥ 95%.
- 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` (new) as they land: constants table, step order,
determinism contract, denied-reason list.
## Completion Criteria
- [ ] `uv run python -c "from undercooked.sim.step import step"` succeeds
- [ ] `uv run pytest tests/sim -q` → green, including determinism + golden replay
- [ ] `uv run pytest -m slow tests/sim/test_replay.py -q` shows ticks/s ≥ 10 000 for a 4-player kitchen
- [ ] changing a sim constant without regenerating the golden fails the suite (demonstrated by the test design, not by hand)
- [ ] `docs/SIMULATION.md` documents the step order, constants, and the determinism contract
- [ ] 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,66 +0,0 @@
# Task 01 — Fixed-step kernel and seeded RNG
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
## Objective
Create the loop that everything else plugs into: `build_state(level, players, seed)` and
`step(state, dt) -> (state, events)`, with a deterministic RNG and a fixed subsystem
order.
## Work
1. `src/undercooked/sim/rng.py` — a pure, seedable PRNG (splitmix64 or xoshiro128+; no
stdlib `random`, which is not portable across versions):
`Rng(state: int)` with `next_u64()`, `next_float() -> float` in [0,1),
`range_int(lo, hi)`, `choice(seq)`, `fork(salt: int) -> Rng`; returns a new `Rng`
(immutable state threading). Golden vector test with fixed expected values.
2. `src/undercooked/sim/state_build.py`:
- `build_state(level: Level, seats: Sequence[SeatSpec], seed: int, book: RecipeBook) -> KitchenState`
placing stations from `level`, spawning players at `level.spawns` (seat index →
spawn, wrapping for > spawns), `time_left = level.time_limit`,
`target_score`, `orders=()`, `hazards=()` (populated in phase 10), `rng_state = seed`.
- `SeatSpec(player_id: str, seat: int, display_name: str, color: str)` with a fixed
4-color palette constant.
- Spawn assignment wraps when there are more seats than spawns (`seat % len(spawns)`)
and offsets stacked players by 0.25 tile so nobody starts overlapping; raise
`StateBuildError` only when the level defines zero spawns.
3. `src/undercooked/sim/step.py`:
- `STEP_DT` constant (0.05) and `step(state, dt, inputs: Sequence[PlayerInput] = ()) -> tuple[KitchenState, tuple[Event, ...]]`
with a documented, fixed order:
1. clamp `dt` to `[0, MAX_DT = 0.1]` (never spiral), accumulate fractional ticks via
`state.accumulator` so the sim advances in exact `STEP_DT` substeps,
2. apply inputs (movement intent + action requests) — earliest-received wins,
tie-break by seat,
3. movement + collision (`movement.step_positions`),
4. station progress (`stations.step` — phase 04 makes it real; now an identity hook),
5. orders (`orders.step` — phase 04 hook),
6. hazards (`hazards.step` — phase 10 hook),
7. round timer + end conditions (`round_state.step` — phase 04 hook; timer counts down now),
8. `tick += 1`.
Hooks are no-op functions in this phase, each in its own module with a docstring
naming the phase that fills it in.
- `PlayerInput(player_id, seq, move: Vec2, action: bool, face: Facing | None,
received_at_tick: int)` frozen dataclass; unknown `player_id` → `err` event, never a
crash.
- Never mutates the input state: build a new `KitchenState` per substep (dataclasses
with `replace`/explicit rebuild; a `_draft` mutable helper is allowed internally as
long as the public function is pure).
4. `docs/SIMULATION.md` — constants table (`STEP_DT`, `MAX_DT`, `PLAYER_RADIUS`,
`MAX_SPEED`, `ACCEL`, `REACH`, `HANDLING_S`), the 8-step order, purity + determinism
contract, and how to regenerate goldens.
5. `tests/sim/test_step_kernel.py` — dt clamping, substep accumulation (17 ticks over
0.85 s at 0.05), unknown player id, purity (input state object unchanged after step),
two states from the same seed stay identical over 200 steps, `tick` monotonic.
6. `tests/sim/test_rng.py` — golden vectors, `fork` determinism, distribution sanity
(10 000 draws within tolerance), immutability.
## Testing & Quality
- Unit: as listed above; RNG is golden-vector locked.
- Coverage: **>90%** on `rng.py`, `step.py`, `state_build.py`.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_rng.py tests/sim/test_step_kernel.py -q` → green
- [ ] `uv run python -c "from undercooked.sim.step import step; from undercooked.sim.state_build import build_state"` succeeds
- [ ] the purity guard test (`tests/sim/test_purity.py` from phase 02) still passes with the new modules
- [ ] 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,55 +0,0 @@
# Task 02 — Movement, collision, facing, stun
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
## Objective
Make cooks walk: analog velocity, axis-separated tile collision, soft player push,
facing, and stun handling — deterministic and cheap.
## Work
1. `src/undercooked/sim/movement.py`:
- Constants: `MAX_SPEED = 3.5` tiles/s, `ACCEL = 30.0`, `DECEL = 40.0`,
`RADIUS = 0.35`, `DIAGONAL_NORM = True`.
- `desired_velocity(player, intent: Vec2, dt, modifiers: MoveModifiers) -> Vec2` —
accelerate toward the intent direction (normalized so diagonals are not faster),
decelerate to zero when the intent is zero, apply `MoveModifiers(speed_scale,
accel_scale, slide)` (used by spills in phase 10).
- `resolve_moves(state, dt) -> tuple[tuple[Player, ...], tuple[Event, ...]]` —
integrate X then Y separately, clamping against blocked tiles (`WALL`, `COUNTER`,
`VOID`, out-of-bounds) using AABB-vs-circle-as-square resolution; then resolve
player↔player overlap with a symmetric soft push (mass 1, positional correction
only, no velocity injection), iterating twice for chains, falling back to reverting
the later-received input on a deadlock.
- `update_facing(player, intent, action) -> Facing` — facing follows the last nonzero
intent direction; an action press with zero intent keeps facing.
- `apply_stun(players, dt)` — decrement `stun`, zero velocity while stunned, emit
`EventKind.STUNNED` once on entry.
- Guarantees to assert in docstrings and tests: a player never ends a tick inside a
blocked tile; no tunneling at 2× `MAX_SPEED`; two players never share an overlap
after resolution; a stunned player cannot move.
2. `src/undercooked/sim/collision.py` — pure helpers: `cell_blocked(grid, x, y, extra_blocked)`,
`sweep_axis(pos, delta, radius, blocked) -> float` (max allowed delta), `body_cells(pos, radius)`,
`station_body_cells(station)` (stations occupy their tile as `COUNTER`-like blockers
unless `kind in {SERVE, SINK, TRASH}` where the cook may stand adjacent-adjacent).
3. Update `step.py` to call `movement.step_movement(state, dt)` in slot 3 (replace the
phase-03-task-01 hook) and feed `intent` from `PlayerInput.move`, clamping the intent
vector length to ≤ 1 server-side (a client cannot send speed 50).
4. `tests/sim/test_movement.py` — table-driven: walk into each tile kind, corner catch,
diagonal speed parity, soft push with 2 and 4 players, stun blocks movement, no
tunneling at `dt = MAX_DT` and doubled `MAX_SPEED`, intent longer than 1 is clamped,
out-of-bounds containment, `MoveModifiers` effect.
5. `tests/sim/test_movement_stress.py` — 4 players, 2 000 ticks of pseudo-random walk
from a fixed seed: assert no player is ever inside a blocked tile and no two players
overlap by more than 2×`RADIUS` at any tick.
## Testing & Quality
- Unit: everything listed; stress test asserts invariants rather than exact positions.
- Coverage: **>90%** on `movement.py`/`collision.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_movement.py tests/sim/test_movement_stress.py -q` → green
- [ ] a test proves a doubled `MAX_SPEED` still cannot pass through a 1-tile wall
- [ ] the phase-03 golden replay hash from task 05 (once created) is unaffected by later cosmetic refactors — regenerate only on contract change
- [ ] 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,55 +0,0 @@
# Task 03 — Interaction targeting and verb selection
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
## Objective
Decide, purely and deterministically, *what the action button means* right now: which
entity is targeted and which verb applies, with explicit denial reasons the client can
show.
## Work
1. `src/undercooked/sim/interact.py`:
- `DeniedReason` constants: `OUT_OF_REACH`, `HANDS_FULL`, `HANDS_EMPTY`,
`STATION_BUSY`, `STATION_DIRTY`, `NOTHING_HERE`, `NOT_FACING`, `STUNNED`,
`WRONG_ITEM`, `INVALID_STATE`, `CONTESTED_LOST`.
- `Target` union: `StationTarget(station)`, `ItemTarget(item, on_station_id | None)`,
`ServeTarget(station)`, `None`.
- `candidate_cells(player) -> tuple[tuple[int,int], ...]` — the tile directly in
front, then the two diagonal-neighbouring front tiles, then the tile under the
player; ordered, deduped.
- `select_target(state, player) -> Target | None` — walk `candidate_cells` in order,
preferring (a) the item on a station the player is carrying-compatible with,
(b) station, (c) none; a target is only valid within `REACH = 1.0` tile plus a
`FORGIVE = 0.6` distance check from the player's position.
- `Verb` constants: `PICK_ITEM`, `PICK_FROM_STATION`, `START_WORK`, `HOLD_WORK`,
`PLACE_ON_STATION`, `PLATE_DISH`, `SERVE`, `WASH`, `TRASH`, `RELEASE`, `NONE`.
- `select_verb(state, player, target) -> tuple[Verb, DeniedReason | None]` — the
priority ladder from PLAN §5: carrying→place/drop; else pick, work, plate, serve,
wash; long-press trash handled in task 04. Every rejection returns a reason, never a
silent `None` with no explanation.
- `resolve_action(state, player, held_for: float) -> tuple[KitchenState, tuple[Event, ...]]`
— in this phase implements only `RELEASE`/`PICK_ITEM`/`NONE`/denials; the station
verbs are stubbed with `TODO(phase 04)` docstrings that return
`INVALID_STATE`-free no-ops so tests can pin the ladder shape now.
2. Wire the action edge/hold semantics into the kernel: `step.py` slot 2 tracks
`player.action_held_ticks` and `player.action_pressed_tick` (added to `Player`),
emitting `EventKind.DENIED` events with the reason in the payload.
3. `tests/sim/test_targeting.py` — candidate-cell ordering per facing, forgiveness radius
boundary (0.99 reach ok, 1.61 rejected), priority when item + station share a cell,
diagonal-front preference, target selection when a wall separates the cook from the
station, `STUNNED` denial, out-of-reach press emits exactly one `DENIED` per press
(not per tick).
4. `tests/sim/test_verbs.py` — table-driven `(carrying, target, expected_verb,
expected_denial)` covering every `Verb` and every `DeniedReason` at least once.
## Testing & Quality
- Unit: full verb/denial matrix; targeting ordering deterministic for identical states.
- Coverage: **>90%** on `interact.py` (target 95%).
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_targeting.py tests/sim/test_verbs.py -q` → green
- [ ] every `DeniedReason` appears in at least one test (assert set coverage in the test file)
- [ ] a press that does nothing emits exactly one `DENIED` event (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
@@ -1,57 +0,0 @@
# Task 04 — Pick up, carry, drop, hand off, trash
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
## Objective
Implement the hands: one carried item per cook, deterministic arbitration when two cooks
want the same thing, counter-based handoff between players, and an always-available trash.
## Work
1. Extend `src/undercooked/sim/interact.py` (and add `src/undercooked/sim/carry.py` if
the file grows past ~350 lines):
- `PICK_ITEM` / `PICK_FROM_STATION`: moves an `Item` into `player.carrying`, removes it
from the station/cell; emits `PICKED` (add `EventKind.PICKED`, `DROPPED`,
`HANDED_OFF`, `TRASHED_ALREADY_EMPTY` as needed in `model.py`).
- `PLACE_ON_STATION` / `RELEASE`: placing onto a station that accepts the item kind
transfers it; releasing into open floor is **not** allowed in v1 (items live on
stations and in hands) → `RELEASE` onto a `FLOOR` cell becomes a no-op with
`NOTHING_HERE`.
- Handoff: two players may both act on the same `PREP`/counter tile; placing an item
there and another player picking it up is the sanctioned pass. Emit `HANDED_OFF`
with both player ids when a placed item is picked by a different player within
`HANDOFF_WINDOW_TICKS = 20`.
- `TRASH`: long-press (≥ `TRASH_HOLD_TICKS = 8`) over a `TRASH` station destroys the
carried item, `counters["trashed"] += 1`, score nudge `-2` (never below 0 overall —
clamp at 0 in `scoring` from phase 04; store the raw delta in the event payload).
- **Contested resolution** (PLAN §5): within one substep, when two players target the
same item/station, the winner is the one with the smaller `received_at_tick`, then
the smaller seat. Winner gets the verb; losers get
`EventKind.DENIED{reason: CONTESTED_LOST}`. No item ever exists in two places.
- `active_claims` bookkeeping on the state draft: `Mapping[target_key, player_id]` for
the current substep only (never persisted into the snapshot).
2. Update `docs/SIMULATION.md` — carry rules, handoff window, trash long-press, and the
contested-resolution tie-break (this is the contract phase 09's E2E asserts).
3. `tests/sim/test_carry.py`:
- one item per hand: second pick while carrying → `HANDS_FULL`.
- pick/place round trip preserves `Item` identity fields and clears the station cell.
- handoff between two players emits `HANDED_OFF` with correct ids; a third party
picking it up later does not.
- trash long-press destroys at tick 8 and not at 7; short press does nothing.
- contested: two players same target, `received_at_tick` tie broken by seat, exactly
one winner, loser event reason `CONTESTED_LOST`, item count conserved.
- 100-tick fuzz with random simultaneous presses from a fixed seed: item count
invariant (sum of items in hands + on stations + trashed == initial) holds every tick.
4. `tests/sim/test_replay_carry.py` — a scripted two-player sequence (one pass + one
contest) reaches a committed golden hash.
## Testing & Quality
- Unit: rules above plus the conservation invariant under fuzz.
- Coverage: **>90%** on carry/interaction changes.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_carry.py tests/sim/test_replay_carry.py -q` → green
- [ ] the item-conservation invariant test runs 100 ticks and cannot be satisfied by a hack (asserts per tick, not only at the end)
- [ ] `docs/SIMULATION.md` documents the contested tie-break exactly as implemented
- [ ] 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,53 +0,0 @@
# Task 05 — Replay harness, golden hashes, perf smoke
**Phase:** `03_simulation_movement_and_interaction` · **Story:** `US-04_move_and_cook`
## Objective
Lock determinism into CI: a reusable input-script format, committed golden state hashes,
a regeneration path for intentional contract changes, and a headless perf smoke test.
## Work
1. `src/undercooked/sim/replay.py` (library, stdlib only):
- `InputScript` = `{seed, level_id, tick_hz, frames: [{tick, player_id, move, action, face}]}`
with `load(path)`, `dump(path)`, `validate()`.
- `run_script(level: Level, book: RecipeBook, script: InputScript, max_ticks: int | None = None)
-> ReplayResult(state, hashes: tuple[tuple[int, str], ...], events: tuple[Event, ...],
wall_seconds: float)` — pure driver: builds the state, feeds frames at their tick,
records `(tick, state_hash())` every `record_every` ticks.
- `compare(result, golden: dict[int, str]) -> list[HashMismatch]` reporting the first
divergent tick plus a field-level diff (which player/station/order changed) so a
failure is diagnosable in one read.
- CLI: `uv run python -m undercooked.sim.replay record|check <path.json>` writing
`tests/goldens/<name>.golden.json` (record is manual; CI only checks).
2. Replay fixtures + goldens (committed):
- `tests/fixtures/replays/walk_solo.json` — 600 ticks of scripted walking around the
level-1 kitchen (no actions): exercises movement + collision only.
- `tests/fixtures/replays/pick_drop_pass.json` — two players, pick/place/pass/trash.
- `tests/goldens/walk_solo.golden.json`, `tests/goldens/pick_drop_pass.golden.json`.
3. `tests/sim/test_replay.py` — both scripts match their goldens; a deliberately mutated
script diverges and `compare()` names the first bad tick and the changed field; the
hash record cadence is stable; replay of the same script twice in one process is
identical (no hidden global state); goldens are never rewritten by tests.
4. `tests/sim/test_perf_smoke.py` (mark `slow`) — 4 players, 2 000 ticks on level 1:
assert mean step < 0.2 ms and total < 400 ms on the dev machine, and print the measured
numbers in the report. Mark the test `slow` so the quick loop skips it but
`.agents/validate.sh` runs it.
5. `.agents/validate.sh` — add `uv run pytest -m slow tests/sim -q` as a final stage
(after the full-suite run) so determinism and perf are gated, not advisory.
6. `docs/SIMULATION.md` — "Replaying and re-blessing goldens" section (when a re-bless is
legitimate: a deliberate rule change; never to silence a failure).
## Testing & Quality
- Unit/integration: replay determinism, mismatch diagnostics, goldens committed and used.
- Perf: budget asserted, numbers reported.
- Coverage: **>90%** on `replay.py`.
## Completion Criteria
- [ ] `uv run pytest tests/sim/test_replay.py -q` → green against committed goldens
- [ ] `uv run python -m undercooked.sim.replay check tests/fixtures/replays/walk_solo.json` exits 0
- [ ] editing one coordinate in the fixture makes `check` exit 1 and print the first divergent tick + changed field
- [ ] `uv run pytest -m slow tests/sim -q` meets the perf budget
- [ ] `bash .agents/validate.sh` includes the slow stage 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