"""`sim/interact.py` verb ladder — what a press means, or why it means nothing. PLAN §5 gives the cook one button and a priority order; this file pins it table-driven as `(carrying, target) -> (verb, denial)`, covering **every** `Verb` and **every** `DeniedReason` at least once (the coverage tests at the bottom assert that the table and the contested tests together cannot quietly lose a row — `CONTESTED_LOST` is produced by the gate, not by `select_verb`). The order as implemented: ```text stunned -> STUNNED nothing in the zone -> NOTHING_HERE beyond REACH+FORGIVE -> OUT_OF_REACH behind the cook -> NOT_FACING item in reach: full hand -> HANDS_FULL, no holder -> INVALID_STATE, otherwise PICK_ITEM fixture in reach: state first (dirty -> WASH with a free hand, STATION_DIRTY with a full one; working -> HOLD_WORK/STATION_BUSY, blocked -> INVALID_STATE, cooling -> STATION_BUSY, ready -> PICK_FROM_STATION), then kind: serve / wash / trash / shelf / work surface ``` After the matrix: what `resolve_action` implements in *this* phase — the pick (with its dirty side effect), the set-down (which becomes a pipeline input when the station works the item), the named refusals, and the deliberate no-ops that remain (the `TRASH` press whose hold the gate spends — the `SERVE` press left with task 03: it is the scoring's) — plus the contested arbitration `action_gate` runs when two cooks press for one thing. The work verbs (`START_WORK`, `HOLD_WORK`, `WASH`, the set-downs) are spent by `sim/stations.py` (phase 04 task 01); `tests/sim/test_stations.py` pins what they do. """ from __future__ import annotations from dataclasses import replace from typing import Any import pytest from undercooked.sim.grid import Grid from undercooked.sim.interact import ( DeniedReason, ItemTarget, ServeTarget, StationTarget, Target, Verb, action_gate, claim_rank, claims_entity, resolve_action, select_target, select_verb, target_key, ) from undercooked.sim.model import ( EventKind, Facing, Item, ItemForm, ItemKind, KitchenState, Player, PlayerState, Station, StationKind, StationState, TileKind, Vec2, ) from undercooked.sim.recipe import RecipeBook from undercooked.sim.step import STEP_DT, PlayerInput 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, ...] = (), ) -> KitchenState: return KitchenState( level_id="03_verbs", seed=2, grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)), 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) def lettuce() -> Item: return Item(kind=ItemKind.LETTUCE, form=ItemForm.RAW) def plated_dish() -> Item: return Item(kind=ItemKind.PLATE, form=ItemForm.PLATED) def dirty_plate() -> Item: return Item(kind=ItemKind.PLATE, form=ItemForm.DIRTY) def verbs_book() -> RecipeBook: """The one-recipe book the `SERVE` routing test threads in (`soup`, a tomato, worth 40).""" return RecipeBook.model_validate( { "recipes": { "soup": { "id": "soup", "name": "Soup", "base_score": 40, "ingredients": [{"kind": "tomato", "form": "raw"}], "steps": [{"kind": "serve", "station": "serve"}], } } } ) # --- the verb ladder, table-driven -------------------------------------------------------- # # The cook defaults to cell (5, 5) facing UP; the fixture defaults to the front # cell (5, 4). Rows override either. `expect` is the (Verb, DeniedReason) pair. MATRIX: list[dict[str, Any]] = [ # the cook's own condition outranks everything { "name": "stun_outranks_a_valid_target", "player": {"stun": 0.3}, "station": {"kind": StationKind.SPAWN_SHELF}, "target": "station", "expect": (Verb.NONE, DeniedReason.STUNNED), }, { "name": "the_stunned_state_flag_alone_refuses", "player": {"state": PlayerState.STUNNED}, "station": {"kind": StationKind.SPAWN_SHELF}, "target": "station", "expect": (Verb.NONE, DeniedReason.STUNNED), }, # nothing to act on { "name": "nothing_at_all_is_a_named_refusal", "target": "none", "expect": (Verb.NONE, DeniedReason.NOTHING_HERE), }, { "name": "a_target_beyond_reach_plus_forgive_is_out_of_reach", "player": {"pos": Vec2(8.5, 5.5)}, "station": {"kind": StationKind.STOVE}, "target": "station", "expect": (Verb.NONE, DeniedReason.OUT_OF_REACH), }, { "name": "a_target_behind_the_cook_is_not_facing", "station": {"y": 6}, "target": "station", "expect": (Verb.NONE, DeniedReason.NOT_FACING), }, { "name": "a_lateral_target_passes_the_facing_test_into_the_ladder", "station": {"x": 4, "y": 5}, "target": "station", "expect": (Verb.NONE, DeniedReason.INVALID_STATE), }, # items: one hand per cook, and a holder the state can actually empty { "name": "a_free_hand_picks_an_item", "station": {"state": StationState.READY, "contents": (tomato(),)}, "target": "item", "expect": (Verb.PICK_ITEM, None), }, { "name": "a_full_hand_refuses_the_item", "player": {"carrying": "lettuce"}, "target": "item", "expect": (Verb.NONE, DeniedReason.HANDS_FULL), }, { "name": "an_item_with_no_holder_is_invalid_state", "target": "item_loose", "expect": (Verb.NONE, DeniedReason.INVALID_STATE), }, { "name": "an_item_whose_fixture_left_the_state_is_invalid_state", "target": "item_ghost", "expect": (Verb.NONE, DeniedReason.INVALID_STATE), }, # station state outranks station kind { "name": "a_dirty_fixture_is_washed_with_a_free_hand", "station": {"dirty": True}, "target": "station", "expect": (Verb.WASH, None), }, { "name": "a_dirty_fixture_refuses_a_full_hand", "station": {"dirty": True}, "player": {"carrying": "tomato"}, "target": "station", "expect": (Verb.NONE, DeniedReason.STATION_DIRTY), }, { "name": "the_dirty_state_is_washed_with_a_free_hand", "station": {"state": StationState.DIRTY}, "target": "station", "expect": (Verb.WASH, None), }, { "name": "a_dirty_blocked_fixture_reports_the_dirt_first", "station": {"dirty": True, "state": StationState.BLOCKED}, "target": "station", "expect": (Verb.WASH, None), }, { "name": "another_cooks_working_fixture_is_busy", "station": {"state": StationState.WORKING, "worker_id": "p9"}, "target": "station", "expect": (Verb.NONE, DeniedReason.STATION_BUSY), }, { "name": "your_own_working_fixture_is_held", "station": {"state": StationState.WORKING, "worker_id": "p0"}, "target": "station", "expect": (Verb.HOLD_WORK, None), }, { "name": "an_unattributed_working_fixture_is_held_not_busy", "station": {"state": StationState.WORKING}, "target": "station", "expect": (Verb.HOLD_WORK, None), }, { "name": "a_blocked_fixture_admits_no_verb", "station": {"state": StationState.BLOCKED}, "target": "station", "expect": (Verb.NONE, DeniedReason.INVALID_STATE), }, { "name": "a_busy_fixture_is_busy", "station": {"state": StationState.BUSY}, "target": "station", "expect": (Verb.NONE, DeniedReason.STATION_BUSY), }, { "name": "a_cooling_fixture_is_busy", "station": {"cooldown": 0.5}, "target": "station", "expect": (Verb.NONE, DeniedReason.STATION_BUSY), }, { "name": "a_ready_fixture_hands_its_output_to_a_free_hand", "station": {"state": StationState.READY}, "target": "station", "expect": (Verb.PICK_FROM_STATION, None), }, { "name": "a_ready_fixture_refuses_a_full_hand", "station": {"state": StationState.READY}, "player": {"carrying": "lettuce"}, "target": "station", "expect": (Verb.NONE, DeniedReason.HANDS_FULL), }, { "name": "a_ready_fixture_with_stock_yields_the_item", "station": {"state": StationState.READY, "contents": (tomato(ItemForm.CHOPPED),)}, "target": "item", "expect": (Verb.PICK_ITEM, None), }, # kind: the serve window { "name": "serving_needs_something_in_the_hand", "station": {"kind": StationKind.SERVE}, "target": "serve", "expect": (Verb.NONE, DeniedReason.HANDS_EMPTY), }, { "name": "serving_refuses_the_wrong_item", "station": {"kind": StationKind.SERVE}, "player": {"carrying": "tomato"}, "target": "serve", "expect": (Verb.NONE, DeniedReason.WRONG_ITEM), }, { "name": "a_plated_dish_is_served", "station": {"kind": StationKind.SERVE}, "player": {"carrying": "plated"}, "target": "serve", "expect": (Verb.SERVE, None), }, { "name": "a_dirty_serve_window_refuses_a_dish", "station": {"kind": StationKind.SERVE, "dirty": True}, "player": {"carrying": "plated"}, "target": "serve", "expect": (Verb.NONE, DeniedReason.STATION_DIRTY), }, { "name": "a_blocked_serve_window_refuses_a_dish", "station": {"kind": StationKind.SERVE, "state": StationState.BLOCKED}, "player": {"carrying": "plated"}, "target": "serve", "expect": (Verb.NONE, DeniedReason.INVALID_STATE), }, # kind: the sink { "name": "washing_needs_something_in_the_hand", "station": {"kind": StationKind.SINK}, "target": "serve", "expect": (Verb.NONE, DeniedReason.HANDS_EMPTY), }, { "name": "washing_refuses_the_clean", "station": {"kind": StationKind.SINK}, "player": {"carrying": "tomato"}, "target": "serve", "expect": (Verb.NONE, DeniedReason.WRONG_ITEM), }, { "name": "a_dirty_plate_is_washed", "station": {"kind": StationKind.SINK}, "player": {"carrying": "dirty_plate"}, "target": "serve", "expect": (Verb.WASH, None), }, # kind: the trash { "name": "trashing_needs_something_in_the_hand", "station": {"kind": StationKind.TRASH}, "target": "station", "expect": (Verb.NONE, DeniedReason.HANDS_EMPTY), }, { "name": "anything_carries_to_the_trash", "station": {"kind": StationKind.TRASH}, "player": {"carrying": "tomato"}, "target": "station", "expect": (Verb.TRASH, None), }, # kind: the spawn shelf is stock, and a set-down escape hatch { "name": "a_shelf_hands_stock_to_a_free_hand", "station": {"kind": StationKind.SPAWN_SHELF}, "target": "station", "expect": (Verb.PICK_FROM_STATION, None), }, { "name": "a_shelf_takes_a_set_down_from_a_full_hand", "station": {"kind": StationKind.SPAWN_SHELF}, "player": {"carrying": "lettuce"}, "target": "station", "expect": (Verb.RELEASE, None), }, # kind: the work surfaces act on what is on them { "name": "a_loaded_surface_starts_work", "station": {"kind": StationKind.CHOP_BOARD, "contents": (tomato(),)}, "target": "station", "expect": (Verb.START_WORK, None), }, { "name": "a_loaded_surface_refuses_a_full_hand", "station": {"kind": StationKind.CHOP_BOARD, "contents": (tomato(),)}, "player": {"carrying": "lettuce"}, "target": "station", "expect": (Verb.NONE, DeniedReason.STATION_BUSY), }, { "name": "an_empty_surface_and_an_empty_hand_is_invalid_state", "station": {"kind": StationKind.CHOP_BOARD}, "target": "station", "expect": (Verb.NONE, DeniedReason.INVALID_STATE), }, { "name": "an_empty_surface_takes_a_placed_item", "station": {"kind": StationKind.CHOP_BOARD}, "player": {"carrying": "lettuce"}, "target": "station", "expect": (Verb.PLACE_ON_STATION, None), }, { "name": "an_empty_prep_counter_plates", "station": {"kind": StationKind.PREP}, "player": {"carrying": "lettuce"}, "target": "station", "expect": (Verb.PLATE_DISH, None), }, ] CARRYING: dict[str, Item] = { "tomato": tomato(), "lettuce": lettuce(), "plated": plated_dish(), "dirty_plate": dirty_plate(), } def build(row: dict[str, Any]) -> tuple[KitchenState, Player, Target | None]: """Assemble one matrix row: the kitchen, the cook, and what they point at.""" station = fixture(**row["station"]) if row.get("station") else None state = make_state(stations=(station,) if station is not None else ()) player_overrides = dict(row.get("player", {})) if "carrying" in player_overrides: player_overrides["carrying"] = CARRYING[player_overrides["carrying"]] player = cook(**player_overrides) kind = row["target"] item = CARRYING.get("tomato", tomato()) if kind == "none": target: Target | None = None elif kind == "station": target = StationTarget(station) # type: ignore[arg-type] elif kind == "serve": target = ServeTarget(station) # type: ignore[arg-type] elif kind == "item": target = ItemTarget(item=item, on_station_id=station.id if station else "nope") elif kind == "item_loose": target = ItemTarget(item=item) elif kind == "item_ghost": target = ItemTarget(item=item, on_station_id="ghost", at=Vec2(5.5, 4.5)) else: msg = f"unknown target kind {kind!r} in row {row['name']!r}" raise ValueError(msg) return state, player, target @pytest.mark.parametrize("row", MATRIX, ids=lambda row: row["name"]) def test_verb_ladder(row: dict[str, Any]) -> None: state, player, target = build(row) verb, denial = select_verb(state, player, target) assert (verb, denial) == row["expect"] def test_the_matrix_covers_every_verb() -> None: covered = {row["expect"][0] for row in MATRIX} assert covered == set(Verb) def test_the_matrix_covers_every_denied_reason() -> None: # `CONTESTED_LOST` is the one reason the ladder cannot produce: it comes # from the action gate's arbitration of two cooks, so the matrix covers the # other ten and the contested tests below pin this one. covered = {row["expect"][1] for row in MATRIX if row["expect"][1] is not None} assert covered == set(DeniedReason) - {DeniedReason.CONTESTED_LOST} def test_every_denied_reason_appears_in_a_test() -> None: # The set-coverage the phase demands: the matrix's reasons plus the reason a # real contested press produces together are the whole enum. from_matrix = {row["expect"][1] for row in MATRIX if row["expect"][1] is not None} state, frames = _contested_kitchen() _new_state, events = action_gate(state, frames, STEP_DT) from_gate = { DeniedReason(dict(event.payload)["reason"]) for event in events if event.kind is EventKind.DENIED } assert from_matrix | from_gate == set(DeniedReason) def test_no_row_holds_a_verb_and_a_denial_at_once() -> None: for row in MATRIX: verb, denial = row["expect"] if verb is not Verb.NONE: assert denial is None, row["name"] else: assert denial is not None, row["name"] # --- resolve_action: what this phase implements --------------------------------------------- def test_resolve_action_without_a_target_is_one_named_refusal() -> None: state = make_state(players=(cook("p0"),)) new_state, events = resolve_action(state, state.players[0]) assert new_state is state # a refusal moves nothing assert len(events) == 1 assert events[0].kind is EventKind.DENIED assert dict(events[0].payload) == { "player_id": "p0", "reason": "nothing_here", "verb": "none", } def test_resolve_action_for_a_stunned_press_names_the_reason() -> None: state = make_state( stations=(fixture(kind=StationKind.SPAWN_SHELF),), players=(cook("p0", stun=0.3),), ) new_state, events = resolve_action(state, state.players[0]) assert new_state is state assert len(events) == 1 assert dict(events[0].payload)["reason"] == "stunned" @pytest.mark.parametrize("held_for", [STEP_DT, 0.4]) def test_held_for_above_zero_is_the_documented_no_op(held_for: float) -> None: state = make_state(players=(cook("p0"),)) new_state, events = resolve_action(state, state.players[0], held_for=held_for) assert new_state is state assert events == () def test_pick_takes_the_item_off_the_fixture_and_into_the_hand() -> None: chopped = tomato(ItemForm.CHOPPED) ready = fixture(state=StationState.READY, contents=(chopped,)) state = make_state(stations=(ready,), players=(cook("p0"),)) before = state.state_hash() new_state, events = resolve_action(state, state.players[0]) assert new_state.players[0].carrying is chopped assert new_state.stations[0].contents == () assert len(events) == 1 assert events[0].kind is EventKind.PICKED assert dict(events[0].payload) == { "player_id": "p0", "station": ready.id, "item": "tomato", "form": "chopped", } # purity: the input state and the cook object it held are untouched assert state.state_hash() == before assert state.players[0].carrying is None assert state.stations[0].contents == (chopped,) def test_pick_removes_only_the_first_of_duplicates() -> None: second = Item(kind=ItemKind.TOMATO, form=ItemForm.RAW, quality=0.5) ready = fixture(state=StationState.READY, contents=(tomato(), second)) state = make_state(stations=(ready,), players=(cook("p0"),)) new_state, _events = resolve_action(state, state.players[0]) assert len(new_state.stations[0].contents) == 1 assert new_state.stations[0].contents[0].quality == 0.5 assert new_state.players[0].carrying is ready.contents[0] def test_release_sets_the_item_down_on_the_fixture() -> None: shelf = fixture(kind=StationKind.SPAWN_SHELF) held = lettuce() state = make_state(stations=(shelf,), players=(cook("p0", carrying=held),)) before = state.state_hash() new_state, events = resolve_action(state, state.players[0]) assert new_state.players[0].carrying is None assert new_state.stations[0].contents == (held,) assert len(events) == 1 assert events[0].kind is EventKind.DROPPED assert dict(events[0].payload) == { "player_id": "p0", "station": shelf.id, "item": "lettuce", "form": "raw", } assert state.state_hash() == before assert state.players[0].carrying is held assert state.stations[0].contents == () def test_a_serve_window_is_not_a_set_down_target() -> None: # A full hand at the window: `select_target` skips the item tier and the # SERVE kind is a `ServeTarget`; the serve ladder then demands a plated dish, # so a raw item is refused — the window is the score path, not a shelf to # drop on (`RELEASE` exists only at a fixture, and the shelf is that hatch). window = fixture(kind=StationKind.SERVE) state = make_state(stations=(window,), players=(cook("p0", carrying=lettuce()),)) target = select_target(state, state.players[0]) assert isinstance(target, ServeTarget) verb, denial = select_verb(state, state.players[0], target) assert (verb, denial) == (Verb.NONE, DeniedReason.WRONG_ITEM) new_state, events = resolve_action(state, state.players[0]) assert new_state is state assert len(events) == 1 assert dict(events[0].payload)["reason"] == "wrong_item" # --- the verbs the pipeline still does not spend --------------------------------- # # `START_WORK`, `WASH` and the set-downs left this table with task 04: a set-down # now actually sets the item down — and starts the work when the station works # the item (`sim/stations.py`, pinned by `tests/sim/test_stations.py`). What # remains unspent here is the `TRASH` press (the gate spends the hold) and the # `HOLD_WORK` *press* (the hold itself is stage 4's job; the press only # re-claims a released slot). The `SERVE` press left with task 03: it is # spent by `sim/scoring.py` (pinned by `tests/sim/test_scoring.py` and the # test below). NO_OP_ROWS: list[dict[str, Any]] = [ { "name": "hold_work", "station": { "kind": StationKind.CHOP_BOARD, "state": StationState.WORKING, "contents": (tomato(),), "worker_id": "p0", }, "target": "station", "expect": Verb.HOLD_WORK, }, { "name": "trash", "station": {"kind": StationKind.TRASH}, "player": {"carrying": "tomato"}, "target": "station", "expect": Verb.TRASH, }, ] @pytest.mark.parametrize("row", NO_OP_ROWS, ids=lambda row: row["name"]) def test_the_unspent_verbs_are_named_but_deliberate_no_ops(row: dict[str, Any]) -> None: # `select_verb` names the verb (the ladder is pinned by the matrix); # `resolve_action` spends nothing for these at the press: no state change, # and no `DENIED` — a no-op is not a refusal, the player did nothing wrong, # and the client plays nothing rather than a nudge. The `HOLD_WORK` press # re-claims a *released* slot only (stage 4 spends the hold); the `TRASH` # press starts the hold the action gate spends at the 8-tick threshold # (`tests/sim/test_carry.py`). state, player, target = build(row) verb, denial = select_verb(state, player, target) assert (verb, denial) == (row["expect"], None) before = state.state_hash() new_state, events = resolve_action(state, player) assert new_state is state assert events == () assert state.state_hash() == before def test_a_serve_press_is_routed_to_the_scoring() -> None: # The `SERVE` press is no longer a no-op (task 03): with the book threaded # in, a plated dish at an empty rail is the scoring's named `DENIED` # (`no_orders`) — the state is the same object back and the dish stays in # the hand. The scored path is pinned by `tests/sim/test_scoring.py`. row = { "name": "serve", "station": {"kind": StationKind.SERVE}, "player": {"carrying": "plated"}, "target": "serve", } state, player, target = build(row) verb, denial = select_verb(state, player, target) assert (verb, denial) == (Verb.SERVE, None) new_state, events = resolve_action(state, player, book=verbs_book()) assert new_state is state assert len(events) == 1 assert events[0].kind is EventKind.DENIED assert dict(events[0].payload)["reason"] == "no_orders" # --- contested arbitration (the action gate) -------------------------------------------- def _contested_kitchen() -> tuple[KitchenState, dict[str, PlayerInput]]: """Two cooks, one ready item: seat 0 in the front cell, seat 1 on the diagonal. Both face the station's cell (5, 4) — seat 0 through its front tier, seat 1 through the diagonal tier — and both have a free hand, so both would pick the same item out of the same fixture were there no arbitration. """ ready = fixture(state=StationState.READY, contents=(tomato(),)) state = make_state( stations=(ready,), players=( cook("p0", seat=0), # (5, 5) facing UP: front cell (5, 4) cook("p1", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1), # (5, 4) is its diagonal ), ) frames = { "p0": PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0), "p1": PlayerInput(player_id="p1", seq=0, action=True, received_at_tick=0), } return state, frames def test_the_earliest_received_frame_wins_a_contested_item() -> None: state, frames = _contested_kitchen() frames["p1"] = PlayerInput(player_id="p1", seq=0, action=True, received_at_tick=1) 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 # one effect per cook, never per tick assert dict(picked[0].payload)["player_id"] == "p0" assert dict(denied[0].payload) == { "player_id": "p1", "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 == () def test_a_full_tie_is_broken_by_the_seat_index() -> None: state, frames = _contested_kitchen() # both tick 0, seq 0: the seat decides 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 dict(denied[0].payload)["player_id"] == "p1" assert dict(denied[0].payload)["reason"] == "contested_lost" assert new_state.players[0].carrying is not None assert new_state.players[1].carrying is None def test_the_lower_seq_wins_a_same_tick_contest() -> None: state, frames = _contested_kitchen() frames["p0"] = PlayerInput(player_id="p0", seq=5, action=True, received_at_tick=0) frames["p1"] = PlayerInput(player_id="p1", seq=2, action=True, received_at_tick=0) new_state, _events = action_gate(state, frames, STEP_DT) assert new_state.players[1].carrying is not None # seq 2 < seq 5, same tick assert new_state.players[0].carrying is None def test_a_stunned_cook_keeps_their_own_reason_and_is_not_a_contest_loser() -> None: state, frames = _contested_kitchen() state = state.with_players( tuple( replace(player, stun=0.3) if player.id == "p1" else player for player in state.players ) ) _new_state, events = action_gate(state, frames, STEP_DT) denied = [event for event in events if event.kind is EventKind.DENIED] # Blaming the other cook for a refusal the stun caused would be a second lie; # the stunned cook gets the stunned reason and never enters the claim scan. assert len(denied) == 1 assert dict(denied[0].payload) == {"player_id": "p1", "reason": "stunned", "verb": "none"} def test_two_cooks_at_a_shelf_with_no_stock_do_not_contend() -> None: # A shelf pick with nothing in `contents` is stock from the level's legend # (phase 04 replenishes it), so two simultaneous taps are both legal and # neither is in the other's way — no `CONTESTED_LOST`, no lie about a fight # there was no fight in. shelf = fixture(kind=StationKind.SPAWN_SHELF) state = make_state( stations=(shelf,), players=( cook("p0", seat=0), cook("p1", pos=Vec2(6.5, 5.5), facing=Facing.LEFT, seat=1), ), ) frames = { "p0": PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0), "p1": PlayerInput(player_id="p1", seq=0, action=True, received_at_tick=0), } new_state, events = action_gate(state, frames, STEP_DT) assert events == () # both are the documented empty-shelf no-op (the stock is the level's # legend, phase 05's room seeds it), and neither was refused assert new_state.players[0].action_held_ticks == 1 assert new_state.players[1].action_held_ticks == 1 # --- the claim helpers ------------------------------------------------------------------ def test_claims_entity_distinguishes_a_stock_pick_from_an_empty_shelf() -> None: shelf = fixture(kind=StationKind.SPAWN_SHELF) stocked = fixture(kind=StationKind.SPAWN_SHELF, contents=(tomato(),)) assert claims_entity(Verb.PICK_FROM_STATION, StationTarget(shelf)) is False assert claims_entity(Verb.PICK_FROM_STATION, StationTarget(stocked)) is True assert claims_entity(Verb.PICK_ITEM, ItemTarget(item=tomato(), on_station_id=shelf.id)) is True assert claims_entity(Verb.START_WORK, StationTarget(stocked)) is True assert claims_entity(Verb.RELEASE, StationTarget(shelf)) is False assert claims_entity(Verb.SERVE, ServeTarget(shelf)) is False def test_target_key_claims_shelf_stock_as_a_pile_and_floor_items_by_position() -> None: first = ItemTarget(item=tomato(), on_station_id="shelf-1") second = ItemTarget(item=tomato(), on_station_id="shelf-1") assert target_key(first) == target_key(second) assert target_key(first) == ("item", "shelf-1") floor = ItemTarget(item=tomato(), at=Vec2(5.5, 4.5)) assert target_key(floor) == ("floor", (5.5 * 64, 4.5 * 64)) # Vec2.KEY, 1/64 tile def test_claim_rank_orders_by_arrival_then_seq_then_seat() -> None: p0 = cook("p0", seat=0) p1 = cook("p1", seat=1) high_seq = PlayerInput(player_id="p0", seq=9, received_at_tick=0) low_seq = PlayerInput(player_id="p1", seq=1, received_at_tick=0) later_tick = PlayerInput(player_id="p1", seq=0, received_at_tick=1) assert claim_rank(p1, low_seq) < claim_rank(p0, high_seq) # seq before seat assert claim_rank(p0, high_seq) < claim_rank(p1, later_tick) # arrival before seq assert claim_rank(p0, low_seq) < claim_rank(p1, low_seq) # the seat breaks a full tie