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,230 @@
|
||||
"""`movement` under a scripted random walk: 4 cooks, 2000 ticks, invariants only.
|
||||
|
||||
The table-driven tests in `test_movement.py` prove the geometry for the cases someone
|
||||
thought of. This file is for the cases nobody did: four cooks wandering a pillar maze for
|
||||
2000 substeps, checked after *every* substep — the simulation must never be observed in an
|
||||
impossible state, not even for one tick (PLAN A21).
|
||||
|
||||
The invariants, and why each is the load-bearing kind:
|
||||
|
||||
* **containment** — no body footprint ever overlaps a blocked cell. The maze's walls are
|
||||
one tile thick, so this one check *is* the no-tunnelling proof: a cook that crossed a
|
||||
wall would have to spend a substep inside it. It also covers containment in the void,
|
||||
because out-of-bounds is blocked by definition.
|
||||
* **no pass-through** — no pair ever overlaps by more than `2 * RADIUS`, the brief's
|
||||
bound, which is exactly what a head-on collision at the coarsest legal dt produces.
|
||||
* **no explosions** — a cook's per-tick displacement stays inside one tick of walking
|
||||
plus every push correction that could legally have been applied to it. A sign error in
|
||||
the soft push shows up here, and nowhere in a screenshot.
|
||||
* **progress** — every cook must visit a good many distinct cells. A walk that freezes in
|
||||
a corner passes every safety invariant above while testing nothing.
|
||||
* **determinism** — same seed, same script, same state hash, at `STEP_DT` and at `MAX_DT`.
|
||||
|
||||
The RNG is the sim's own splitmix64: nothing here imports `random`, so a failing run is
|
||||
reproducible to the tick and this file doubles as the movement stage's first golden.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.sim import movement
|
||||
from undercooked.sim.collision import body_blocker, body_cells
|
||||
from undercooked.sim.grid import Grid
|
||||
from undercooked.sim.model import KitchenState, Player, Station, StationKind, TileKind, Vec2
|
||||
from undercooked.sim.rng import Rng
|
||||
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
LEGEND: dict[str, TileKind] = {
|
||||
".": TileKind.FLOOR,
|
||||
"#": TileKind.WALL,
|
||||
"=": TileKind.COUNTER,
|
||||
"o": TileKind.VOID,
|
||||
}
|
||||
|
||||
#: A pillar maze with three doorways: one-tile corridors in every direction, so the walk
|
||||
#: is mostly corners, doorframes and dead ends. Two tiles of terrain are punched into it
|
||||
#: that a cook must treat as a wall but cannot fall through (`o` void, `=` counter), and
|
||||
#: `maze_state` adds two fixtures — a stove, which must block, and a serve window, which
|
||||
#: must not — because "the invariant held on empty floor" is the weakest version of this
|
||||
#: test. `test_the_maze_is_one_room` keeps it a single connected kitchen.
|
||||
MAZE = (
|
||||
"#################",
|
||||
"#...#..o#...#...#",
|
||||
"#.#...#...#...#.#",
|
||||
"#...#...#...#=..#",
|
||||
"#################",
|
||||
)
|
||||
|
||||
TICKS = 2000
|
||||
SEED = 0x5EED
|
||||
COOK_COUNT = 4
|
||||
|
||||
#: How much of the 32-cell kitchen each cook must have set foot in by the end of the
|
||||
#: scripted walk. The maze connects its pockets through three one-tile doorways, so a
|
||||
#: random heading takes a while to thread one: measured on this seed, every cook covers
|
||||
#: 16-18 cells over TICKS and none covers fewer than half that in under a tenth of it.
|
||||
MIN_CELLS_VISITED = 12
|
||||
|
||||
#: The eight compass directions plus standing still, as a fixed tuple so `Rng.choice`
|
||||
#: never depends on set ordering.
|
||||
DIRECTIONS: tuple[Vec2, ...] = (
|
||||
Vec2(1, 0),
|
||||
Vec2(1, 1),
|
||||
Vec2(0, 1),
|
||||
Vec2(-1, 1),
|
||||
Vec2(-1, 0),
|
||||
Vec2(-1, -1),
|
||||
Vec2(0, -1),
|
||||
Vec2(1, -1),
|
||||
Vec2(0, 0),
|
||||
)
|
||||
|
||||
STARTS: tuple[tuple[float, float], ...] = ((1.5, 1.5), (5.5, 1.5), (9.5, 1.5), (13.5, 1.5))
|
||||
|
||||
|
||||
def maze_state() -> KitchenState:
|
||||
stations = (
|
||||
Station(id="stove_0", kind=StationKind.STOVE, x=6, y=1),
|
||||
Station(id="serve_0", kind=StationKind.SERVE, x=10, y=1),
|
||||
)
|
||||
players = tuple(
|
||||
Player(id=f"p{seat}", seat=seat, pos=Vec2(x, y))
|
||||
for seat, (x, y) in enumerate(STARTS[:COOK_COUNT])
|
||||
)
|
||||
return KitchenState(
|
||||
level_id="stress",
|
||||
seed=SEED,
|
||||
grid=Grid.from_rows(MAZE, LEGEND),
|
||||
stations=stations,
|
||||
players=players,
|
||||
time_left=float(TICKS * STEP_DT),
|
||||
)
|
||||
|
||||
|
||||
def tick_budget(dt: float) -> float:
|
||||
"""The most a cook may legitimately move in one call.
|
||||
|
||||
One tick of walking at the ceiling, plus `PUSH_ITERATIONS` passes over up to
|
||||
`COOK_COUNT - 1` contacts, each of which can move this cook a full body (and the
|
||||
remainder a neighbour could not take). Anything above this is a runaway correction.
|
||||
"""
|
||||
body = 2 * movement.RADIUS
|
||||
return movement.MAX_SPEED * dt + movement.PUSH_ITERATIONS * (COOK_COUNT - 1) * body * 2
|
||||
|
||||
|
||||
def assert_invariants(state: KitchenState, before: Sequence[Vec2], dt: float) -> None:
|
||||
blocked = body_blocker(state.grid, state.stations)
|
||||
budget = tick_budget(dt)
|
||||
for player, was in zip(state.players, before, strict=True):
|
||||
for value in (player.pos.x, player.pos.y, player.vel.x, player.vel.y, player.stun):
|
||||
assert math.isfinite(value), f"{player.id} left the number line at tick {state.tick}"
|
||||
footprint = body_cells(player.pos, movement.RADIUS)
|
||||
assert not any(blocked(x, y) for x, y in footprint), (
|
||||
f"{player.id} is inside terrain at tick {state.tick}: {player.pos} -> {footprint}"
|
||||
)
|
||||
assert player.vel.length <= movement.MAX_SPEED + 1e-9
|
||||
jumped = (player.pos - was).length
|
||||
assert jumped <= budget, f"{player.id} jumped {jumped:.3f} tiles at tick {state.tick}"
|
||||
|
||||
diameter = 2 * movement.RADIUS
|
||||
for i, first in enumerate(state.players):
|
||||
for second in state.players[i + 1 :]:
|
||||
overlap_x = diameter - abs(second.pos.x - first.pos.x)
|
||||
overlap_y = diameter - abs(second.pos.y - first.pos.y)
|
||||
if overlap_x > 0.0 and overlap_y > 0.0: # the two footprints do intersect
|
||||
assert min(overlap_x, overlap_y) <= diameter + 1e-9, (
|
||||
f"{first.id} and {second.id} passed through each other at tick {state.tick}"
|
||||
)
|
||||
|
||||
|
||||
def run_walk(
|
||||
ticks: int = TICKS,
|
||||
dt: float = STEP_DT,
|
||||
visited: dict[str, set[tuple[int, int]]] | None = None,
|
||||
) -> KitchenState:
|
||||
"""The scripted walk: `ticks` substeps of a wandering four-cook kitchen."""
|
||||
current = maze_state()
|
||||
rng = Rng(SEED)
|
||||
heading = [Vec2(0, 0) for _ in current.players]
|
||||
for tick in range(1, ticks + 1):
|
||||
frames: list[PlayerInput] = []
|
||||
for player in current.players:
|
||||
if rng.next_float() < 0.2:
|
||||
heading[player.seat] = rng.choice(DIRECTIONS)
|
||||
frames.append(PlayerInput(
|
||||
player_id=player.id,
|
||||
seq=tick,
|
||||
move=heading[player.seat],
|
||||
received_at_tick=tick,
|
||||
))
|
||||
before = [player.pos for player in current.players]
|
||||
current, _events = movement.step_movement(current, dt, frames)
|
||||
assert_invariants(current, before, dt)
|
||||
if visited is not None:
|
||||
for player in current.players:
|
||||
visited.setdefault(player.id, set()).update(
|
||||
body_cells(player.pos, movement.RADIUS)
|
||||
)
|
||||
return current
|
||||
|
||||
|
||||
def test_the_maze_is_one_room() -> None:
|
||||
"""The progress check below only means something if the kitchen has no sealed pockets."""
|
||||
state = maze_state()
|
||||
blocked = body_blocker(state.grid, state.stations)
|
||||
free = {
|
||||
(x, y)
|
||||
for y in range(state.grid.height)
|
||||
for x in range(state.grid.width)
|
||||
if not blocked(x, y)
|
||||
}
|
||||
seen = {min(free)}
|
||||
stack = [min(free)]
|
||||
while stack:
|
||||
x, y = stack.pop()
|
||||
for step in ((1, 0), (-1, 0), (0, 1), (0, -1)):
|
||||
cell = (x + step[0], y + step[1])
|
||||
if cell in free and cell not in seen:
|
||||
seen.add(cell)
|
||||
stack.append(cell)
|
||||
assert seen == free
|
||||
assert len(free) >= MIN_CELLS_VISITED
|
||||
|
||||
|
||||
def test_the_walk_never_breaks_the_kitchen() -> None:
|
||||
"""Containment, no pass-through and no explosions, checked after every substep."""
|
||||
final = run_walk()
|
||||
assert len(final.players) == COOK_COUNT
|
||||
assert [player.id for player in final.players] == [f"p{seat}" for seat in range(COOK_COUNT)]
|
||||
assert all(player.connected for player in final.players)
|
||||
|
||||
|
||||
def test_the_walk_actually_walks() -> None:
|
||||
"""Every cook covers real ground: a frozen cook would satisfy every invariant above."""
|
||||
visited: dict[str, set[tuple[int, int]]] = {}
|
||||
run_walk(TICKS, visited=visited)
|
||||
for player_id, cells in sorted(visited.items()):
|
||||
assert len(cells) >= MIN_CELLS_VISITED, f"{player_id} only ever saw {sorted(cells)}"
|
||||
|
||||
|
||||
def test_the_same_script_always_produces_the_same_kitchen() -> None:
|
||||
"""Same seed, same script, same hash — the replay contract, at the movement stage."""
|
||||
first = run_walk(TICKS // 2)
|
||||
second = run_walk(TICKS // 2)
|
||||
assert first.state_hash() == second.state_hash()
|
||||
assert first.state_hash() != run_walk(TICKS // 2, dt=MAX_DT).state_hash()
|
||||
|
||||
|
||||
def test_the_walk_survives_the_coarsest_legal_tick() -> None:
|
||||
"""`MAX_DT` is 4x the substep: displacement per call quadruples, containment may not."""
|
||||
visited: dict[str, set[tuple[int, int]]] = {}
|
||||
final = run_walk(TICKS // 2, dt=MAX_DT, visited=visited)
|
||||
for player in final.players:
|
||||
assert player.vel.length <= movement.MAX_SPEED + 1e-9
|
||||
assert all(len(cells) >= MIN_CELLS_VISITED for cells in visited.values())
|
||||
Reference in New Issue
Block a user