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:
@@ -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
|
||||
-55
@@ -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
|
||||
-55
@@ -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
|
||||
-53
@@ -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
|
||||
Reference in New Issue
Block a user