Files
undercooked/tests/sim/test_movement_stress.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

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())