**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`**.
496 lines
18 KiB
Python
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
|