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:
@@ -0,0 +1,153 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user