"""`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