Files
ducoterra 4febdfe780 phase: 03_simulation_movement_and_interaction
**Final verification pass — phase 03 `simulation_movement_and_interaction` (all 5 task files already in `complete/`)**

- Verified all phase-03 deliverables exist and work: `sim/` kernel (`step`, `state_build`, `rng`, `movement`, `collision`, `interact`, `carry`, `replay`), replay fixtures + 3 committed goldens, `docs/SIMULATION.md`.
- `bash .agents/validate.sh` → **passed**: ruff `All checks passed!`, pyright `0 errors`, `undercooked-levels validate` green, full `pytest` **1033 passed** (coverage **99.71%** ≥ 90% floor), slow sim stage green, repo-hygiene clean.
- Perf smoke (4 players, 2 000 ticks): mean **0.049 ms/tick, 20 423 ticks/s** — ≥ 10 000 ticks/s criterion met (test lives in `test_perf_smoke.py`, which the gate's slow stage runs; the overview names it `test_replay.py`).
- Completion criteria: import `undercooked.sim.step.step` ✅ · `tests/sim` green incl. determinism + goldens ✅ · perf ✅ · constant-change-fails-by-design ✅ (committed-hash equality + mutation test naming first bad tick/field; goldens never rewritten by tests) · `docs/SIMULATION.md` ✅ (constants, 8-step order, purity/determinism, re-bless) · full suite + coverage ✅ · app launches: `/healthz` 200, `/` 200, `/api/levels` serves content ✅ · replay CLI `check walk_solo.json` exit 0 ✅ · no regression in phases 01/02 (their suites green) ✅.
- Documentation: README.md — fixed one stale inconsistency (Status opening listed only 01/02 complete with 03 pending; now lists 01–03 complete, next pending `04_kitchen_pipeline_and_scoring`); all other README claims (CLI flags, config table, shell text, replay CLI exit codes) verified against code. AGENTS.md — verified consistent with the project as it stands, no changes needed.
- No deviations. Next pending phase: **`04_kitchen_pipeline_and_scoring`**.
2026-10-07 02:46:10 -04:00

496 lines
18 KiB
Python

"""`sim/interact.py` targeting — what the reach zone contains (phase 03 task 03).
The reach zone is discrete first and continuous second: `candidate_cells()` names
the tiles the hand walks (front, the two diagonal-front, underfoot) and
`REACH + FORGIVE` decides whether the cook is actually standing close enough to
the tile it found. This file pins both halves with hand-computed geometry:
* **candidate cells** — the per-facing order, and the fact that the four
facings are 90° rotations of one row rather than four ad-hoc tie-breaks;
* **reach** — the forgiveness boundary (0.99 ok, 1.6 itself inclusive, 1.61
rejected), including the case the diagonal tier exists for: the same diagonal
station is out of reach from the far corner of the cook's tile and in reach
from the near corner;
* **priority** — item outranks the fixture it sits on, a full hand skips the
item, a non-ready fixture holds its contents, the serve window is its own
target kind, nothing at all is `None`;
* **the wall** — a fixture two tiles ahead (wall in between) is never a
candidate: the discrete walk has no "through the wall" mode, and the
out-of-reach skip keeps the walk going so a nearer tile still gets its turn.
The press-edge behaviour — one press, one named refusal, however long the button
stays held — is at the bottom, through the full `step()`.
"""
from __future__ import annotations
import pytest
from undercooked.sim.grid import Grid
from undercooked.sim.interact import (
FORGIVE,
REACH,
DeniedReason,
ItemTarget,
ServeTarget,
StationTarget,
Verb,
candidate_cells,
faces_target,
select_target,
select_verb,
station_cells,
target_center,
within_reach,
)
from undercooked.sim.model import (
EventKind,
Facing,
Item,
ItemForm,
ItemKind,
KitchenState,
Player,
PlayerState,
Station,
StationKind,
StationState,
TileKind,
Vec2,
)
from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput, step
pytestmark = pytest.mark.unit
WIDTH, HEIGHT = 13, 9
DEFAULT_POS = Vec2(5.5, 5.5) # module-level so a default argument may use it (B008)
# --- fixtures ---------------------------------------------------------------------------
def make_state(
stations: tuple[Station, ...] = (),
players: tuple[Player, ...] = (),
tile_overrides: dict[tuple[int, int], TileKind] | None = None,
) -> KitchenState:
tiles = [TileKind.FLOOR] * (WIDTH * HEIGHT)
for (x, y), kind in (tile_overrides or {}).items():
tiles[y * WIDTH + x] = kind
return KitchenState(
level_id="03_targeting",
seed=1,
grid=Grid(width=WIDTH, height=HEIGHT, tiles=tuple(tiles)),
stations=stations,
players=players,
target_score=40,
time_left=90.0,
)
def cook(
player_id: str = "p0",
*,
pos: Vec2 = DEFAULT_POS,
facing: Facing = Facing.UP,
carrying: Item | None = None,
stun: float = 0.0,
state: PlayerState = PlayerState.IDLE,
seat: int = 0,
held_ticks: int = 0,
pressed_tick: int = 0,
) -> Player:
return Player(
id=player_id,
pos=pos,
facing=facing,
carrying=carrying,
state=state,
stun=stun,
seat=seat,
action_held_ticks=held_ticks,
action_pressed_tick=pressed_tick,
)
def fixture(
kind: StationKind = StationKind.STOVE,
*,
x: int = 5,
y: int = 4,
state: StationState = StationState.IDLE,
contents: tuple[Item, ...] = (),
worker_id: str | None = None,
dirty: bool = False,
cooldown: float = 0.0,
) -> Station:
return Station(
id=f"{kind.value}-{x}-{y}",
kind=kind,
x=x,
y=y,
state=state,
contents=contents,
worker_id=worker_id,
dirty=dirty,
cooldown=cooldown,
)
def tomato(form: ItemForm = ItemForm.RAW) -> Item:
return Item(kind=ItemKind.TOMATO, form=form)
# --- candidate cells --------------------------------------------------------------------
@pytest.mark.parametrize(
("facing", "expected"),
[
(Facing.UP, ((2, 2), (1, 2), (3, 2), (2, 3))),
(Facing.RIGHT, ((3, 3), (3, 2), (3, 4), (2, 3))),
(Facing.DOWN, ((2, 4), (3, 4), (1, 4), (2, 3))),
(Facing.LEFT, ((1, 3), (1, 4), (1, 2), (2, 3))),
],
)
def test_candidate_cells_walk_front_then_both_diagonals_then_underfoot(
facing: Facing, expected: tuple[tuple[int, int], ...]
) -> None:
# The cook stands at the centre of cell (2, 3); every facing reports the front
# tile first, its two diagonal neighbours second (left, then right, in the
# rotated row), and the tile underfoot last.
player = cook("p0", pos=Vec2(2.5, 3.5), facing=facing)
assert player.pos == Vec2(2.5, 3.5)
assert candidate_cells(player) == expected
def test_candidate_cells_do_not_depend_on_where_in_the_tile_the_cook_stands() -> None:
positions = [Vec2(2.51, 3.99), Vec2(2.02, 3.01), Vec2(2.99, 3.51)]
for facing in Facing:
baseline = candidate_cells(cook("p0", pos=positions[0], facing=facing))
for position in positions[1:]:
assert candidate_cells(cook("p0", pos=position, facing=facing)) == baseline
def test_candidate_cells_use_the_floor_of_the_position() -> None:
# (3.0, 3.0) is in cell (3, 3), not (2, 2): the body centre decides.
assert candidate_cells(cook("p0", pos=Vec2(3.0, 3.0), facing=Facing.UP)) == (
(3, 2),
(2, 2),
(4, 2),
(3, 3),
)
def test_candidate_cells_are_ordered_and_deduplicated() -> None:
cells = candidate_cells(cook("p0", pos=Vec2(2.5, 3.5), facing=Facing.RIGHT))
assert len(cells) == 4
assert len(set(cells)) == 4
assert cells[3] == (2, 3) # underfoot is always last, whatever the facing
# --- the forgiveness boundary -----------------------------------------------------------
def test_reach_plus_forgive_is_the_number_the_boundary_tests_use() -> None:
assert abs((REACH + FORGIVE) - 1.6) < 1e-12
@pytest.mark.parametrize(
("distance", "in_reach"),
[
(0.99, True), # the task boundary: 0.99 is ok
(REACH, True), # the front tile itself
(1.6, True), # the boundary is inclusive (with REACH_EPS of slack)
(1.61, False), # the task boundary: 1.61 is rejected
],
)
def test_within_reach_forgives_up_to_the_boundary(
distance: float, in_reach: bool
) -> None:
state = make_state(stations=(fixture(),))
# The player sits on the vertical line through the station centre (5.5, 4.5),
# so the distance is exactly `distance` and no corner math gets in.
player = cook("p0", pos=Vec2(5.5, 4.5 - distance), facing=Facing.UP)
assert within_reach(state, player, StationTarget(state.stations[0])) is in_reach
def test_target_center_is_the_cell_centre_for_a_fixture() -> None:
state = make_state(stations=(fixture(kind=StationKind.SINK, x=3, y=2),))
assert target_center(state, StationTarget(state.stations[0])) == Vec2(3.5, 2.5)
def test_target_center_falls_back_to_the_stamped_position_when_the_station_is_gone() -> None:
state = make_state()
stale = ItemTarget(item=tomato(), on_station_id="gone", at=Vec2(5.5, 4.5))
assert target_center(state, stale) == Vec2(5.5, 4.5)
def test_an_item_already_in_the_hand_needs_no_reach() -> None:
state = make_state()
player = cook("p0")
in_hand = ItemTarget(item=tomato()) # no station, no world position
assert target_center(state, in_hand) is None
assert within_reach(state, player, in_hand) is True
assert faces_target(state, player, in_hand) is True
# --- select_target: reach through the cell walk ------------------------------------------
def test_a_station_at_0p99_in_front_is_targeted() -> None:
state = make_state(
stations=(fixture(),),
players=(cook("p0", pos=Vec2(5.5, 4.51), facing=Facing.DOWN),), # 0.99 below the centre
)
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
assert target.station.id == "stove-5-4"
def test_a_diagonal_station_at_1p61_is_skipped_not_refused() -> None:
# From the far corner of the cook's own tile the diagonal centre is
# 1.15 * sqrt(2) = 1.618 away, outside REACH + FORGIVE, so the tile is
# skipped rather than becoming the answer.
state = make_state(
stations=(fixture(kind=StationKind.STOVE, x=6, y=6),),
players=(cook("p0", pos=Vec2(5.35, 5.35), facing=Facing.RIGHT),),
)
assert select_target(state, state.players[0]) is None
def test_the_same_diagonal_station_is_reachable_from_the_near_corner() -> None:
state = make_state(
stations=(fixture(kind=StationKind.STOVE, x=6, y=6),),
players=(cook("p0", pos=Vec2(5.65, 5.65), facing=Facing.RIGHT),),
)
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
assert target.station.id == "stove-6-6"
def test_an_out_of_reach_diagonal_is_skipped_so_the_tile_underfoot_still_wins() -> None:
state = make_state(
stations=(
fixture(kind=StationKind.STOVE, x=6, y=6), # candidate 3, out of reach here
fixture(kind=StationKind.SINK, x=5, y=5), # candidate 4, underfoot, in reach
),
players=(cook("p0", pos=Vec2(5.35, 5.35), facing=Facing.RIGHT),),
)
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
assert target.station.kind is StationKind.SINK
# --- select_target: priority ----------------------------------------------------------------
def test_a_ready_fixture_with_contents_yields_the_item_to_a_free_hand() -> None:
ready = fixture(state=StationState.READY, contents=(tomato(ItemForm.CHOPPED),))
state = make_state(stations=(ready,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, ItemTarget)
assert target.item.form is ItemForm.CHOPPED
assert target.on_station_id == ready.id
assert target.at == Vec2(5.5, 4.5)
def test_a_full_hand_skips_the_item_and_gets_the_fixture_instead() -> None:
ready = fixture(state=StationState.READY, contents=(tomato(ItemForm.CHOPPED),))
state = make_state(
stations=(ready,),
players=(
cook(
"p0",
carrying=Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW),
),
),
)
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
assert target.station is ready
def test_a_spawn_shelf_hands_stock_even_while_idle() -> None:
shelf = fixture(kind=StationKind.SPAWN_SHELF, contents=(tomato(),))
state = make_state(stations=(shelf,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, ItemTarget)
assert target.item.kind is ItemKind.TOMATO
def test_a_spawn_shelf_with_no_stock_is_a_plain_fixture() -> None:
shelf = fixture(kind=StationKind.SPAWN_SHELF)
state = make_state(stations=(shelf,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
def test_a_non_ready_fixture_holds_its_contents() -> None:
working = fixture(
kind=StationKind.CHOP_BOARD,
state=StationState.WORKING,
contents=(tomato(),),
worker_id="p0",
)
state = make_state(stations=(working,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget) # the onion is *to be chopped*, not grabbable
def test_a_ready_fixture_with_empty_contents_is_a_plain_fixture() -> None:
ready = fixture(state=StationState.READY)
state = make_state(stations=(ready,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
def test_a_serve_window_is_a_serve_target_not_a_station_target() -> None:
window = fixture(kind=StationKind.SERVE)
state = make_state(stations=(window,), players=(cook("p0"),))
target = select_target(state, state.players[0])
assert isinstance(target, ServeTarget)
assert target.station is window
def test_nothing_in_the_zone_is_none() -> None:
state = make_state(players=(cook("p0"),))
assert select_target(state, state.players[0]) is None
def test_a_station_two_tiles_away_is_never_a_candidate_even_beside_a_wall() -> None:
# The wall fills the front tile; the stove is beyond it. The cell walk has
# no "through the wall" mode, so the stove is simply not a candidate and
# the press cannot land on it.
state = make_state(
stations=(fixture(kind=StationKind.STOVE, x=5, y=3),),
players=(cook("p0"),),
tile_overrides={(5, 4): TileKind.WALL},
)
assert select_target(state, state.players[0]) is None
def test_the_front_tile_outranks_a_diagonal_one() -> None:
state = make_state(
stations=(
fixture(kind=StationKind.STOVE, x=6, y=4), # diagonal (candidate 3 for UP)
fixture(kind=StationKind.CHOP_BOARD, x=5, y=4), # front (candidate 1)
),
players=(cook("p0"),),
)
target = select_target(state, state.players[0])
assert isinstance(target, StationTarget)
assert target.station.kind is StationKind.CHOP_BOARD
def test_station_cells_keys_fixtures_by_their_authored_tile() -> None:
state = make_state(
stations=(
fixture(kind=StationKind.STOVE, x=3, y=2),
fixture(kind=StationKind.SINK, x=7, y=1),
)
)
cells = station_cells(state)
assert set(cells) == {(3, 2), (7, 1)}
assert cells[(3, 2)].kind is StationKind.STOVE
# --- facing: the half-plane, not an angle ---------------------------------------------------
def test_faces_target_refuses_a_target_behind_the_cook() -> None:
state = make_state(stations=(fixture(kind=StationKind.SERVE, x=5, y=6),))
player = cook("p0", pos=Vec2(5.5, 5.5), facing=Facing.UP)
assert faces_target(state, player, StationTarget(state.stations[0])) is False
def test_faces_target_accepts_a_target_level_with_the_cook() -> None:
state = make_state(stations=(fixture(kind=StationKind.SERVE, x=4, y=5),))
player = cook("p0", pos=Vec2(5.5, 5.5), facing=Facing.UP)
# `along` is 0 for a target on the cook's own row, so a serve window the
# cook is standing beside is not refused.
assert faces_target(state, player, StationTarget(state.stations[0])) is True
# --- the STUNNED denial ---------------------------------------------------------------------
def test_a_stunned_cook_is_refused_before_the_target_is_even_read() -> None:
state = make_state(
stations=(fixture(kind=StationKind.SPAWN_SHELF),),
players=(cook("p0", stun=0.3),),
)
target = select_target(state, state.players[0])
verb, denial = select_verb(state, state.players[0], target)
assert (verb, denial) == (Verb.NONE, DeniedReason.STUNNED)
def test_the_stunned_state_flag_alone_is_enough() -> None:
state = make_state(
stations=(fixture(kind=StationKind.SPAWN_SHELF),),
players=(cook("p0", state=PlayerState.STUNNED, stun=0.0),),
)
target = select_target(state, state.players[0])
verb, denial = select_verb(state, state.players[0], target)
assert (verb, denial) == (Verb.NONE, DeniedReason.STUNNED)
# --- one press, one event (through the full kernel) -------------------------------------------
def test_a_do_nothing_press_emits_exactly_one_denied_not_one_per_tick() -> None:
state = make_state(players=(cook("p0"),))
frame = PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0)
first_before_hash = state.state_hash()
first, events = step(state, STEP_DT, (frame,))
assert len(events) == 1
assert events[0].kind is EventKind.DENIED
assert dict(events[0].payload) == {
"player_id": "p0",
"reason": "nothing_here",
"verb": "none",
}
assert (events[0].x, events[0].y) == (5.5, 5.5)
assert first.players[0].action_held_ticks == 1
assert first.players[0].action_pressed_tick == 0
# The same frame on the next tick continues the press: no new edge, no new
# event, the hold just gets longer.
second, held_events = step(first, STEP_DT, (frame,))
assert held_events == ()
assert second.players[0].action_held_ticks == 2
assert second.players[0].action_pressed_tick == 0
# One call that runs two substeps: still exactly one press.
batched, batched_events = step(state, MAX_DT, (frame,))
assert sum(event.kind is EventKind.DENIED for event in batched_events) == 1
assert batched.players[0].action_held_ticks == 2
# Releasing the button clears the counters and the next press is a new one.
released, released_events = step(
second, STEP_DT, (PlayerInput(player_id="p0", seq=1, action=False),)
)
assert released_events == ()
assert released.players[0].action_held_ticks == 0
assert released.players[0].action_pressed_tick == 0
# Purity: the state the first press was handed is still exactly what it was.
assert state.state_hash() == first_before_hash
assert state.players[0].action_held_ticks == 0
def test_a_press_at_a_station_out_of_reach_is_one_named_refusal() -> None:
state = make_state(
stations=(fixture(kind=StationKind.STOVE, x=5, y=3),),
players=(cook("p0"),),
tile_overrides={(5, 4): TileKind.WALL},
)
frame = PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0)
first, events = step(state, STEP_DT, (frame,))
assert len(events) == 1
assert events[0].kind is EventKind.DENIED
# The discrete zone has no "through the wall" mode, so the press surfaces as
# nothing_here: the one effect, named, at the cook.
assert dict(events[0].payload)["reason"] == "nothing_here"
assert first.players[0].action_held_ticks == 1