"""`sim/stations.py` — the station events: kinds, payloads, positions, order. A completion is the stage event (if the kind has one — the prep has none: there is no `plated` `EventKind`, its completion is the `READY` alone), then the `READY`, in that order, at the fixture, stamped with the state's tick: * `CHOPPED` / `COOKED` / `WASHED` carry `{"station", "item", "form", "tick"}`; * `READY` carries `{"station", "tick"}`; * nothing fires before the work is done, and an idle kitchen fires nothing (the committed replay goldens rest on that). """ from __future__ import annotations from collections.abc import Sequence from dataclasses import replace import pytest from undercooked.sim.grid import Grid from undercooked.sim.model import ( Event, EventKind, Facing, Item, ItemForm, ItemKind, KitchenState, Player, PlayerState, Station, StationKind, StationState, TileKind, Vec2, ) from undercooked.sim.stations import step_stations from undercooked.sim.step import STEP_DT, PlayerInput, step pytestmark = pytest.mark.unit WIDTH, HEIGHT = 13, 9 # --- fixtures --------------------------------------------------------------------------- def make_state( stations: tuple[Station, ...] = (), players: tuple[Player, ...] = (), tick: int = 0, ) -> KitchenState: return KitchenState( level_id="04_station_events", seed=13, 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", *, seat: int = 0) -> Player: # in reach of the fixture at (5, 4), holding — the stage-4 progress driver return Player( id=player_id, pos=Vec2(5.5, 5.5), facing=Facing.UP, state=PlayerState.IDLE, seat=seat, action_held_ticks=1, ) def working_station( kind: StationKind, contents: tuple[Item, ...], duration: float, *, x: int = 5, y: int = 4, ) -> Station: return Station( id=f"{kind.value}-{x}-{y}", kind=kind, x=x, y=y, state=StationState.WORKING, contents=contents, worker_id="p0", duration=duration, ) def item(kind: ItemKind, form: ItemForm = ItemForm.RAW) -> Item: return Item(kind=kind, form=form) def ticks(duration: float) -> int: return round(duration / STEP_DT) def run_to_ready(state: KitchenState, duration: float) -> tuple[KitchenState, tuple[Event, ...]]: """Drive the work to its completion substep and return that substep's events.""" for _ in range(ticks(duration) - 1): state, produced = step_stations(state, STEP_DT) assert produced == () # nothing fires before the work is done return step_stations(state, STEP_DT) def kinds(events: Sequence[Event]) -> list[EventKind]: return [EventKind(str(event.kind)) for event in events] # --- the stage event, then READY, in order --------------------------------------------- def test_chop_completes_with_a_chopped_event_then_ready() -> None: state = make_state( stations=(working_station(StationKind.CHOP_BOARD, (item(ItemKind.LETTUCE),), 0.9),), players=(cook(),), ) _state, events = run_to_ready(state, 0.9) assert kinds(events) == [EventKind.CHOPPED, EventKind.READY] assert dict(events[0].payload) == { "station": "chop_board-5-4", "item": "lettuce", "form": "chopped", "tick": 0, } assert dict(events[1].payload) == {"station": "chop_board-5-4", "tick": 0} def test_cook_completes_with_a_cooked_event_then_ready() -> None: state = make_state( stations=( working_station( StationKind.STOVE, (item(ItemKind.RICE, ItemForm.CHOPPED),), 6.0 ), ), players=(cook(),), ) _state, events = run_to_ready(state, 6.0) assert kinds(events) == [EventKind.COOKED, EventKind.READY] assert dict(events[0].payload) == { "station": "stove-5-4", "item": "rice", "form": "cooked", "tick": 0, } assert dict(events[1].payload) == {"station": "stove-5-4", "tick": 0} def test_wash_completes_with_a_washed_event_then_ready() -> None: state = make_state( stations=( working_station( StationKind.SINK, (item(ItemKind.PLATE, ItemForm.DIRTY),), 1.2 ), ), players=(cook(),), ) _state, events = run_to_ready(state, 1.2) assert kinds(events) == [EventKind.WASHED, EventKind.READY] assert dict(events[0].payload) == { "station": "sink-5-4", "item": "plate", "form": "raw", # the dirt goes, the prop comes back clean "tick": 0, } assert dict(events[1].payload) == {"station": "sink-5-4", "tick": 0} def test_prep_completes_with_ready_only() -> None: """The prep has no stage event: its completion is the `READY` alone.""" state = make_state( stations=( working_station( StationKind.PREP, (item(ItemKind.TOMATO, ItemForm.COOKED),), 0.6 ), ), players=(cook(),), ) _state, events = run_to_ready(state, 0.6) assert kinds(events) == [EventKind.READY] assert dict(events[0].payload) == {"station": "prep-5-4", "tick": 0} def test_an_in_place_wash_emits_washed_then_ready() -> None: """The dirty work station's in-place wash: `WASHED` (no item — the prop is the fixture's, its contents are set-down objects), then `READY`.""" state = make_state( stations=( Station( id="prep-5-4", kind=StationKind.PREP, x=5, y=4, state=StationState.WORKING, dirty=True, worker_id="p0", duration=1.2, ), ), players=(cook(),), ) _state, events = run_to_ready(state, 1.2) assert kinds(events) == [EventKind.WASHED, EventKind.READY] assert dict(events[0].payload) == {"station": "prep-5-4", "tick": 0} assert dict(events[1].payload) == {"station": "prep-5-4", "tick": 0} # --- positions, order, the stamp ------------------------------------------------------- def test_events_are_at_the_fixture_and_stamped_with_the_state_tick() -> None: state = make_state( stations=(working_station(StationKind.CHOP_BOARD, (item(ItemKind.TOMATO),), 0.9),), players=(cook(),), tick=41, ) _state, events = run_to_ready(state, 0.9) for event in events: assert event.x == 5.0 assert event.y == 4.0 assert dict(event.payload)["tick"] == 41 def test_two_stations_completing_in_one_substep_emit_in_station_order() -> None: state = make_state( stations=( working_station(StationKind.CHOP_BOARD, (item(ItemKind.TOMATO),), 0.9), working_station( StationKind.PREP, (item(ItemKind.LETTUCE, ItemForm.COOKED),), 0.6, x=7, ), ), players=( cook(), Player( id="p1", pos=Vec2(7.5, 5.5), facing=Facing.UP, state=PlayerState.IDLE, seat=1, action_held_ticks=1, ), ), ) state = replace_worker(state, "prep-7-4", "p1") # the prep (12 substeps) finishes before the board (18): its `READY` alone first for _ in range(ticks(0.6) - 1): state, produced = step_stations(state, STEP_DT) assert produced == () state, events = step_stations(state, STEP_DT) assert kinds(events) == [EventKind.READY] assert dict(events[0].payload)["station"] == "prep-7-4" # then the board's stage event + ready: substeps 13-17 are still midstream for _ in range(ticks(0.9) - 12 - 1): state, produced = step_stations(state, STEP_DT) assert produced == () state, events = step_stations(state, STEP_DT) assert kinds(events) == [EventKind.CHOPPED, EventKind.READY] assert dict(events[0].payload)["station"] == "chop_board-5-4" def replace_worker(state: KitchenState, station_id: str, worker_id: str) -> KitchenState: """`state` with one station's `worker_id` set (the fixture's default is `p0`).""" return replace( state, stations=tuple( replace(s, worker_id=worker_id) if s.id == station_id else s for s in state.stations ), ) def test_no_events_fire_while_the_work_is_midstream() -> None: state = make_state( stations=(working_station(StationKind.STOVE, (item(ItemKind.TOMATO),), 8.0),), players=(cook(),), ) for _ in range(ticks(8.0) - 1): state, produced = step_stations(state, STEP_DT) assert produced == () assert state.stations[0].state is StationState.WORKING def test_an_idle_kitchen_fires_no_events() -> None: state = make_state( stations=( Station(id="prep-5-4", kind=StationKind.PREP, dirty=True), Station(id="stove-9-4", kind=StationKind.STOVE, x=9), ), players=(cook(),), ) for _ in range(5): state, produced = step_stations(state, STEP_DT) assert produced == () # --- the kernel delivers the station events (integration) ------------------------------- def test_the_kernel_delivers_the_station_events_on_the_completion_substep() -> None: """Through `step()`: the `CHOPPED`/`READY` pair arrives on the 18th substep, stamped with the substep's tick (the kernel increments after stage 4).""" state = make_state( stations=( Station( id="chop_board-5-4", kind=StationKind.CHOP_BOARD, x=5, y=4, contents=(item(ItemKind.TOMATO),), ), ), players=( Player( id="p0", pos=Vec2(5.5, 5.5), facing=Facing.UP, state=PlayerState.IDLE, seat=0, ), ), ) for substep in range(1, ticks(0.9)): state, events = step( state, STEP_DT, (PlayerInput(player_id="p0", seq=substep, action=True, received_at_tick=state.tick),), ) assert kinds(events) == [] # the press substep starts the work; it does not complete it assert state.stations[0].state is StationState.WORKING state, events = step( state, STEP_DT, (PlayerInput(player_id="p0", seq=ticks(0.9), action=True, received_at_tick=state.tick),), ) assert kinds(events) == [EventKind.CHOPPED, EventKind.READY] # the substep's tick, stamped before the kernel's increment assert dict(events[0].payload)["tick"] == ticks(0.9) - 1 assert state.stations[0].state is StationState.READY