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`**.
This commit is contained in:
2026-10-07 02:46:10 -04:00
parent 3426264ffb
commit 4febdfe780
41 changed files with 17690 additions and 23 deletions
+804
View File
@@ -0,0 +1,804 @@
"""`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 -> STATION_DIRTY, 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 (pick,
set-down, named refusals, and the deliberate no-ops phase 04 will spend), and
the contested arbitration `action_gate` runs when two cooks press for one thing.
"""
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.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, dish_id=1)
def dirty_plate() -> Item:
return Item(kind=ItemKind.PLATE, form=ItemForm.DIRTY)
# --- 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_refuses_anything",
"station": {"dirty": True},
"target": "station",
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
},
{
"name": "the_dirty_state_refuses_anything",
"station": {"state": StationState.DIRTY},
"target": "station",
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
},
{
"name": "a_dirty_blocked_fixture_reports_the_dirt_first",
"station": {"dirty": True, "state": StationState.BLOCKED},
"target": "station",
"expect": (Verb.NONE, DeniedReason.STATION_DIRTY),
},
{
"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 phase 04 verbs: named today, deliberately unspent -----------------------------
#
# `PLACE_ON_STATION` and `PLATE_DISH` left this table with task 04: a set-down now
# actually sets the item down (the hand-off's marked place, `sim/carry.py` — pinned
# by `tests/sim/test_carry.py`). What remains unspent is the work itself.
NO_OP_ROWS: list[dict[str, Any]] = [
{
"name": "start_work",
"station": {"kind": StationKind.CHOP_BOARD, "contents": (tomato(),)},
"target": "station",
"expect": Verb.START_WORK,
},
{
"name": "hold_work",
"station": {
"kind": StationKind.CHOP_BOARD,
"state": StationState.WORKING,
"contents": (tomato(),),
"worker_id": "p0",
},
"target": "station",
"expect": Verb.HOLD_WORK,
},
{
"name": "serve",
"station": {"kind": StationKind.SERVE},
"player": {"carrying": "plated"},
"target": "serve",
"expect": Verb.SERVE,
},
{
"name": "wash",
"station": {"kind": StationKind.SINK},
"player": {"carrying": "dirty_plate"},
"target": "station",
"expect": Verb.WASH,
},
{
"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_phase_04_verbs_are_named_but_deliberate_no_ops(row: dict[str, Any]) -> None:
# `select_verb` names the verb today (the ladder is pinned by the matrix);
# `resolve_action` spends nothing for the work verbs until phase 04's pipeline
# exists: 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.
# `TRASH` is the same shape at the press: the press starts the hold, and the
# action gate spends it 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
# --- 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 phase 04 no-ops, 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