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:
@@ -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)
|
||||
Reference in New Issue
Block a user