**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`**.
805 lines
29 KiB
Python
805 lines
29 KiB
Python
"""`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
|