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:
2026-10-07 02:46:10 -04:00
parent 3426264ffb
commit 4febdfe780
41 changed files with 17690 additions and 23 deletions
+881
View File
@@ -0,0 +1,881 @@
"""`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)