"""`sim/carry.py` + the action gate's carry verbs — the hands, pinned. PLAN §5 gives the cook one button and a pair of hands; this file pins what the hands do: * **one item per hand** — a second pick while carrying is `HANDS_FULL`, and a press at open floor with a full hand is the ladder's named `NOTHING_HERE` (v1 has no floor drops); * **the set-down** — `RELEASE` / `PLACE_ON_STATION` / `PLATE_DISH` put the item on the fixture, stamp it with a mark, and leave it grabbable the moment it is set down (while an unmarked pipeline input stays the work press's); a pick-and-place round trip preserves the `Item`'s identity and clears the fixture's cell; * **the hand-off** — a pick of a set-down placed by another cook within `HANDOFF_WINDOW_TICKS` (inclusive) is a `HANDED_OFF` with both ids; a self-pick, or a third party's pick outside the window, is a plain `PICKED`; * **the trash** — a long press over the trash can destroys the carried item on the 8th substep of the hold and not the 7th; a short press and a walk-away do nothing; a threshold met with an empty hand is `TRASHED_ALREADY_EMPTY`, never a silent no-op; the raw score delta rides the event payload; * **the contested pick** — one winner, one `CONTESTED_LOST` loser, the item count conserved; * **the conservation law** — a 100-tick fuzz of random simultaneous presses from a fixed seed re-checks `hands + stations + trashed == initial` after *every* tick, and runs bit-identical twice. The two-player pass-and-contest script with its committed golden hash is `test_replay_carry.py`. """ from __future__ import annotations from dataclasses import replace from typing import Any import pytest from undercooked.sim.carry import ( HANDOFF_WINDOW_TICKS, TRASH_HOLD_TICKS, TRASH_SCORE_DELTA, first_loose_entry, pick_item, place_item, trash_effect, ) from undercooked.sim.grid import Grid from undercooked.sim.interact import ( ItemTarget, StationTarget, action_gate, resolve_action, select_target, ) from undercooked.sim.model import ( Event, EventKind, Facing, Item, ItemForm, ItemKind, KitchenState, Player, PlayerState, Station, StationKind, StationState, TileKind, Vec2, ) from undercooked.sim.rng import Rng from undercooked.sim.step import 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, ...] = (), tick: int = 0, ) -> KitchenState: return KitchenState( level_id="03_carry", seed=7, grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)), tick=tick, 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, state: PlayerState = PlayerState.IDLE, seat: int = 0, ) -> Player: return Player( id=player_id, pos=pos, facing=facing, carrying=carrying, state=state, seat=seat, ) def fixture( kind: StationKind = StationKind.STOVE, *, x: int = 5, y: int = 4, state: StationState = StationState.IDLE, contents: tuple[Item, ...] = (), dirty: bool = False, ) -> Station: return Station( id=f"{kind.value}-{x}-{y}", kind=kind, x=x, y=y, state=state, contents=contents, dirty=dirty, ) def tomato(form: ItemForm = ItemForm.RAW) -> Item: return Item(kind=ItemKind.TOMATO, form=form) def lettuce() -> Item: return Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW) def rice() -> Item: return Item(kind=ItemKind.RICE, form=ItemForm.RAW) def frame(player_id: str, seq: int, action: bool, tick: int) -> PlayerInput: return PlayerInput(player_id=player_id, seq=seq, action=action, received_at_tick=tick) def kinds(events: tuple[Event, ...]) -> list[EventKind]: return [EventKind(event.kind) for event in events] def payload(event: Event) -> dict[str, Any]: return dict(event.payload) def total_items(state: KitchenState) -> int: """The conservation quantity: hands + station contents + trashed.""" in_hands = sum(1 for player in state.players if player.carrying is not None) on_stations = sum(len(station.contents) for station in state.stations) trashed = state.counters.get("trashed", 0) return in_hands + on_stations + trashed # --- grabbability: a set-down is an object, a pipeline input is work --------------------- def test_a_set_down_item_is_targeted_by_a_free_hand_on_an_idle_surface() -> None: """The whole of the hand-off: a placed item on a counter is grabbable at once.""" prep = fixture(kind=StationKind.PREP, contents=(lettuce(),)) state = make_state(stations=(prep,), players=(cook("p0"),)) state = replace(state, item_marks={prep.id: (("placerr", 3),)}) target = select_target(state, state.players[0]) assert isinstance(target, ItemTarget) assert target.item == lettuce() def test_an_unmarked_loaded_surface_holds_its_item_for_the_work_press() -> None: """An onion on an idle board is *to be chopped*: no mark, not `READY`, not a shelf.""" board = fixture(kind=StationKind.CHOP_BOARD, contents=(tomato(),)) state = make_state(stations=(board,), players=(cook("p0"),)) target = select_target(state, state.players[0]) assert isinstance(target, StationTarget) assert first_loose_entry(state, board) is None def test_a_ready_unmarked_fixture_hands_its_output_to_a_free_hand() -> None: ready = fixture( kind=StationKind.CHOP_BOARD, 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 def test_the_shelf_hands_its_entries_whether_marked_or_stock() -> None: """Shelf entries are all grabbable: stock (unmarked) first, then set-downs.""" shelf = fixture(kind=StationKind.SPAWN_SHELF, contents=(tomato(), lettuce())) state = make_state(stations=(shelf,), players=(cook("p0"),)) entry = first_loose_entry(state, shelf) assert entry is not None and entry[0] == tomato() # the unmarked stock entry is first target = select_target(state, state.players[0]) assert isinstance(target, ItemTarget) assert target.item == tomato() def test_a_marked_set_down_stays_grabbable_after_the_placer_moves_away() -> None: """The mark outlives the press: the object is on the counter, not in a hand.""" station = fixture(kind=StationKind.PREP, contents=(lettuce(),)) state = make_state(stations=(station,), players=(cook("p0"),)) state = replace(state, item_marks={station.id: (("other", 5),)}) target = select_target(state, state.players[0]) assert isinstance(target, ItemTarget) assert target.item == lettuce() # --- one item per hand ----------------------------------------------------------------- def test_a_full_hand_refuses_a_second_pick_with_one_named_denial() -> None: ready = fixture(state=StationState.READY, contents=(tomato(),)) state = make_state(stations=(ready,), players=(cook("p0", carrying=lettuce()),)) frames = {"p0": frame("p0", 0, True, 0)} _new_state, events = action_gate(state, frames, STEP_DT) denied = [event for event in events if event.kind is EventKind.DENIED] assert len(denied) == 1 # one refusal per press, never per tick assert payload(denied[0])["reason"] == "hands_full" def test_a_press_at_open_floor_with_a_full_hand_is_a_named_nothing_here() -> None: """v1 has no floor drops: a `RELEASE` at a FLOOR cell is `NOTHING_HERE`, not a drop.""" state = make_state(players=(cook("p0", carrying=lettuce()),)) # no stations at all new_state, events = resolve_action(state, state.players[0]) assert new_state is state assert len(events) == 1 assert payload(events[0])["reason"] == "nothing_here" # --- pick/place round trip -------------------------------------------------------------- def test_pick_and_place_preserve_the_item_and_clear_the_station_cell() -> None: """The identity fields survive the whole round trip; the cell is cleared on the pick.""" item = Item(kind=ItemKind.TOMATO, form=ItemForm.CHOPPED, quality=0.42, ttl=3.5) ready = fixture(state=StationState.READY, contents=(item,)) shelf = fixture(kind=StationKind.SPAWN_SHELF, x=8) state = make_state(stations=(ready, shelf), players=(cook("p0"),)) new_state, events = resolve_action(state, state.players[0]) assert len(events) == 1 and events[0].kind is EventKind.PICKED carried = new_state.players[0].carrying assert carried is item # value identity: the very object left the fixture assert new_state.stations[0].contents == () # the cell is cleared # the set-down: move the cook to the shelf, press again cook_at_shelf = replace(new_state.players[0], pos=Vec2(8.5, 5.5)) new_state2, events2 = resolve_action(new_state, cook_at_shelf) assert len(events2) == 1 and events2[0].kind is EventKind.DROPPED assert new_state2.players[0].carrying is None assert new_state2.stations[1].contents == (item,) # the same object, back on a fixture assert item.quality == 0.42 and item.ttl == 3.5 # the fields never move @pytest.mark.parametrize( "kind", [StationKind.CHOP_BOARD, StationKind.PREP, StationKind.SPAWN_SHELF], ids=["place_on_station", "plate_dish", "release"], ) def test_place_verbs_put_the_item_on_the_counter_and_stamp_the_mark( kind: StationKind, ) -> None: """`PLACE_ON_STATION` (board), `PLATE_DISH` (the counter) and `RELEASE` (the shelf).""" held = lettuce() station = fixture(kind=kind) state = make_state(stations=(station,), players=(cook("p0", carrying=held),)) new_state, events = resolve_action(state, state.players[0]) assert len(events) == 1 and events[0].kind is EventKind.DROPPED assert new_state.players[0].carrying is None assert new_state.stations[0].contents == (held,) # the mark: who put it down, at which tick — the hand-off's whole memory assert new_state.item_marks[station.id] == (("p0", 0),) # --- the hand-off ----------------------------------------------------------------------- def _pass_kitchen() -> KitchenState: """A at the prep, B at its left diagonal, both facing the counter; A holds an item.""" prep = fixture(kind=StationKind.PREP, x=9, y=4) a = cook("cook-a", pos=Vec2(9.5, 5.5), carrying=lettuce(), seat=0) b = cook("cook-b", pos=Vec2(8.5, 5.5), seat=1) return make_state(stations=(prep,), players=(a, b)) def test_a_pass_is_a_handed_off_with_both_ids() -> None: state = _pass_kitchen() # t0: A sets the item down (the marked place) state, events = step(state, STEP_DT, (frame("cook-a", 0, True, 0),)) assert kinds(events) == [EventKind.DROPPED] assert payload(events[0])["player_id"] == "cook-a" # t1: A releases; B picks it up -> the sanctioned pass state, events = step( state, STEP_DT, (frame("cook-a", 1, False, 1), frame("cook-b", 1, True, 1)), ) handed = [event for event in events if event.kind is EventKind.HANDED_OFF] assert len(handed) == 1 assert payload(handed[0]) == { "from": "cook-a", "to": "cook-b", "item": "lettuce", "form": "raw", } assert state.players[1].carrying is not None assert state.players[0].carrying is None assert state.stations[0].contents == () def test_the_hand_off_window_is_inclusive_at_the_twentieth_tick() -> None: station = fixture(kind=StationKind.PREP) a = cook("cook-a", carrying=lettuce()) b = cook("cook-b", seat=1) state = make_state(stations=(station,), players=(a, b)) held = a.carrying assert held is not None placed, _events = place_item(state, a, station, held) at_boundary = placed.with_tick(HANDOFF_WINDOW_TICKS) _new_state, events = pick_item(at_boundary, b, station, lettuce()) assert kinds(events) == [EventKind.HANDED_OFF] assert payload(events[0]) == { "from": "cook-a", "to": "cook-b", "item": "lettuce", "form": "raw", } just_after = placed.with_tick(HANDOFF_WINDOW_TICKS + 1) _new_state, events = pick_item(just_after, b, station, lettuce()) assert kinds(events) == [EventKind.PICKED] def test_the_placer_picking_their_own_item_is_a_plain_pick() -> None: station = fixture(kind=StationKind.PREP) a = cook("cook-a", carrying=lettuce()) state = make_state(stations=(station,), players=(a,)) held = a.carrying assert held is not None placed, _events = place_item(state, a, station, held) new_state, events = pick_item(placed.with_tick(1), a, station, lettuce()) assert kinds(events) == [EventKind.PICKED] assert new_state.players[0].carrying is not None def test_a_third_party_pick_outside_the_window_is_a_plain_pick() -> None: """A's set-down, C's pick one tick past the window: a pick, never a pass.""" station = fixture(kind=StationKind.PREP) a = cook("cook-a", carrying=lettuce()) c = cook("cook-c", seat=2) state = make_state(stations=(station,), players=(a, c)) held = a.carrying assert held is not None placed, _events = place_item(state, a, station, held) late = placed.with_tick(HANDOFF_WINDOW_TICKS + 1) new_state, events = pick_item(late, c, station, lettuce()) assert kinds(events) == [EventKind.PICKED] assert payload(events[0])["player_id"] == "cook-c" assert new_state.players[1].carrying is not None assert EventKind.HANDED_OFF not in kinds(events) def test_a_same_tick_pass_is_inside_the_window() -> None: """Place and pick in the same substep (seat order): the pass, at zero ticks old.""" station = fixture(kind=StationKind.PREP) a = cook("cook-a", carrying=lettuce(), seat=0) b = cook("cook-b", seat=1) state = make_state(stations=(station,), players=(a, b)) frames = {"cook-a": frame("cook-a", 0, True, 0), "cook-b": frame("cook-b", 0, True, 0)} new_state, events = action_gate(state, frames, STEP_DT) assert kinds(events) == [EventKind.DROPPED, EventKind.HANDED_OFF] assert payload(events[1]) == { "from": "cook-a", "to": "cook-b", "item": "lettuce", "form": "raw", } assert new_state.players[1].carrying is not None assert new_state.players[0].carrying is None assert new_state.stations[0].contents == () # --- the trash --------------------------------------------------------------------------- def _trash_kitchen() -> KitchenState: trash = fixture(kind=StationKind.TRASH) return make_state(stations=(trash,), players=(cook("p0", carrying=lettuce()),)) def test_a_trash_long_press_destroys_on_the_eighth_substep_not_the_seventh() -> None: state = _trash_kitchen() for substep in range(TRASH_HOLD_TICKS - 1): # substeps 1..7: the hold builds state, events = step(state, STEP_DT, (frame("p0", substep, True, state.tick),)) assert state.players[0].carrying is not None assert EventKind.TRASHED not in kinds(events) # the 8th substep of the hold: the item goes state, events = step( state, STEP_DT, (frame("p0", TRASH_HOLD_TICKS - 1, True, state.tick),) ) trashed = [event for event in events if event.kind is EventKind.TRASHED] assert len(trashed) == 1 assert payload(trashed[0]) == { "player_id": "p0", "item": "lettuce", "form": "raw", "score_delta": TRASH_SCORE_DELTA, } assert state.players[0].carrying is None assert state.counters["trashed"] == 1 assert state.score == 0 # the raw delta is phase 04's to spend (clamped at 0) def test_a_short_press_does_not_trash() -> None: state = _trash_kitchen() for substep in range(3): state, _events = step(state, STEP_DT, (frame("p0", substep, True, state.tick),)) state, _events = step(state, STEP_DT, (frame("p0", 3, False, state.tick),)) assert state.players[0].carrying is not None assert "trashed" not in state.counters def test_releasing_before_the_threshold_starts_a_fresh_hold() -> None: state = _trash_kitchen() plan = (True, True, True, True, True, False) + (True,) * TRASH_HOLD_TICKS for substep, action in enumerate(plan): state, events = step(state, STEP_DT, (frame("p0", substep, action, state.tick),)) if substep < len(plan) - 1: assert state.players[0].carrying is not None assert EventKind.TRASHED not in kinds(events) assert state.players[0].carrying is None # the 8th substep of the *second* hold assert state.counters["trashed"] == 1 def test_walking_away_from_the_trash_voids_the_hold() -> None: """The gate re-derives the hold: off the can, the button is no longer `TRASH`.""" state = _trash_kitchen() for substep in range(TRASH_HOLD_TICKS): # the hold's 8 substeps, walking right walking = PlayerInput( player_id="p0", seq=substep, action=True, move=Vec2(1.0, 0.0), received_at_tick=state.tick, ) state, events = step(state, STEP_DT, (walking,)) assert EventKind.TRASHED not in kinds(events) for substep in range(TRASH_HOLD_TICKS, TRASH_HOLD_TICKS + 6): state, events = step(state, STEP_DT, (frame("p0", substep, True, state.tick),)) assert EventKind.TRASHED not in kinds(events) assert state.players[0].carrying is not None # the hold was voided, nothing trashed assert "trashed" not in state.counters def test_a_trash_threshold_with_an_empty_hand_is_named_not_silent() -> None: """Defensive path: the threshold met with an empty hand is an event, not a no-op.""" trash = fixture(kind=StationKind.TRASH) state = make_state(stations=(trash,), players=(cook("p0"),)) # nothing carried before = state.state_hash() new_state, events = trash_effect(state, state.players[0]) assert kinds(events) == [EventKind.TRASHED_ALREADY_EMPTY] assert payload(events[0]) == {"player_id": "p0"} assert new_state is state # nothing moved assert state.state_hash() == before # --- contested picks (the new paths, conserved) ------------------------------------------ def _contested_kitchen() -> tuple[KitchenState, dict[str, PlayerInput]]: """Two cooks on one ready item: seat 0 in front, seat 1 on the left diagonal.""" ready = fixture(state=StationState.READY, contents=(tomato(),)) state = make_state( stations=(ready,), players=( cook("cook-a", seat=0), # (5, 5) facing UP: front cell (5, 4) cook("cook-b", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1), ), ) frames = { "cook-a": frame("cook-a", 0, True, 0), "cook-b": frame("cook-b", 0, True, 0), } return state, frames def test_a_contested_pick_has_exactly_one_winner_and_conserves_items() -> None: state, frames = _contested_kitchen() initial = total_items(state) new_state, events = action_gate(state, frames, STEP_DT) picked = [event for event in events if event.kind is EventKind.PICKED] denied = [event for event in events if event.kind is EventKind.DENIED] assert len(picked) == 1 and len(denied) == 1 assert payload(picked[0])["player_id"] == "cook-a" # the full tie goes to the seat assert payload(denied[0]) == { "player_id": "cook-b", "reason": "contested_lost", "verb": "none", } assert new_state.players[0].carrying is not None assert new_state.players[1].carrying is None assert new_state.stations[0].contents == () assert total_items(new_state) == initial # the item is in exactly one place def test_the_earlier_received_frame_wins_regardless_of_seat() -> None: state, frames = _contested_kitchen() frames["cook-a"] = frame("cook-a", 0, True, 1) # seat 0 arrives one tick later new_state, _events = action_gate(state, frames, STEP_DT) assert new_state.players[1].carrying is not None # the earlier frame wins assert new_state.players[0].carrying is None def test_a_contested_start_work_claims_the_worker_slot() -> None: """A loaded unmarked board: the single worker slot is the claim, not an item.""" board = fixture(kind=StationKind.CHOP_BOARD, contents=(tomato(),)) a = cook("cook-a", seat=0) b = cook("cook-b", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1) state = make_state(stations=(board,), players=(a, b)) frames = {"cook-a": frame("cook-a", 0, True, 0), "cook-b": frame("cook-b", 0, True, 0)} new_state, events = action_gate(state, frames, STEP_DT) denied = [event for event in events if event.kind is EventKind.DENIED] assert len(denied) == 1 assert payload(denied[0]) == { "player_id": "cook-b", "reason": "contested_lost", "verb": "none", } assert new_state.stations[0].contents == (tomato(),) # the input stays for the work # --- the conservation law: 100-tick fuzz ------------------------------------------------- DIRECTIONS: tuple[Vec2, ...] = ( Vec2(0.0, 0.0), Vec2(1.0, 0.0), Vec2(-1.0, 0.0), Vec2(0.0, 1.0), Vec2(0.0, -1.0), Vec2(1.0, 1.0), Vec2(1.0, -1.0), Vec2(-1.0, 1.0), Vec2(-1.0, -1.0), ) def _fuzz_kitchen() -> KitchenState: """Three cooks, one carried item each, at a shelf / prep / trash in a row.""" shelf = fixture(kind=StationKind.SPAWN_SHELF, x=5) prep = fixture(kind=StationKind.PREP, x=7) trash = fixture(kind=StationKind.TRASH, x=9) state = make_state( stations=(shelf, prep, trash), players=( cook("p0", pos=Vec2(5.5, 5.5), carrying=tomato(), seat=0), cook("p1", pos=Vec2(7.5, 5.5), carrying=lettuce(), seat=1), cook("p2", pos=Vec2(9.5, 5.5), carrying=rice(), seat=2), ), ) assert total_items(state) == 3 return state def _fuzz_run(seed: int, ticks: int = 100) -> tuple[KitchenState, list[str]]: """One fuzz run: random move + random press per cook per tick, from `seed`.""" rng = Rng(seed) state = _fuzz_kitchen() hashes: list[str] = [] for tick in range(ticks): frames = tuple( PlayerInput( player_id=player.id, seq=tick, move=rng.choice(DIRECTIONS), action=rng.next_float() < 0.35, received_at_tick=state.tick, ) for player in state.players ) state, _events = step(state, STEP_DT, frames) assert total_items(state) == 3, f"item count lost at tick {state.tick}" hashes.append(state.state_hash()) return state, hashes def test_the_item_count_is_conserved_over_a_hundred_tick_fuzz() -> None: """The per-tick assert is the point: a leak mid-run would surface immediately.""" state, _hashes = _fuzz_run(0xC0FFEE) assert total_items(state) == 3 # and at the end, as the task requires def test_the_fuzz_is_bit_identical_across_two_runs() -> None: first, first_hashes = _fuzz_run(0xC0FFEE) second, second_hashes = _fuzz_run(0xC0FFEE) assert first_hashes == second_hashes # the whole history, tick for tick assert first.state_hash() == second.state_hash()