"""`sim/collision.py` + `sim/movement.py` — the two promises walking makes (PLAN §5). Everything here is table-driven against hand-computed geometry, because "it moved" is not a specification. The numbers come from the shapes, not from the code: a cook is a square of half-width `RADIUS` = 0.35 and a tile is 1.0 wide, so a cook pressed against the wall cell at x=6 sits with its centre at `6 - 0.35 - CONTACT_EPS` = 5.6499. If the implementation ever says otherwise, one of those two is wrong. The property groups: * **terrain** — walk into every `TileKind` (and off the map), and stop flush; * **speed** — the ceiling, the ramp, the harder brake, no diagonal boost, and the server-side clamp that makes a `move` of 50 worth exactly a `move` of 1; * **other cooks** — the symmetric soft push (two cooks and a chain of four), no velocity injection, the transfer of what a wall refuses, and the deadlock revert; * **stun** — pinned, one event, expires; * **facing** — the last non-zero intent, a press that never turns you, an explicit turn. The invariant-only version of all this, over thousands of ticks of nonsense input, is `test_movement_stress.py`. """ from __future__ import annotations from collections.abc import Sequence from dataclasses import replace from itertools import pairwise from typing import Any import pytest from undercooked.sim import movement from undercooked.sim.collision import ( AXIS_X, AXIS_Y, CONTACT_EPS, PASS_THROUGH_STATIONS, body_blocker, body_cells, cell_blocked, station_body_cells, sweep_axis, ) from undercooked.sim.grid import Grid from undercooked.sim.model import ( EventKind, Facing, Hazard, HazardKind, KitchenState, Player, PlayerState, Station, StationKind, TileKind, Vec2, ) from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput, step pytestmark = pytest.mark.unit # --- fixtures --------------------------------------------------------------------------- LEGEND: dict[str, TileKind] = { ".": TileKind.FLOOR, "#": TileKind.WALL, "=": TileKind.COUNTER, "o": TileKind.VOID, } #: 13x5, interior x 1..11 / y 1..3, walls all round. ROOM = ( "#############", "#...........#", "#...........#", "#...........#", "#############", ) #: 21x21: big enough that nothing touches an edge for the first twenty ticks of a #: full-speed run from the centre. FIELD = tuple(["#" * 21] + ["#" + "." * 19 + "#"] * 19 + ["#" * 21]) #: half-width + the epsilon a body stops short of a blocked cell by. FLUSH = movement.RADIUS + CONTACT_EPS TWO_TILES = 2 * movement.RADIUS def grid(rows: Sequence[str], legend: dict[str, TileKind] | None = None) -> Grid: return Grid.from_rows(rows, legend or LEGEND) def kitchen( players: Sequence[Player], rows: Sequence[str] = ROOM, stations: Sequence[Station] = (), legend: dict[str, TileKind] | None = None, ) -> KitchenState: return KitchenState( level_id="movement", seed=1, grid=grid(rows, legend), stations=tuple(stations), players=tuple(players), time_left=30.0, ) def cook(pid: str, x: float, y: float, seat: int = 0, **overrides: Any) -> Player: return replace(Player(id=pid, pos=Vec2(x, y), seat=seat), **overrides) def press( pid: str, x: float, y: float, *, seq: int = 1, received: int = 0, action: bool = False, face: Facing | None = None, ) -> PlayerInput: return PlayerInput( player_id=pid, seq=seq, move=Vec2(x, y), action=action, face=face, received_at_tick=received, ) def walked( state: KitchenState, frames: dict[str, PlayerInput], ticks: int, dt: float = STEP_DT ) -> KitchenState: """`state` after `ticks` substeps of `frames`.""" current = state for _ in range(ticks): current, _events = movement.step_movement(current, dt, frames) return current def occupied_cells(state: KitchenState, index: int = 0) -> tuple[tuple[int, int], ...]: return body_cells(state.players[index].pos, movement.RADIUS) def assert_no_body_in_terrain(state: KitchenState) -> None: """The core guarantee, asserted as geometry instead of as a remembered coordinate.""" blocked = body_blocker(state.grid, state.stations) for player in state.players: assert not any(blocked(x, y) for x, y in body_cells(player.pos, movement.RADIUS)) # --- collision.cell_blocked ------------------------------------------------------------- KINDS = ("#####", "#.=o#", "#####") @pytest.mark.parametrize( ("x", "y", "expected"), [ (1, 1, False), # the only walkable cell (2, 1, True), # counter (3, 1, True), # void: blocked, never a fall (PLAN §5) (1, 0, True), # wall above (1, 2, True), # wall below (0, 1, True), # wall left (4, 1, True), # wall right ], ) def test_cell_blocked_per_tile_kind(x: int, y: int, expected: bool) -> None: assert cell_blocked(grid(KINDS), x, y) is expected @pytest.mark.parametrize(("x", "y"), [(-1, 1), (5, 1), (1, -1), (1, 5), (99, 99)]) def test_out_of_bounds_is_blocked(x: int, y: int) -> None: assert cell_blocked(grid(KINDS), x, y) is True def test_extra_blocked_uses_row_major_cell_indices() -> None: board = grid(KINDS) assert cell_blocked(board, 1, 1, frozenset()) is False assert cell_blocked(board, 1, 1, frozenset({1 * board.width + 1})) is True # an index for a cell that is blocked anyway changes nothing assert cell_blocked(board, 3, 1, frozenset({3})) is True # --- collision.station_body_cells / body_blocker ---------------------------------------- @pytest.mark.parametrize( "kind", [StationKind.SPAWN_SHELF, StationKind.CHOP_BOARD, StationKind.STOVE, StationKind.PREP], ) def test_fixture_stations_block_their_tile(kind: StationKind) -> None: assert station_body_cells(Station(id=f"{kind.value}_0", kind=kind, x=4, y=2)) == ((4, 2),) def ordered(kinds: frozenset[StationKind]) -> list[StationKind]: """The pass-through set as a stable list — a set has no order a test can rely on.""" return sorted(kinds, key=lambda kind: kind.value) @pytest.mark.parametrize("kind", ordered(PASS_THROUGH_STATIONS)) def test_pass_through_stations_block_nothing(kind: StationKind) -> None: assert kind in {StationKind.SERVE, StationKind.SINK, StationKind.TRASH} assert station_body_cells(Station(id=f"{kind.value}_0", kind=kind, x=4, y=2)) == () def test_body_blocker_layers_terrain_fixtures_and_exclusions() -> None: board = grid(("#####", "#...#", "#####")) shelf = Station(id="chop_board_0", kind=StationKind.CHOP_BOARD, x=2, y=1) blocked = body_blocker(board, [shelf]) assert blocked(2, 1) is True # floor, but a board stands on it assert blocked(1, 1) is False # plain floor assert blocked(0, 1) is True # terrain assert blocked(2, 0) is True # terrain carrying = body_blocker(board, [shelf], frozenset({board.width + 2})) assert carrying(2, 1) is False # the cook holding the board walks over it (phase 10) assert carrying(2, 0) is True # `movable_cells` never opens terrain # --- collision.body_cells --------------------------------------------------------------- @pytest.mark.parametrize( ("pos", "expected"), [ (Vec2(2.5, 2.5), [(2, 2)]), (Vec2(3.0, 3.0), [(2, 2), (3, 2), (2, 3), (3, 3)]), # a corner touches four cells (Vec2(6 - FLUSH, 2.5), [(5, 2)]), # flush left of x=6 claims only cell x=5 (Vec2(2.5, 6 - FLUSH), [(2, 5)]), ], ) def test_body_cells(pos: Vec2, expected: list[tuple[int, int]]) -> None: assert body_cells(pos, movement.RADIUS) == tuple(expected) def test_a_small_body_stays_in_one_cell() -> None: assert body_cells(Vec2(3.5, 3.5), 0.1) == ((3, 3),) # --- collision.sweep_axis --------------------------------------------------------------- SWEEP_MAP = ( "########", "#......#", "#..#...#", "#......#", "########", ) @pytest.mark.parametrize("axis", [AXIS_X, AXIS_Y]) def test_a_sweep_of_nothing_moves_nothing(axis: str) -> None: blocked = body_blocker(grid(SWEEP_MAP)) assert sweep_axis(Vec2(2.5, 2.5), 0.0, movement.RADIUS, blocked, axis) == 0.0 @pytest.mark.parametrize( ("pos", "delta", "axis", "expected"), [ # a free corridor: the whole move is legal, both directions (Vec2(1.5, 3.5), 0.25, AXIS_X, 0.25), (Vec2(5.5, 3.5), -0.25, AXIS_X, -0.25), # the pillar at (3,2) stops a rightward move one epsilon short of x=3 (Vec2(1.5, 2.5), 3.0, AXIS_X, 3.0 - 1.85 - CONTACT_EPS), # the same pillar met from the right stops one epsilon right of x=4 (Vec2(5.5, 2.5), -3.0, AXIS_X, 4.0 + CONTACT_EPS - 5.15), # down into the floor wall at y=4, up into the one at y=0 (Vec2(1.5, 1.5), 3.0, AXIS_Y, 4.0 - 1.85 - CONTACT_EPS), (Vec2(1.5, 1.5), -3.0, AXIS_Y, 1.0 + CONTACT_EPS - 1.15), # already flush: no room on that axis, and no bounce back either (Vec2(3 - FLUSH, 2.5), 0.5, AXIS_X, 0.0), (Vec2(4 + FLUSH, 2.5), -0.5, AXIS_X, 0.0), ], ) def test_sweep_axis_limits(pos: Vec2, delta: float, axis: str, expected: float) -> None: allowed = sweep_axis(pos, delta, movement.RADIUS, body_blocker(grid(SWEEP_MAP)), axis) assert allowed == pytest.approx(expected, abs=1e-12) def test_a_huge_sweep_never_crosses_a_one_tile_wall() -> None: """50 tiles in one call is still stopped by the first wall: every cell on the path is tried.""" blocked = body_blocker(grid(SWEEP_MAP)) assert sweep_axis(Vec2(1.5, 2.5), 50.0, movement.RADIUS, blocked, AXIS_X) == pytest.approx( 1.1499, abs=1e-9 ) assert sweep_axis(Vec2(6.5, 2.5), -50.0, movement.RADIUS, blocked, AXIS_X) == pytest.approx( -2.1499, abs=1e-9 ) def test_a_sweep_cannot_push_a_body_out_of_the_map() -> None: """The edge of the map stops a body the way a wall does: its trail edge at `+EPS`.""" blocked = body_blocker(grid(("...", "...", "..."))) assert sweep_axis(Vec2(1.5, 1.5), -9.0, movement.RADIUS, blocked, AXIS_X) == pytest.approx( CONTACT_EPS - 1.15, abs=1e-9 ) # --- terrain: walking into everything --------------------------------------------------- @pytest.mark.parametrize( ("kind", "blocked_cell", "expected_cell"), [ (TileKind.FLOOR, 5, (4, 1)), (TileKind.WALL, 4, (3, 1)), (TileKind.COUNTER, 4, (3, 1)), (TileKind.VOID, 4, (3, 1)), ], ) def test_walking_into_each_tile_kind( kind: TileKind, blocked_cell: int, expected_cell: tuple[int, int] ) -> None: """One tile of each kind in the path: the cook stops flush against it, never inside it. "Flush" is `blocked_cell - RADIUS` to within `CONTACT_EPS`: a sweep stops a body one epsilon short of the blocked cell, but when the moving edge lands exactly *on* the boundary the body is already flush and no clamp is needed at all. """ state = kitchen( [cook("p0", 1.5, 1.5)], rows=["######", "#...K#", "######"], legend={**LEGEND, "K": kind}, ) out = walked(state, {"p0": press("p0", 1, 0)}, 60) assert out.players[0].pos.x == pytest.approx(blocked_cell - movement.RADIUS, abs=CONTACT_EPS) assert out.players[0].pos.y == pytest.approx(1.5) assert occupied_cells(out) == (expected_cell,) assert_no_body_in_terrain(out) def test_a_walk_stops_flush_against_the_room_wall() -> None: state = kitchen([cook("p0", 2.5, 2.5)], rows=ROOM) out = walked(state, {"p0": press("p0", 1, 0)}, 120) assert out.players[0].pos.x == pytest.approx(12 - movement.RADIUS, abs=CONTACT_EPS) assert occupied_cells(out) == ((11, 2),) def test_a_corner_catch_slides_instead_of_sticking() -> None: """A fixture column in the path: X is refused, Y keeps going, and the cook goes around. The assertion that matters is per-substep: whenever the pillar is level with the cook, the cook is never *inside* it — which is exactly the corner case a centre-only collision test gets wrong. """ state = kitchen( [cook("p0", 1 + FLUSH, 2.5)], rows=("#######", "#.....#", "#.#...#", "#.#...#", "#.....#", "#######"), ) current = state for tick in range(30): current, _events = movement.step_movement( current, STEP_DT, {"p0": press("p0", 1, 1, seq=tick)} ) assert_no_body_in_terrain(current) level_with_pillar = any(y in (2, 3) for _x, y in occupied_cells(current)) if level_with_pillar: assert current.players[0].pos.x <= 2 - movement.RADIUS + CONTACT_EPS out = current.players[0] # slid along the wall instead of into it, and stopped flush with the bottom assert out.pos.y == pytest.approx(5 - movement.RADIUS, abs=CONTACT_EPS) assert out.pos.x > 2.0 # and carried on right once the pillar was behind @pytest.mark.parametrize( "direction", [(-1, 0), (1, 0), (0, -1), (0, 1), (-1, -1), (1, 1)], ids=["west", "east", "north", "south", "north-west", "south-east"], ) def test_out_of_bounds_containment_in_every_direction(direction: tuple[int, int]) -> None: """A map with no walls at all: the edge is a wall for a cook, in every direction.""" state = kitchen([cook("p0", 2.0, 1.5)], rows=("....", "....", "....")) out = walked(state, {"p0": press("p0", *direction)}, 60).players[0] assert out.pos.x - movement.RADIUS >= -CONTACT_EPS assert out.pos.x + movement.RADIUS <= 4.0 + CONTACT_EPS assert out.pos.y - movement.RADIUS >= -CONTACT_EPS assert out.pos.y + movement.RADIUS <= 3.0 + CONTACT_EPS assert_no_body_in_terrain(replace(state, players=(out,))) def test_double_speed_cannot_cross_a_one_tile_wall(monkeypatch: pytest.MonkeyPatch) -> None: """Ceiling doubled and the coarsest legal dt: the sweep still stops the cook flush. The per-substep displacement (7 tiles/s x 0.1 s = 0.7) is 70 % of a tile, so a naive "is the destination cell walkable" test would land the cook on the wall's far edge. `sweep_axis` tests every cell between the old and the new edge instead. """ monkeypatch.setattr(movement, "MAX_SPEED", 2 * movement.MAX_SPEED) state = kitchen([cook("p0", 1.5, 1.5)], rows=("######", "#..#.#", "######")) current = state for tick in range(30): current, _events = movement.step_movement( current, MAX_DT, {"p0": press("p0", 1, 0, seq=tick)} ) assert_no_body_in_terrain(current) assert current.players[0].pos.x == pytest.approx(3 - movement.RADIUS, abs=2 * CONTACT_EPS) @pytest.mark.parametrize( ("kind", "blocked"), [(StationKind.STOVE, True), (StationKind.SERVE, False)] ) def test_a_stove_stops_you_and_a_serve_window_does_not(kind: StationKind, blocked: bool) -> None: fixture = Station(id=f"{kind.value}_0", kind=kind, x=4, y=1) state = kitchen( [cook("p0", 1.5, 1.5)], rows=("#######", "#.....#", "#######"), stations=[fixture] ) out = walked(state, {"p0": press("p0", 1, 0)}, 60).players[0] assert out.pos.x == pytest.approx((4 if blocked else 6) - movement.RADIUS, abs=CONTACT_EPS) assert_no_body_in_terrain(replace(state, players=(out,))) # --- speed ------------------------------------------------------------------------------ def test_intent_is_clamped_to_the_unit_disc() -> None: assert movement.intent_direction(Vec2(0, 0)) == Vec2(0, 0) assert movement.intent_direction(Vec2(0.5, 0)) == Vec2(0.5, 0) # analog throttle kept diagonal = movement.intent_direction(Vec2(3, 4)) assert diagonal.x == pytest.approx(0.6) assert diagonal.y == pytest.approx(0.8) assert movement.intent_direction(Vec2(1, 1)).length == pytest.approx(1.0) assert movement.intent_direction(Vec2(50, 0)).x == pytest.approx(1.0) def test_normalisation_is_what_makes_the_diagonal_not_faster( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr(movement, "DIAGONAL_NORM", False) assert movement.intent_direction(Vec2(2, 2)) == Vec2(1, 1) assert movement.intent_direction(Vec2(50, -50)) == Vec2(1, -1) assert movement.intent_direction(Vec2(0.4, 0.4)) == Vec2(0.4, 0.4) @pytest.mark.parametrize( "intent", [Vec2(1, 0), Vec2(-1, 0), Vec2(0, 1), Vec2(0, -1), Vec2(1, 1), Vec2(-2, -2)], ids=["e", "w", "s", "n", "diag", "diag_overdrive"], ) def test_a_diagonal_is_not_a_speed_boost(intent: Vec2) -> None: state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD) travelled = walked(state, {"p0": press("p0", intent.x, intent.y)}, 20).players[0].pos straight = walked(state, {"p0": press("p0", 1, 0)}, 20).players[0].pos start = Vec2(10.5, 10.5) assert (travelled - start).length == pytest.approx((straight - start).length, abs=1e-12) def test_the_ramp_and_the_harder_brake() -> None: still = cook("p0", 5.5, 2.5) first = movement.desired_velocity(still, Vec2(1, 0), STEP_DT) assert first == pytest.approx(Vec2(movement.ACCEL * STEP_DT, 0)) second = movement.desired_velocity(replace(still, vel=first), Vec2(1, 0), STEP_DT) assert second.x == pytest.approx(2 * movement.ACCEL * STEP_DT) third = movement.desired_velocity(replace(still, vel=second), Vec2(1, 0), STEP_DT) assert third.x == pytest.approx(movement.MAX_SPEED) # the ceiling is met, not overshot rolling = cook("p0", 5.5, 2.5, vel=Vec2(movement.MAX_SPEED, 0.0)) braking = movement.desired_velocity(rolling, Vec2(0, 0), STEP_DT) assert braking.x == pytest.approx(movement.MAX_SPEED - movement.DECEL * STEP_DT) assert movement.DECEL * STEP_DT > movement.ACCEL * STEP_DT # braking is the sharper ramp def test_speed_never_exceeds_the_ceiling() -> None: state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD) current = state peak = 0.0 for tick in range(20): current, _events = movement.step_movement( current, STEP_DT, {"p0": press("p0", 1, 1, seq=tick)} ) speed = current.players[0].vel.length assert speed <= movement.MAX_SPEED + 1e-12 peak = max(peak, speed) assert peak == pytest.approx(movement.MAX_SPEED) @pytest.mark.parametrize( ("modifiers", "expected"), [ (movement.MoveModifiers(), movement.MAX_SPEED), (movement.MoveModifiers(speed_scale=0.5), movement.MAX_SPEED / 2), (movement.MoveModifiers(speed_scale=0.0), 0.0), ], ) def test_speed_scale_moves_the_ceiling(modifiers: movement.MoveModifiers, expected: float) -> None: """`dt = 1.0` so the grip limit cannot bind: this reads the ceiling itself.""" walked = movement.desired_velocity(cook("p0", 1, 1), Vec2(1, 0), 1.0, modifiers) assert walked.x == pytest.approx(expected) def test_accel_scale_reaches_the_ceiling_in_half_the_time() -> None: still = cook("p0", 1, 1) assert movement.desired_velocity(still, Vec2(1, 0), STEP_DT).x == pytest.approx(1.5) grippy = movement.MoveModifiers(accel_scale=2.0) assert movement.desired_velocity(still, Vec2(1, 0), STEP_DT, grippy).x == pytest.approx(3.0) def test_slide_removes_the_brake_but_not_the_throttle() -> None: rolling = cook("p0", 1, 1, vel=Vec2(2.0, 0.0)) icy = movement.MoveModifiers(slide=True) assert movement.desired_velocity(rolling, Vec2(0, 0), STEP_DT, icy) == Vec2(2.0, 0.0) assert movement.desired_velocity(rolling, Vec2(0, 0), STEP_DT).x == pytest.approx(0.0) # pushing on a spill still pushes assert movement.desired_velocity(rolling, Vec2(1, 0), STEP_DT, icy).x > 2.0 def test_a_spill_halves_the_cruising_speed() -> None: state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD) frames = {"p0": press("p0", 1, 0)} spill = movement.MoveModifiers(speed_scale=0.5) floor = walked(state, frames, 40).players[0] on_spill = state for _ in range(40): on_spill, _events = movement.step_movement(on_spill, STEP_DT, frames, {"p0": spill}) travelled = on_spill.players[0].pos.x - 10.5 assert on_spill.players[0].vel.length == pytest.approx(movement.MAX_SPEED / 2, abs=1e-9) assert travelled == pytest.approx(0.5 * (floor.pos.x - 10.5), rel=0.05) def test_a_client_cannot_send_speed_fifty() -> None: """The clamp is server-side and it is in the kernel path, not only in the helper.""" state = kitchen([cook("p0", 10.5, 10.5)], rows=FIELD) polite = state rude = state for tick in range(40): polite, _ = step(polite, STEP_DT, [press("p0", 1, 0, seq=tick)]) rude, _ = step(rude, STEP_DT, [press("p0", 50, 0, seq=tick)]) assert rude.players[0].pos == pytest.approx(polite.players[0].pos) assert rude.players[0].vel.length <= movement.MAX_SPEED + 1e-12 # --- other cooks ------------------------------------------------------------------------ def test_one_walked_cook_moves_the_other_and_never_throws_them() -> None: corridor = ("#" * 21, "#" + "." * 19 + "#", "#" * 21) state = kitchen([cook("p0", 2.5, 1.5), cook("p1", 3.6, 1.5, seat=1)], rows=corridor) before = state.players[1] current = state for tick in range(30): current, _events = movement.step_movement( current, STEP_DT, {"p0": press("p0", 1, 0, seq=tick)} ) assert_no_body_in_terrain(current) pusher, pushed = current.players assert pushed.pos.x > before.pos.x # it moved assert pushed.vel == before.vel == Vec2(0.0, 0.0) # positional correction, no velocity injected assert pushed.state is PlayerState.IDLE assert pusher.pos.x < pushed.pos.x assert pushed.pos.x - pusher.pos.x >= TWO_TILES - movement.PUSH_SLOP def test_a_cook_pinned_by_a_wall_takes_the_push_backstage() -> None: """B is flush against the wall and A walks into B: A absorbs the whole separation.""" wall = 6 - movement.RADIUS state = kitchen( [cook("p0", 2.0, 1.5), cook("p1", wall - CONTACT_EPS, 1.5, seat=1)], rows=("#######", "#.....#", "#######"), ) current = state for tick in range(40): current, _events = movement.step_movement( current, STEP_DT, {"p0": press("p0", 1, 0, seq=tick)} ) assert_no_body_in_terrain(current) pusher, pinned = current.players assert pinned.pos.x == pytest.approx(wall, abs=CONTACT_EPS) # it never moved # A stopped behind B rather than walking through it assert pusher.pos.x == pytest.approx(wall - TWO_TILES, abs=2 * CONTACT_EPS) def test_a_chain_of_four_cooks_drains_apart() -> None: """A queue of overlapping cooks separates over a few ticks; nothing reverts forever.""" packed = [cook(f"p{i}", 2.0 + 0.6 * i, 1.5, seat=i) for i in range(4)] state = kitchen(packed, rows=("#############", "#...........#", "#############")) current = state for _ in range(40): current, _events = movement.step_movement(current, STEP_DT, {}) assert_no_body_in_terrain(current) xs = [player.pos.x for player in current.players] assert xs == sorted(xs) for first, second in pairwise(xs): assert second - first == pytest.approx(TWO_TILES, abs=1e-3) def test_a_bump_separates_on_the_easy_axis() -> None: """Two cooks offset more in x than in y separate vertically, not horizontally.""" room = tuple(["#" * 11] + ["#" + "." * 9 + "#"] * 11) state = kitchen([cook("p0", 2.5, 2.5), cook("p1", 2.7, 3.0, seat=1)], rows=room) before = state.players[1].pos current = state for _ in range(20): current, _events = movement.step_movement(current, STEP_DT, {}) assert_no_body_in_terrain(current) moved = current.players[1].pos assert moved.y > before.y assert moved.x == pytest.approx(before.x) def test_a_terrain_deadlock_reverts_the_later_input() -> None: """Two cooks stacked in a 1x1 alcove: terrain wins, so the later frame is the one dropped.""" state = kitchen( [cook("p0", 1.5, 1.5, vel=Vec2(0.2, 0.0)), cook("p1", 1.5, 1.5, seat=1)], rows=("###", "#.#", "###"), ) players, events = movement.resolve_moves( state, STEP_DT, { "p0": press("p0", 1, 0, seq=9, received=5), # received later: loses "p1": press("p1", 1, 0, seq=1, received=1), # received earlier: keeps its tick }, ) late, early = players assert late.pos == state.players[0].pos # exactly where the tick started assert late.vel == Vec2(0.2, 0.0) # and exactly the velocity it started with assert early.vel.x == pytest.approx(movement.ACCEL * STEP_DT) # still touching, never through: the overlap never exceeds one body diameter assert abs(early.pos.x - late.pos.x) <= TWO_TILES + movement.PUSH_SLOP assert events == () def test_a_pair_that_can_move_is_never_reverted() -> None: """Two cooks walking abreast into a wall queue up; the revert is only for real deadlocks.""" state = kitchen( [cook("p0", 2.5, 1.5), cook("p1", 2.9, 1.5, seat=1)], rows=("#######", "#.....#", "#######"), ) current = state for tick in range(20): current, _events = movement.step_movement( current, STEP_DT, {"p0": press("p0", 1, 0, seq=tick), "p1": press("p1", 1, 0, seq=tick)}, ) assert_no_body_in_terrain(current) first, second = current.players assert second.pos.x - first.pos.x >= TWO_TILES - movement.PUSH_SLOP def test_the_cook_asking_for_the_move_loses_a_deadlock() -> None: """A cook with no frame this tick is never the loser, because it wants nothing. The revert is not free — it puts a cook back where the substep started — so who gets it matters. Only one cook sent input, and only that cook can be told "not this tick": the idle one is not asking to be anywhere and has no input to discard, so it keeps the position the push left it in and the cook with the joystick is the one refused. """ state = kitchen( [cook("p0", 1.5, 1.5), cook("p1", 1.5, 1.5, seat=1)], rows=("###", "#.#", "###"), ) players, events = movement.resolve_moves(state, STEP_DT, {"p0": press("p0", 1, 0)}) framed, frameless = players assert framed.pos == state.players[0].pos # the one that asked is the one that's refused # The idle cook keeps the shove it never asked for — and it was shoved *away* from the # cook that walked into it, which is the only reason the two are not stacked again. assert frameless.pos != state.players[1].pos assert frameless.pos.x < state.players[1].pos.x assert events == () assert_no_body_in_terrain(state.with_players(players)) def test_a_jam_drains_over_a_few_ticks_instead_of_fusing_forever() -> None: """Four cooks packed into an open kitchen are a queue, not a deadlock. A jam of overlapping bodies unties itself over a handful of ticks as the pushes propagate, so each of those ticks must be legal: nobody inside terrain, nobody's position reverted, because there *is* a way out and reverting would refuse a move that was perfectly possible. """ state = kitchen( [ cook("p0", 5.5, 2.5), cook("p1", 5.7, 2.5, seat=1), cook("p2", 5.5, 2.7, seat=2), cook("p3", 5.7, 2.7, seat=3), ], rows=FIELD, ) current = state for tick in range(6): current, _events = movement.step_movement( current, STEP_DT, { player.id: press(player.id, 1, 1, seq=tick) for player in current.players }, ) assert_no_body_in_terrain(current) positions = [player.pos for player in current.players] assert positions != [player.pos for player in state.players] for index, first in enumerate(current.players): for other in current.players[index + 1 :]: overlapped_x = TWO_TILES - abs(other.pos.x - first.pos.x) overlapped_y = TWO_TILES - abs(other.pos.y - first.pos.y) if overlapped_x > 0.0 and overlapped_y > 0.0: assert min(overlapped_x, overlapped_y) < TWO_TILES # --- stun ------------------------------------------------------------------------------- def test_a_stunned_cook_cannot_move_and_reports_the_hit_once() -> None: state = kitchen([cook("p0", 5.5, 2.5, stun=movement.STUN_S)], rows=ROOM) current = state seen: list[str] = [] for tick in range(11): current, events = movement.step_movement( current, STEP_DT, {"p0": press("p0", 1, 1, seq=tick)} ) seen.extend(event.kind for event in events) pinned = current.players[0] assert pinned.pos == Vec2(5.5, 2.5) assert pinned.vel == Vec2(0.0, 0.0) assert pinned.state is PlayerState.STUNNED assert pinned.facing is Facing.DOWN # a hit never spins you either assert pinned.stun == pytest.approx(movement.STUN_S - 11 * STEP_DT) assert seen == [EventKind.STUNNED] # the entry only, not one per substep def test_a_stun_expires_and_the_cook_walks_again() -> None: state = kitchen([cook("p0", 5.5, 2.5, stun=0.6)], rows=ROOM) frames = {"p0": press("p0", 1, 0)} pinned = walked(state, frames, 11).players[0] assert pinned.state is PlayerState.STUNNED freed = walked(walked(state, frames, 11), frames, 1).players[0] assert freed.stun == 0.0 assert freed.state is PlayerState.MOVING assert freed.pos.x > 5.5 def test_apply_stun_pins_velocity_and_clears_a_stale_state() -> None: hurt = cook("p0", 2.5, 2.5, stun=0.3, vel=Vec2(3.0, 0.0), state=PlayerState.MOVING) bystander = cook("p1", 3.5, 2.5, seat=1) players, events = movement.apply_stun([hurt, bystander], STEP_DT) assert players[0].vel == Vec2(0.0, 0.0) assert players[0].state is PlayerState.STUNNED assert players[0].stun == pytest.approx(0.25) assert players[1] is bystander # an unharmed cook is not even copied reported = [(event.kind, event.payload["player_id"]) for event in events] assert reported == [(EventKind.STUNNED, "p0")] stuck = cook("p0", 2.5, 2.5, stun=0.0, state=PlayerState.STUNNED) revived, later = movement.apply_stun([stuck], STEP_DT) assert revived[0].state is PlayerState.IDLE # a hazard's half-written state is corrected assert later == () # --- facing ----------------------------------------------------------------------------- @pytest.mark.parametrize( ("intent", "expected"), [ (Vec2(1, 0), Facing.RIGHT), (Vec2(-1, 0), Facing.LEFT), (Vec2(0, 1), Facing.DOWN), (Vec2(0, -1), Facing.UP), (Vec2(1, 1), Facing.RIGHT), # a 45 degree press always turns the same way (Vec2(-0.4, -0.9), Facing.UP), (Vec2(0.2, -0.1), Facing.RIGHT), ], ) def test_facing_follows_the_intent(intent: Vec2, expected: Facing) -> None: assert movement.update_facing(cook("p0", 1, 1), intent) is expected @pytest.mark.parametrize("action", [False, True]) def test_standing_still_keeps_the_facing(action: bool) -> None: facing_left = cook("p0", 1, 1, facing=Facing.LEFT) assert movement.update_facing(facing_left, Vec2(), action) is Facing.LEFT assert movement.update_facing(facing_left, Vec2(0, 0), action, None) is Facing.LEFT def test_an_explicit_turn_beats_the_vector() -> None: assert movement.update_facing(cook("p0", 1, 1), Vec2(1, 0), face=Facing.UP) is Facing.UP def test_an_action_press_with_no_intent_does_not_spin_the_cook() -> None: """Walk right, stop, press: the cook still faces right (US-04, one action button).""" state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM) walking = walked(state, {"p0": press("p0", 1, 0)}, 10) assert walking.players[0].facing is Facing.RIGHT holding = walking for seq in range(5): holding, _events = movement.step_movement( holding, STEP_DT, {"p0": PlayerInput( player_id="p0", seq=seq, move=Vec2(), action=True, received_at_tick=9 )}, ) assert holding.players[0].facing is Facing.RIGHT assert holding.players[0].state is PlayerState.IDLE def test_the_player_state_tracks_the_velocity() -> None: state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM) walking = walked(state, {"p0": press("p0", 0, -1)}, 3).players[0] assert walking.state is PlayerState.MOVING stopped = walked(replace(state, players=(walking,)), {}, 4).players[0] assert stopped.state is PlayerState.IDLE assert stopped.vel == Vec2(0.0, 0.0) # --- the kernel seam -------------------------------------------------------------------- def test_resolve_moves_without_a_frame_brakes_to_a_stop() -> None: state = kitchen([cook("p0", 5.5, 2.5, vel=Vec2(movement.MAX_SPEED, 0.0))], rows=ROOM) players, events = movement.resolve_moves(state, STEP_DT) assert players[0].vel.x == pytest.approx(movement.MAX_SPEED - movement.DECEL * STEP_DT) assert players[0].pos.x > 5.5 assert players[0].state is PlayerState.MOVING assert events == () def test_step_movement_is_a_pure_stage_of_the_state() -> None: state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM) before = state.state_hash() moved, events = movement.resolve_moves(state, STEP_DT, {"p0": press("p0", 1, 0)}) out, stage_events = movement.step_movement(state, STEP_DT, {"p0": press("p0", 1, 0)}) assert state.state_hash() == before # the caller's state is untouched assert out.players == moved assert stage_events == events assert out.grid is state.grid assert out.tick == state.tick assert [player.id for player in out.players] == ["p0"] def patch(patch_id: str, x: float, y: float) -> Hazard: """A spill at (x, y): inert data until phase 07 gives hazards behaviour. It is here for the subsystem order, not for its effect — the determinism check below must cover a tick that runs hazards *and* movement, because that is the tick a replay has to reproduce exactly. """ return Hazard(id=patch_id, kind=HazardKind.SPILL, pos=Vec2(x, y)) def test_the_tick_is_deterministic_through_the_movement_stage() -> None: """Same seed, same input script, same hash — the replay contract, at the new stage.""" script = [ press("p0", tick % 4 - 1, tick % 3 - 1, seq=tick) for tick in range(1, 41) ] def run(seed: int) -> KitchenState: current = replace( kitchen([cook("p0", 5.5, 3.5), cook("p1", 6.5, 3.5, seat=1)], rows=FIELD), seed=seed, hazards=(patch("spill_0", 5.5, 3.5),), ) for tick, frame in enumerate(script, start=1): current, _events = step(current, STEP_DT, [frame]) assert current.tick == tick return current first, second = run(0xC0FFEE), run(0xC0FFEE) assert first.state_hash() == second.state_hash() assert first.players == second.players def test_the_kernel_runs_the_movement_stage() -> None: state = kitchen([cook("p0", 5.5, 2.5)], rows=ROOM) current = state for tick in range(10): current, _events = step(current, STEP_DT, [press("p0", 1, 0, seq=tick)]) assert current.tick == 10 assert current.players[0].pos.x > 5.5 assert_no_body_in_terrain(current)