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