Files
undercooked/tests/sim/test_replay_carry.py
T
ducoterra 4febdfe780 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`**.
2026-10-07 02:46:10 -04:00

154 lines
5.8 KiB
Python

"""The two-player carry script against its committed golden hash.
`tests/goldens/carry_two_player.golden.json` pins the state hash of a fixed 12-tick
script: one pass (a set-down picked up inside the hand-off window, twice — out and
back) and one contested pick (the full tie broken by seat, the loser told
`contested_lost`). The event sequence is asserted in code, so a golden drift is
diagnosable in one read: if the events match but the hash does not, a constant or
a subsystem order moved; if the events differ, a rule changed shape.
The golden is committed, not computed: a mismatch fails, and this test prints the
diff hint. Regeneration is deliberate — an intentional sim-contract change recorded
in the phase task, never a side effect of a failing run (task 05's replay harness
formalises the re-bless path and the input-script format this script foreshadows).
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from undercooked.sim.grid import Grid
from undercooked.sim.model import (
Facing,
Item,
ItemForm,
ItemKind,
KitchenState,
Player,
Station,
StationKind,
TileKind,
Vec2,
)
from undercooked.sim.step import STEP_DT, PlayerInput, step
pytestmark = pytest.mark.unit
GOLDEN_PATH: Path = (
Path(__file__).resolve().parent.parent / "goldens" / "carry_two_player.golden.json"
)
WIDTH, HEIGHT = 13, 9
def build_state() -> KitchenState:
"""Tick 0: A at the prep holding a lettuce, B at its left diagonal, both facing it."""
grid = Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT))
prep = Station(id="prep-9-4", kind=StationKind.PREP, x=9, y=4)
a = Player(
id="cook-a",
pos=Vec2(9.5, 5.5),
facing=Facing.UP,
carrying=Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW),
seat=0,
)
b = Player(id="cook-b", pos=Vec2(8.5, 5.5), facing=Facing.UP, seat=1)
return KitchenState(
level_id="03_replay_carry",
seed=99,
grid=grid,
stations=(prep,),
players=(a, b),
target_score=40,
time_left=90.0,
)
def frame(player_id: str, seq: int, action: bool, tick: int) -> PlayerInput:
return PlayerInput(player_id=player_id, seq=seq, action=action, received_at_tick=tick)
# The 12-tick script: (tick, one or two frames). The pass is t0 -> t2 (a sets down,
# b picks: HANDED_OFF), the contest is t6 (both press the prep's item: a, seat 0,
# wins; b is told contested_lost), and the return pass is t8 -> t10.
SCRIPT: tuple[tuple[int, tuple[PlayerInput, ...]], ...] = (
(0, (frame("cook-a", 0, True, 0),)),
(1, (frame("cook-a", 1, False, 1),)),
(2, (frame("cook-b", 2, True, 2),)),
(3, (frame("cook-b", 3, False, 3),)),
(4, (frame("cook-b", 4, True, 4),)),
(5, (frame("cook-b", 5, False, 5),)),
(6, (frame("cook-a", 6, True, 6), frame("cook-b", 6, True, 6))),
(7, (frame("cook-a", 7, False, 7),)),
(8, (frame("cook-a", 8, True, 8),)),
(9, (frame("cook-a", 9, False, 9),)),
(10, (frame("cook-b", 10, True, 10),)),
(11, (frame("cook-b", 11, False, 11),)),
)
# The event the script must produce, tick by tick (kinds and the load-bearing payload).
EXPECTED_EVENTS: tuple[tuple[str, dict[str, str]], ...] = (
("dropped", {"player_id": "cook-a", "station": "prep-9-4"}),
("handed_off", {"from": "cook-a", "to": "cook-b"}),
("dropped", {"player_id": "cook-b", "station": "prep-9-4"}),
("handed_off", {"from": "cook-b", "to": "cook-a"}),
("denied", {"player_id": "cook-b", "reason": "contested_lost"}),
("dropped", {"player_id": "cook-a", "station": "prep-9-4"}),
("handed_off", {"from": "cook-a", "to": "cook-b"}),
)
def run_script() -> tuple[KitchenState, tuple[tuple[str, dict[str, object]], ...]]:
"""Run the 12-tick script; return the final state and the events, keyed by kind."""
state = build_state()
events: list[tuple[str, dict[str, object]]] = []
for _tick, frames in SCRIPT:
state, produced = step(state, STEP_DT, frames)
events.extend((event.kind, dict(event.payload)) for event in produced)
return state, tuple(events)
def test_the_script_produces_the_expected_event_sequence() -> None:
_state, events = run_script()
assert len(events) == len(EXPECTED_EVENTS)
for (got_kind, got_payload), (want_kind, want_payload) in zip(
events, EXPECTED_EVENTS, strict=True
):
assert got_kind == want_kind
for key, value in want_payload.items():
assert got_payload.get(key) == value, (got_kind, got_payload)
def test_the_script_reaches_the_committed_golden_hash() -> None:
golden = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
state, _events = run_script()
assert state.tick == golden["tick"]
if state.state_hash() != golden["hash"]:
# The diff hint the phase promises: name both hashes so the commit shows the drift.
print(
f"golden mismatch for {GOLDEN_PATH.name}:\n"
f" expected {golden['hash']}\n"
f" actual {state.state_hash()}\n"
"Regenerate only on a deliberate sim-contract change recorded in the phase "
"task (docs/SIMULATION.md, 'Goldens'); never to silence a failure."
)
assert state.state_hash() == golden["hash"]
def test_the_golden_file_is_committed_and_well_formed() -> None:
golden = json.loads(GOLDEN_PATH.read_text(encoding="utf-8"))
assert golden["name"] == "carry_two_player"
assert golden["tick"] == 12
assert len(golden["hash"]) == 64 # a sha256 hex digest, nothing else
def test_rerunning_the_script_is_bit_identical() -> None:
"""No hidden global state: the same script twice in one process hashes the same."""
first, first_events = run_script()
second, second_events = run_script()
assert first.state_hash() == second.state_hash()
assert first_events == second_events