"""`sim/freshness.py` — the quality-decay, burn, and dirty-loop clocks (task 04). The task's contract, pinned one rule at a time: * the burn boundary, tick-exact: a ready stove's cooked dish burns on the 160th substep after the ready flip (8.0 s / 0.05 s) and not the 159th — driven through the whole `step()` so the stove completion's countdown start and the stage-4b wiring are proven in the same run; * the board's linear decay at 25 / 50 / 75 / 100 % of `ROT_S` (400 ticks), a raw item on the board rotting too (the push-out rule's precondition); * the half-rate carry: a carried prepared item decays half the board's rate (a hand is not a fridge), a carried raw does not decay, and a carried `PLATED` dish keeps the quality it was plated with; * the burnt dish the serve window rejects (task 03's `burnt` path, wired through the kernel); * the push-out: a burnt board item yields to a new raw set-down (`WASTED`, `counters["wasted"]`), a usable one never is (a named `station_busy`, the board unchanged); * the dirty loop's stock: use moves the prop clean -> dirty, a wash moves it back — the sink's wash and the in-place wash — and `…_clean + …_dirty` is the limit every tick of a 1 000-tick scripted sequence (asserted per tick), with a zero stock `PLATE_DISH` a clean refusal and `plates_clean` never below 0. """ from __future__ import annotations from collections.abc import Sequence from dataclasses import replace from typing import Any import pytest from undercooked.sim.freshness import ( BURN_AFTER_S, CARRY_DECAY_TICKS, ROT_S, step_freshness, ) from undercooked.sim.grid import Grid from undercooked.sim.interact import ItemTarget, StationTarget, Verb from undercooked.sim.model import ( Event, EventKind, Facing, Item, ItemForm, ItemKind, KitchenState, Order, Player, Station, StationKind, StationState, TileKind, Vec2, ) from undercooked.sim.recipe import RecipeBook from undercooked.sim.stations import WASH_SECONDS, apply_station_verb from undercooked.sim.step import STEP_DT, PlayerInput, step pytestmark = pytest.mark.unit from _cook_policy import build_kitchen, run_policy # noqa: E402 WIDTH, HEIGHT = 7, 7 # --- fixtures --------------------------------------------------------------------------- def make_state( stations: tuple[Station, ...] = (), players: tuple[Player, ...] = (), tick: int = 0, counters: dict[str, int] | None = None, orders: tuple[Order, ...] = (), ) -> KitchenState: return KitchenState( level_id="04_freshness", seed=13, grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)), tick=tick, stations=stations, players=players, counters=dict(counters) if counters is not None else {}, orders=orders, target_score=40, time_left=90.0, ) def station( kind: StationKind, *, x: int = 5, y: int = 4, state: StationState = StationState.IDLE, contents: tuple[Item, ...] = (), worker_id: str | None = None, dirty: bool = False, progress: float = 0.0, duration: 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, progress=progress, duration=duration, ) def item(kind: ItemKind, form: ItemForm = ItemForm.RAW, **kwargs: Any) -> Item: return Item(kind=kind, form=form, **kwargs) def cook( pos: Vec2, *, carrying: Item | None = None, facing: Facing = Facing.UP, seat: int = 0, ) -> Player: return Player(id="p0", pos=pos, facing=facing, seat=seat, carrying=carrying) def book() -> RecipeBook: return RecipeBook.model_validate( { "recipes": { "soup": { "id": "soup", "name": "Soup", "base_score": 40, "ingredients": [{"kind": "tomato", "form": "raw"}], "steps": [{"kind": "serve", "station": "serve"}], } } } ) def run( state: KitchenState, substeps: int, *, action: bool = False, book: RecipeBook | None = None, ) -> tuple[KitchenState, list]: """Drive `substeps` substeps through the whole `step()`; collect the events. `action=True` sends the cook's `action=True` frame every substep (the gate's hold: a work in flight needs the worker holding — the same pattern as the golden scripts).""" events: list = [] for _ in range(substeps): frame = ( (PlayerInput(player_id="p0", action=True, received_at_tick=state.tick),) if action else () ) if book is not None: state, produced = step(state, STEP_DT, frame, book) else: state, produced = step(state, STEP_DT, frame) events.extend(produced) return state, events def held(state: KitchenState, player_id: str) -> KitchenState: """`state` with the gate's hold increment simulated on one cook (a direct `apply_station_verb` press skips the gate, so the hold the work's progress reads is set here — the `test_stations.py` idiom).""" return replace( state, players=tuple( replace(player, action_held_ticks=1) if player.id == player_id else player for player in state.players ), ) def _burned(station: Station) -> bool: return any(entry.form is ItemForm.BURNT for entry in station.contents) # --- burn: the tick-exact boundary ------------------------------------------------------- def test_the_burn_boundary_is_tick_exact_on_a_ready_stove() -> None: """A ready stove's cooked dish (its countdown started at the ready flip, `ttl = BURN_AFTER_S`) burns on the 160th substep after the flip — 8.0 s — and not the 159th: the `BURNT` event, the quality at 0.0, and the station's `DIRTY` flip all land on the same substep.""" dish = item(ItemKind.TOMATO, ItemForm.COOKED, ttl=BURN_AFTER_S) state = make_state( stations=( station(StationKind.STOVE, state=StationState.READY, contents=(dish,)), ), ) state, events = run(state, 159) stove = state.stations[0] assert stove.state is StationState.READY # not dirty yet assert stove.contents[0].form is ItemForm.COOKED assert stove.contents[0].quality == pytest.approx(1.0) assert stove.contents[0].ttl == pytest.approx(BURN_AFTER_S - 159 * STEP_DT, abs=1e-9) assert not any(e.kind is EventKind.BURNT for e in events) state, burned = run(state, 1) # the 160th substep: 8.0 s on the heat stove = state.stations[0] assert stove.state is StationState.DIRTY # the burnt pot is stuck assert stove.dirty assert stove.contents[0].form is ItemForm.BURNT assert stove.contents[0].quality == 0.0 assert stove.contents[0].ttl == 0.0 burnt_events = [e for e in burned if e.kind is EventKind.BURNT] assert len(burnt_events) == 1 assert dict(burnt_events[0].payload) == { "station": stove.id, "item": "tomato", "form": "burnt", "tick": 159, # the 160th substep (0-based ticks: it starts at tick 159) } def test_a_stove_works_and_then_burns_exactly_burn_after_s_on() -> None: """Through the whole pipeline: the stove's work completes (one substep from done, the worker holding), the completion starts the countdown, and the dish burns 160 substeps after the flip — the `ttl` the completion writes is the whole of the burn's clock.""" state = make_state( stations=( station( StationKind.STOVE, state=StationState.WORKING, contents=(item(ItemKind.TOMATO, ItemForm.CHOPPED),), worker_id="p0", progress=1.0 - STEP_DT / 6.0, # one substep from done duration=6.0, ), ), players=(cook(Vec2(5.5, 5.5)),), ) # Substep 0: the hold advances the work to done — the ready flip and the # countdown's first tick (8.0 -> 7.95) in the same substep. state, events = step( state, STEP_DT, (PlayerInput(player_id="p0", action=True, received_at_tick=0),), ) stove = state.stations[0] assert stove.state is StationState.READY assert any(e.kind is EventKind.COOKED for e in events) assert stove.contents[0].form is ItemForm.COOKED assert stove.contents[0].ttl == pytest.approx(BURN_AFTER_S - STEP_DT, abs=1e-9) for _ in range(158): # substeps 1..158 after the flip: the dish keeps state, _events = run(state, 1) # (and the worker's hold is no longer stove = state.stations[0] # needed — the station is ready) assert stove.state is StationState.READY assert stove.contents[0].form is ItemForm.COOKED assert stove.contents[0].ttl == pytest.approx(0.05, abs=1e-9) state, burned = run(state, 1) # the 160th substep after the flip assert state.stations[0].state is StationState.DIRTY assert state.stations[0].contents[0].form is ItemForm.BURNT assert any(e.kind is EventKind.BURNT for e in burned) def test_a_lifted_dish_pauses_the_burn_and_a_set_down_resumes_it() -> None: """A lifted dish's countdown pauses (a hand is not the stove's heat) and a set-down on a ready stove resumes it: 100 substeps on the heat, 100 in the hand, then 60 on the heat burn it — 160 heat substeps, the same boundary as one uninterrupted 8.0 s.""" dish = item(ItemKind.TOMATO, ItemForm.COOKED, ttl=BURN_AFTER_S) state = make_state( stations=(station(StationKind.STOVE, state=StationState.READY, contents=(dish,)),), players=(cook(Vec2(5.5, 5.5)),), ) state, _events = run(state, 100) # 100 substeps on the heat stove = state.stations[0] assert stove.contents[0].ttl == pytest.approx(BURN_AFTER_S - 100 * STEP_DT, abs=1e-9) # Lifted: the stove's contents go with the pick — a hand holds the dish. lifted = state.stations[0].contents[0] state = replace( state, players=( replace(state.players[0], carrying=lifted), ), stations=(replace(state.stations[0], contents=(), state=StationState.IDLE),), ) state, _events = run(state, 100) # 100 substeps in the hand: the countdown pauses carried = state.players[0].carrying assert carried is not None assert carried.ttl == pytest.approx(BURN_AFTER_S - 100 * STEP_DT, abs=1e-9) # unchanged # Set back on the ready stove: the countdown resumes. state = replace( state, players=(replace(state.players[0], carrying=None),), stations=( replace( state.stations[0], contents=(carried,), state=StationState.READY, ), ), ) for _ in range(59): state, _events = run(state, 1) assert state.stations[0].contents[0].ttl == pytest.approx(0.05, abs=1e-9) state, burned = run(state, 1) # the 160th heat substep assert state.stations[0].contents[0].form is ItemForm.BURNT assert state.stations[0].state is StationState.DIRTY assert any(e.kind is EventKind.BURNT for e in burned) # --- the board's decay: linear over ROT_S ------------------------------------------------- def test_board_quality_decays_linearly_at_quarter_points_of_rot_s() -> None: """A chopped item on the board: 25 / 50 / 75 / 100 % of `ROT_S` (100 / 200 / 300 / 400 substeps after the first) is 0.75 / 0.5 / 0.25 / 0.0 — the whole of the board's clock, linear, the burnt flip on the 400th.""" board = station(StationKind.CHOP_BOARD, contents=(item(ItemKind.ONION, ItemForm.CHOPPED),)) state = make_state(stations=(board,)) for cumulative, expected_quality in ((100, 0.75), (200, 0.5), (300, 0.25)): state, _events = run(state, 100) # 100 substeps each: cumulative 100 / 200 / 300 assert ( state.stations[0].contents[0].quality == pytest.approx(1.0 - cumulative / 400.0, abs=1e-9) == pytest.approx(expected_quality, abs=1e-9) ) state, _events = run(state, 99) # 399 substeps: 0.0025, still fresh assert state.stations[0].contents[0].quality == pytest.approx(0.0025, abs=1e-9) assert state.stations[0].contents[0].form is ItemForm.CHOPPED state, _events = run(state, 1) # the 400th: 0.0 — burnt assert state.stations[0].contents[0].form is ItemForm.BURNT assert state.stations[0].contents[0].quality == 0.0 # The board's rot is not a burn event (the snapshot's quality is the # visible rot), and it does not dirty the board. assert state.stations[0].state is StationState.IDLE assert not state.stations[0].dirty def test_a_raw_item_on_the_board_rots_too() -> None: """The push-out rule's precondition: a raw onion left on the board is the one that rots (over the same `ROT_S`), so a board can hold a `BURNT` raw item to be pushed out.""" board = station(StationKind.CHOP_BOARD, contents=(item(ItemKind.ONION, ItemForm.RAW),)) state = make_state(stations=(board,)) state, _events = run(state, 400) assert state.stations[0].contents[0].form is ItemForm.BURNT assert state.stations[0].contents[0].quality == 0.0 def test_a_chopped_item_off_the_board_does_not_decay_there() -> None: """The decay is the board's clock: a chopped item set down elsewhere (a marked object on the prep) is not decaying — only the board (full rate), a carried prepared item (half rate) and the stove's heat (the burn) hold the clocks.""" state = make_state( stations=( station(StationKind.PREP, contents=(item(ItemKind.ONION, ItemForm.CHOPPED),)), ), ) state, _events = run(state, 400) assert state.stations[0].contents[0].quality == pytest.approx(1.0) assert state.stations[0].contents[0].form is ItemForm.CHOPPED # --- the half-rate carry ------------------------------------------------------------------ def test_a_carried_prepared_item_decays_at_half_the_board_rate() -> None: """A carried chopped item: `dt / (2 x ROT_S)` per substep — after 400 substeps (the board's whole `ROT_S`) it is 0.5 where the board's is 0.0, and it burns at 800 substeps (40 s).""" state = make_state( players=( cook(Vec2(5.5, 5.5), carrying=item(ItemKind.ONION, ItemForm.CHOPPED)), ), ) state, _events = run(state, 400) assert state.players[0].carrying is not None assert state.players[0].carrying.quality == pytest.approx(0.5, abs=1e-9) assert state.players[0].carrying.form is ItemForm.CHOPPED state, _events = run(state, 400) # 800 substeps: 0.0 — burnt in the hand assert state.players[0].carrying is not None assert state.players[0].carrying.quality == 0.0 assert state.players[0].carrying.form is ItemForm.BURNT def test_the_carry_rate_is_half_the_board_rate_by_constant() -> None: assert CARRY_DECAY_TICKS == 2 assert pytest.approx(STEP_DT / ROT_S / 2.0, rel=1e-12) == STEP_DT / ( ROT_S * CARRY_DECAY_TICKS ) def test_a_carried_raw_item_does_not_decay() -> None: """A carried raw ingredient is fresh: no decay in the hand (the board is where raw rots).""" state = make_state( players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.ONION, ItemForm.RAW)),) ) state, _events = run(state, 800) assert state.players[0].carrying is not None assert state.players[0].carrying.quality == pytest.approx(1.0) assert state.players[0].carrying.form is ItemForm.RAW def test_a_carried_plated_dish_keeps_its_plated_quality() -> None: """The plate does not keep rotting in the hand: a `PLATED` dish carries the quality it was plated with (here 0.8), so the serve window's math is the dish's, not the walk's.""" state = make_state( players=( cook( Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.PLATED, quality=0.8), ), ) ) state, _events = run(state, 800) assert state.players[0].carrying is not None assert state.players[0].carrying.quality == pytest.approx(0.8) assert state.players[0].carrying.form is ItemForm.PLATED # --- the burnt dish the serve window rejects (task 03's path) ------------------------------ def _serve_kitchen( dish: Item, orders: tuple[Order, ...] ) -> tuple[KitchenState, Player]: window = station(StationKind.SERVE, x=5, y=4) state = make_state( stations=(window,), players=(cook(Vec2(5.5, 5.5), carrying=dish),), orders=orders, ) state = replace( state, order_pool=("soup",), order_concurrent=1, order_deadline=120 ) return state, state.players[0] def test_a_burnt_dish_is_rejected_by_the_serve_window_with_reason_burnt() -> None: """A quality-0.0 dish at the window is task 03's named `burnt` rejection, through the whole `step()`: one `DENIED`, the dish left in the hand, the order intact.""" dish = item(ItemKind.TOMATO, ItemForm.PLATED, quality=0.0) state, _p0 = _serve_kitchen( dish, (Order(id=1, recipe_id="soup", time_left=30.0, deadline=120, value=40),) ) new_state, events = step( state, STEP_DT, (PlayerInput(player_id="p0", action=True, received_at_tick=0),), book(), ) denied = [e for e in events if e.kind is EventKind.DENIED] assert len(denied) == 1 assert dict(denied[0].payload)["reason"] == "burnt" assert new_state.players[0].carrying is dish # the dish stays in the hand # The order stays on the rail (a rejected serve consumes nothing of it). assert [o.id for o in new_state.orders] == [o.id for o in state.orders] assert [o.recipe_id for o in new_state.orders] == ["soup"] def test_a_burnt_form_dish_is_rejected_by_the_ladder() -> None: """The form is the check's other half: a `BURNT`-form dish (its quality irrelevant — here 1.0) is not a dish at all, and the ladder's named `wrong_item` refuses it before the scoring's `burnt` check is even read; the dish stays in the hand.""" dish = item(ItemKind.TOMATO, ItemForm.BURNT, quality=1.0) state, _p0 = _serve_kitchen( dish, (Order(id=1, recipe_id="soup", time_left=30.0, deadline=120, value=40),) ) new_state, events = step( state, STEP_DT, (PlayerInput(player_id="p0", action=True, received_at_tick=0),), book(), ) denied = [e for e in events if e.kind is EventKind.DENIED] assert len(denied) == 1 assert dict(denied[0].payload)["reason"] == "wrong_item" assert new_state.players[0].carrying is dish # --- the push-out: burnt yields, usable never is ------------------------------------------ def _board_set_down( state: KitchenState, ) -> tuple[KitchenState, Sequence[Event]]: """A raw set-down on the board through the whole `step()`: the cook's one press at the board (a facing change and a release tick apart). The board sits directly above the cook (its centre is the first `UP` cell), so the press faces `UP`.""" state, _events = step(state, STEP_DT) # the release: the gate is clean face = step( state, STEP_DT, (PlayerInput(player_id="p0", move=Vec2(0.0, 0.0), face=Facing.UP, received_at_tick=1),), )[0] new_state, events = step( face, STEP_DT, ( PlayerInput( player_id="p0", move=Vec2(0.0, 0.0), action=True, face=Facing.UP, received_at_tick=2, ), ), ) return new_state, events def test_the_push_out_destroys_a_burnt_board_item_with_wasted() -> None: """A new raw must be placed on a board that holds a burnt one: the burnt item is destroyed — one `WASTED`, `counters["wasted"]` up by one — and the set-down proceeds (the work starts on the new raw).""" board = station( StationKind.CHOP_BOARD, contents=(item(ItemKind.ONION, ItemForm.BURNT),), ) state = make_state( stations=(board,), players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),), ) new_state, events = _board_set_down(state) wasted = [e for e in events if e.kind is EventKind.WASTED] assert len(wasted) == 1 assert dict(wasted[0].payload) == { "station": board.id, "item": "onion", "tick": 2, } assert new_state.counters["wasted"] == 1 b = new_state.stations[0] # The burnt one is gone; the new raw is on the board, where the # freshness stage's first tick has already started its clock. assert b.contents[0].kind is ItemKind.TOMATO assert b.contents[0].form is ItemForm.RAW assert b.contents[0].quality == pytest.approx(1.0 - 1.0 / 400.0, abs=1e-9) assert b.state is StationState.WORKING # the set-down started the chop def test_the_push_out_refuses_to_overwrite_a_usable_item() -> None: """A usable raw item on the board is never silently overwritten: the new raw's set-down is the named `station_busy`, the board is unchanged, and nothing is counted as wasted.""" usable = item(ItemKind.ONION, ItemForm.RAW, quality=0.8) board = station(StationKind.CHOP_BOARD, contents=(usable,)) state = make_state( stations=(board,), players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),), ) new_state, events = _board_set_down(state) denied = [e for e in events if e.kind is EventKind.DENIED] assert len(denied) == 1 assert dict(denied[0].payload)["reason"] == "station_busy" assert not any(e.kind is EventKind.WASTED for e in events) b = new_state.stations[0] # The refusal is the set-down's: the new raw is still in the hand, and # the usable one is on the board (its quality only the three ticks the # board's own clock ran — nothing was removed, nothing was overwritten). assert len(b.contents) == 1 assert b.contents[0].kind is ItemKind.ONION assert b.contents[0].form is ItemForm.RAW assert b.contents[0].quality == pytest.approx(0.8 - 3.0 / 400.0, abs=1e-9) assert new_state.players[0].carrying is not None assert new_state.counters.get("wasted", 0) == 0 def test_the_push_out_refuses_over_a_usable_raw_even_with_a_burnt_one() -> None: """The usable item outranks the burnt one: a board with a usable raw and a burnt raw still refuses the new raw — the burnt one is not destroyed while the usable one would be overwritten.""" board = station( StationKind.CHOP_BOARD, contents=( item(ItemKind.ONION, ItemForm.RAW, quality=0.8), item(ItemKind.LETTUCE, ItemForm.BURNT), ), ) state = make_state( stations=(board,), players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),), ) new_state, events = _board_set_down(state) denied = [e for e in events if e.kind is EventKind.DENIED] assert len(denied) == 1 assert dict(denied[0].payload)["reason"] == "station_busy" # Both items intact (the usable raw's quality only the three ticks the # board's own clock ran); the new raw is still in the hand. assert new_state.stations[0].contents[0].kind is ItemKind.ONION assert new_state.stations[0].contents[0].quality == pytest.approx(0.8 - 3.0 / 400.0, abs=1e-9) assert new_state.stations[0].contents[1].kind is ItemKind.LETTUCE assert new_state.stations[0].contents[1].form is ItemForm.BURNT assert new_state.players[0].carrying is not None assert new_state.counters.get("wasted", 0) == 0 def test_the_push_out_counts_every_destroyed_item() -> None: """Two burnt board items, one new raw: two `WASTED` events and `counters["wasted"]` up by two — a destruction is named exactly once, at the moment it happens.""" board = station( StationKind.CHOP_BOARD, contents=( item(ItemKind.ONION, ItemForm.BURNT), item(ItemKind.LETTUCE, ItemForm.BURNT), ), ) state = make_state( stations=(board,), players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),), ) new_state, events = _board_set_down(state) assert len([e for e in events if e.kind is EventKind.WASTED]) == 2 assert new_state.counters["wasted"] == 2 # The two burnt ones are gone; the new raw is on the board (its quality # the one tick the board's clock ran in the set-down's own substep). assert len(new_state.stations[0].contents) == 1 assert new_state.stations[0].contents[0].kind is ItemKind.TOMATO assert new_state.stations[0].contents[0].form is ItemForm.RAW assert new_state.stations[0].contents[0].quality == pytest.approx(1.0 - 1.0 / 400.0, abs=1e-9) def test_a_rotted_raw_is_pushed_out_by_the_freshness_then_placement() -> None: """The full loop: a raw onion rots on the board (400 substeps, the freshness stage's clock), and the new raw's set-down then pushes it out — the `WASTED` the task names, from the kitchen's own clocks.""" board = station(StationKind.CHOP_BOARD, contents=(item(ItemKind.ONION, ItemForm.RAW),)) state = make_state( stations=(board,), players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),), ) state, _events = run(state, 400) # the board's whole ROT_S: the onion rots assert state.stations[0].contents[0].form is ItemForm.BURNT new_state, events = _board_set_down(state) assert len([e for e in events if e.kind is EventKind.WASTED]) == 1 assert new_state.counters["wasted"] == 1 assert new_state.stations[0].state is StationState.WORKING # --- the dirty loop's stock: use dirties, a wash cleans ------------------------------------ def test_the_in_place_wash_round_trips_the_stock_pair() -> None: """The prop's whole round trip on the prep's plate: `PLATE_DISH` moves it clean -> dirty (3 -> 2, 0 -> 1), the pick leaves the prep `DIRTY`, the in-place wash moves it back (2 -> 3, 1 -> 0) and the prep is `IDLE` again — `…_clean + …_dirty` is the limit at every point.""" prep = station( StationKind.PREP, x=5, y=4, ) shelf = station(StationKind.SPAWN_SHELF, x=6, y=4) state = make_state( stations=(prep, shelf), players=( cook( Vec2(5.5, 5.5), facing=Facing.UP, carrying=item(ItemKind.TOMATO, ItemForm.COOKED), ), ), counters={"plates_clean": 3, "plates_dirty": 0}, ) p0 = state.players[0] # Use: the `PLATE_DISH` press takes the plate from the clean stock # into the dirty pool. state, _events = apply_station_verb( state, p0, Verb.PLATE_DISH, StationTarget(station=state.stations[0]), ) assert state.counters["plates_clean"] == 2 assert state.counters["plates_dirty"] == 1 assert state.stations[0].state is StationState.WORKING # The plate's work: 12 substeps (0.6 s) of the hold. state = held(state, "p0") state, _events = run(state, 12, action=True) assert state.stations[0].state is StationState.READY # The pick leaves the prep DIRTY (the stuck plate). p0 = state.players[0] state, _events = apply_station_verb( state, p0, Verb.PICK_FROM_STATION, ItemTarget( item=state.stations[0].contents[0], on_station_id=state.stations[0].id, ), ) assert state.stations[0].state is StationState.DIRTY assert state.stations[0].dirty assert state.players[0].carrying is not None # A full hand cannot wash: the dish is set down first (the shelf), # the way the kitchen does it. p0 = state.players[0] state, _events = apply_station_verb( state, p0, Verb.RELEASE, StationTarget(station=state.stations[1]), ) assert state.players[0].carrying is None # The forced wash: a free hand at the dirty prep is the in-place wash. p0 = state.players[0] state, _events = apply_station_verb( state, p0, Verb.WASH, StationTarget(station=state.stations[0]), ) assert state.stations[0].state is StationState.WORKING state = held(state, "p0") state, washed = run(state, round(WASH_SECONDS / STEP_DT), action=True) assert state.stations[0].state in (StationState.READY, StationState.IDLE) assert state.counters["plates_clean"] == 3 # the prop is back in the clean stock assert state.counters["plates_dirty"] == 0 # ...and off the dirty pool assert any(e.kind is EventKind.WASHED for e in washed) state, _events = run(state, 1) # the empty ready surface cools to idle assert state.stations[0].state is StationState.IDLE assert state.stations[0].dirty is False def test_the_stove_pot_round_trips_the_same_way() -> None: """The same round trip on the stove's pot: a raw set-down takes the pot (2 -> 1, 0 -> 1), the pick leaves the stove `DIRTY` (the stuck pot), the in-place wash pays it back (1 -> 2, 1 -> 0).""" state = make_state( stations=(station(StationKind.STOVE, x=5, y=4), station(StationKind.SPAWN_SHELF, x=6, y=4)), players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),), counters={"pots_clean": 2, "pots_dirty": 0}, ) p0 = state.players[0] state, _events = apply_station_verb( state, p0, Verb.PLACE_ON_STATION, StationTarget(station=state.stations[0]), ) assert state.counters["pots_clean"] == 1 assert state.counters["pots_dirty"] == 1 assert state.stations[0].state is StationState.WORKING # The raw cook: 8 s = 160 substeps of the hold. state = held(state, "p0") state, _events = run(state, round(8.0 / STEP_DT), action=True) assert state.stations[0].state is StationState.READY p0 = state.players[0] state, _events = apply_station_verb( state, p0, Verb.PICK_FROM_STATION, ItemTarget( item=state.stations[0].contents[0], on_station_id=state.stations[0].id, ), ) assert state.stations[0].state is StationState.DIRTY # the stuck pot # A full hand cannot wash: the dish is set down first (the shelf). p0 = state.players[0] state, _events = apply_station_verb( state, p0, Verb.RELEASE, StationTarget(station=state.stations[1]), ) assert state.players[0].carrying is None p0 = state.players[0] state, _events = apply_station_verb( state, p0, Verb.WASH, StationTarget(station=state.stations[0]), ) state = held(state, "p0") state, washed = run(state, round(WASH_SECONDS / STEP_DT), action=True) assert state.counters["pots_clean"] == 2 assert state.counters["pots_dirty"] == 0 assert any(e.kind is EventKind.WASHED for e in washed) def test_the_sink_wash_round_trips_the_plate_stock() -> None: """The sink's half of the loop: a carried `DIRTY` plate into the sink (a 1.2 s wash) comes back clean — `plates_clean` up by one, `plates_dirty` down by one, the item's form the washed one — and the sink is `IDLE` again (the `DIRTY -> IDLE` the task names).""" state = make_state( stations=( station(StationKind.SINK, x=5, y=4), ), players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.PLATE, ItemForm.DIRTY)),), counters={"plates_clean": 2, "plates_dirty": 1}, ) p0 = state.players[0] state, _events = apply_station_verb( state, p0, Verb.WASH, StationTarget(station=state.stations[0]), ) assert state.stations[0].state is StationState.WORKING assert state.players[0].carrying is None # the plate is in the sink state = held(state, "p0") state, washed = run(state, round(WASH_SECONDS / STEP_DT), action=True) assert state.stations[0].state is StationState.READY assert state.stations[0].contents[0].form is ItemForm.RAW # the washed plate, waiting assert state.counters["plates_clean"] == 3 assert state.counters["plates_dirty"] == 0 assert any(e.kind is EventKind.WASHED for e in washed) # The pick is the `DIRTY -> IDLE` the loop names: the sink is clean and empty. p0 = state.players[0] state, _events = apply_station_verb( state, p0, Verb.PICK_FROM_STATION, ItemTarget( item=state.stations[0].contents[0], on_station_id=state.stations[0].id, ), ) state, _events = run(state, 1) # the empty ready surface cools to idle assert state.stations[0].state is StationState.IDLE assert not state.stations[0].dirty def test_a_zero_stock_plate_dish_is_the_clean_refusal() -> None: """The runtime safety: a zero plate stock is a named refusal (`NOTHING_HERE` with `why=no_plate`), and the stock's floor is 0 — `plates_clean` never goes below it (the game is not over: the wash is the fix, and the validator's `STATION_LIMIT_ZERO` rule keeps a zero- plate level out of the campaign).""" state = make_state( stations=(station(StationKind.PREP, x=5, y=4),), players=( cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.COOKED)), ), counters={"plates_clean": 0, "plates_dirty": 2}, ) p0 = state.players[0] state, events = apply_station_verb( state, p0, Verb.PLATE_DISH, StationTarget(station=state.stations[0]), ) denied = [e for e in events if e.kind is EventKind.DENIED] assert len(denied) == 1 payload = dict(denied[0].payload) assert payload["reason"] == "nothing_here" assert payload["why"] == "no_plate" assert state.counters["plates_clean"] == 0 # the floor: never below 0 assert state.players[0].carrying is not None # the dish stays in the hand # --- no-op neutrality: a kitchen with nothing decaying is untouched ------------------------- def test_no_prepared_food_is_a_no_op_on_the_state() -> None: """Hash neutrality (the goldens' premise): a kitchen with no decaying item — a raw shelf stock, an idle board, an empty hand — comes back the *same object* from `step_freshness`: no replacement, no events, no hash movement.""" state = make_state( stations=( station( StationKind.SPAWN_SHELF, contents=(item(ItemKind.TOMATO, ItemForm.RAW),), ), station(StationKind.CHOP_BOARD), station(StationKind.STOVE), ), players=(cook(Vec2(5.5, 5.5), carrying=item(ItemKind.TOMATO, ItemForm.RAW)),), ) new_state, events = step_freshness(state, STEP_DT) assert new_state is state assert events == () def test_the_freshness_stage_sits_between_stations_and_orders() -> None: """The wiring, pinned by an event sequence: one substep that completes the board's work (stage 4's `CHOPPED`) and then decays the sitting item (stage 4b, the quality drop) and then spawns (stage 5, an order's clock) — the `CHOPPED` event before any decay effect, the decay before the tick's bookkeeping, one substep, the documented order.""" state = make_state( stations=( station( StationKind.CHOP_BOARD, state=StationState.WORKING, contents=(item(ItemKind.ONION, ItemForm.RAW),), worker_id="p0", progress=1.0 - STEP_DT / 0.9, duration=0.9, ), ), players=(cook(Vec2(5.5, 5.5)),), ) state, events = step( state, STEP_DT, (PlayerInput(player_id="p0", action=True, received_at_tick=0),), ) assert [e.kind for e in events] == [EventKind.CHOPPED, EventKind.READY] board = state.stations[0] assert board.state is StationState.READY # The decay started in the same substep as the flip (stage 4b runs # after stage 4, before stage 5): the quality is already 1 - 1/400. assert board.contents[0].quality == pytest.approx(1.0 - STEP_DT / ROT_S, abs=1e-9) assert board.contents[0].quality < 1.0 # --- the 1 000-tick scripted sequence: conservation asserted per tick ----------------------- def test_clean_plus_dirty_is_conserved_every_tick_of_a_1000_tick_run() -> None: """The task's 1 000-tick scripted sequence: the worst-case policy (serve everything possible, never wash until forced) on the one-plate, one-pot kitchen. Per tick: `…_clean + …_dirty` is the limit, the counters never go negative, the cook is alive, and the run actually cooked (a served dish, a forced wash) — so the conservation is asserted on a round that used the loop, not an idle one.""" state = build_kitchen(plates=1, pots=1) states, event_lists = run_policy(state, book(), 1000) assert len(states) == 1000 for index, s in enumerate(states): assert s.counters["plates_clean"] + s.counters["plates_dirty"] == 1, f"tick {index}" assert s.counters["pots_clean"] + s.counters["pots_dirty"] == 1, f"tick {index}" for key, value in s.counters.items(): assert value >= 0, f"tick {index}: {key} = {value}" assert s.players[0].connected # the cook is alive (and can reach the sink) all_events = [e for events in event_lists for e in events] assert any(e.kind is EventKind.SERVED for e in all_events) # it served assert any(e.kind is EventKind.WASHED for e in all_events) # it was forced to wash assert any( e.kind is EventKind.DENIED and dict(e.payload).get("why") == "no_plate" for e in all_events ) is False # never an un-washed zero-stock attempt assert states[-1].result is None # the round is not over at tick 1000