**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`**.
231 lines
9.3 KiB
Python
231 lines
9.3 KiB
Python
"""`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())
|