"""The no-dead-end guarantee (task 04): the worst-case cook never gets stuck. The task's worst case, one scripted cook on the one-plate, one-pot kitchen (sink included — the validator's `STATION_LIMIT_ZERO` rule keeps a zero- plate or zero-pot level out of the campaign, so the only dead end that could exist is the wash being *skipped*, and this test scripts it not to be): * serve everything possible — a dish is chopped, cooked, plated, served, in that order, with no idle walk; * never wash until forced — a wash happens only when a `DIRTY` work station blocks the next use (the in-place wash) or a prop sits in the sink; * the round must survive 2 000 ticks: `plates_clean + plates_dirty` and `pots_clean + pots_dirty` are the limits *every tick*, the counters never go below 0, `plates_clean == 0` never persists longer than `WASH_SECONDS + 5 s` (a live cook reaches the wash in that time — the wash's 1.2 s plus 5 s of walking), and one wash always restores the plate stock to 1 (the round's limit), so the next plate can start. """ from __future__ import annotations from dataclasses import replace import pytest from undercooked.sim.freshness import BURN_AFTER_S from undercooked.sim.grid import Grid, TileKind from undercooked.sim.interact import StationTarget, Verb from undercooked.sim.level import Level, LevelDef from undercooked.sim.model import ( EventKind, Facing, Item, ItemForm, ItemKind, KitchenState, Player, Station, StationKind, StationState, 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 from undercooked.sim.validation import validate_level pytestmark = pytest.mark.unit from _cook_policy import build_kitchen, run_policy # noqa: E402 #: The no-dead-end window: the wash's 1.2 s plus 5 s of walking — a live #: cook at the sink's position is done washing well inside it, so a zero #: stock persisting past it is a dead end, not a walk. NO_DEAD_END_WINDOW = round((WASH_SECONDS + 5.0) / STEP_DT) # 124 ticks 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 test_the_2000_tick_worst_case_never_deadlocks() -> None: """Serve everything, wash only when forced, 2 000 ticks: the stock is conserved every tick, never negative, a zero stock never outlives the wash's window, one wash restores the plate stock, and the round keeps cooking (served dishes, forced washes, a live cook who can reach the sink) — the no-dead-end guarantee, asserted on the run's own ticks.""" state = build_kitchen(plates=1, pots=1) states, event_lists = run_policy(state, _book(), 2000) assert len(states) == 2000 zero_run = 0 max_zero_run = 0 for index, s in enumerate(states): # The conservation and the floor, every tick. 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}" # A live cook (the one who can walk to the sink and wash). assert s.players[0].connected, f"tick {index}" # The wash window: a zero plate stock outlives it only if a wash # is not on its way — here the window is the wash + the walk. if s.counters["plates_clean"] == 0: zero_run += 1 max_zero_run = max(max_zero_run, zero_run) assert zero_run <= NO_DEAD_END_WINDOW, ( f"tick {index}: plates_clean == 0 for {zero_run} ticks " f"(> the wash's window of {NO_DEAD_END_WINDOW})" ) else: zero_run = 0 all_events = [e for events in event_lists for e in events] # One wash restores the plate stock: after every prep wash's # completion, the next tick holds a plate (the round's limit) — no # dead end between the wash and the next plate. for index, events in enumerate(event_lists): prep_washed = any( e.kind is EventKind.WASHED and dict(e.payload).get("station", "").startswith("prep") for e in events ) if prep_washed: assert states[index].counters["plates_clean"] == 1, ( f"tick {index}: a prep wash restored no plate" ) # The run cooked (served dishes) and was forced to wash — the # conservation is asserted on a run that used the loop. assert any(e.kind is EventKind.SERVED for e in all_events) assert any(e.kind is EventKind.WASHED for e in all_events) # A zero stock occurred (the worst case bit) — and was always inside # the wash's window (the loop recovered from it). assert max_zero_run > 0 assert max_zero_run <= NO_DEAD_END_WINDOW # No un-washed zero-stock attempt: the policy never plates at a zero # stock, so a `no_plate` / `no_pot` refusal never even fires (the clean # refusal is test_freshness's targeted test). assert not any( e.kind is EventKind.DENIED and dict(e.payload).get("why") == "no_plate" for e in all_events ) assert not any( e.kind is EventKind.DENIED and dict(e.payload).get("why") == "no_pot" for e in all_events ) # The round is not over at tick 2 000 (the round's own end is phase 05). assert states[-1].result is None # The in-place wash ran (the stuck work station's half of the dirty # loop; the sink's carried-prop half is test_freshness's targeted # round trip). assert any( e.kind is EventKind.WASHED and dict(e.payload).get("station", "").startswith("prep") for e in all_events ) def test_a_single_wash_restores_plating_at_zero_stock() -> None: """The strictest point of the guarantee, one wash: a zero plate stock (a dirty prep's plate stuck on it, the clean pool empty) is the round's dead end *unless the wash runs* — one in-place wash (1.2 s) puts the plate back, and the very next `PLATE_DISH` press starts (no `no_plate` refusal in between). One wash, plating again.""" prep = Station(id="prep-5-4", kind=StationKind.PREP, x=5.0, y=4.0, dirty=True) state = KitchenState( level_id="04_no_deadlock", seed=17, grid=Grid(width=7, height=7, tiles=(TileKind.FLOOR,) * 49), tick=0, stations=(prep,), players=(Player(id="p0", pos=Vec2(5.5, 5.5), facing=Facing.UP, seat=0),), counters={"plates_clean": 0, "plates_dirty": 1, "pots_clean": 1, "pots_dirty": 0}, target_score=40, time_left=90.0, ) dish = Item(kind=ItemKind.TOMATO, form=ItemForm.COOKED, ttl=BURN_AFTER_S) # The zero stock is a clean refusal first — on a *clean* prep (the # dirty guard is the other, also-named refusal): a `PLATE_DISH` press # with a dish and an empty clean pool is the named `no_plate`, the # floor held. clean_prep = Station(id="prep-5-4", kind=StationKind.PREP, x=5.0, y=4.0) clean_state = KitchenState( level_id="04_no_deadlock", seed=17, grid=Grid(width=7, height=7, tiles=(TileKind.FLOOR,) * 49), tick=0, stations=(clean_prep,), players=(Player(id="p0", pos=Vec2(5.5, 5.5), facing=Facing.UP, seat=0),), counters={"plates_clean": 0, "plates_dirty": 1, "pots_clean": 1, "pots_dirty": 0}, target_score=40, time_left=90.0, ) clean_state = clean_state.with_players((replace(clean_state.players[0], carrying=dish),)) clean_state, refused = apply_station_verb( clean_state, clean_state.players[0], Verb.PLATE_DISH, StationTarget(station=clean_prep) ) denied = [e for e in refused if e.kind is EventKind.DENIED] assert len(denied) == 1 assert dict(denied[0].payload)["why"] == "no_plate" assert clean_state.counters["plates_clean"] == 0 # the floor: never below 0 assert clean_state.players[0].carrying is dish # the dish stays in the hand # The dirty prep's own refusal (a full hand at a stuck plate): state = state.with_players((replace(state.players[0], carrying=dish),)) state, dirty_refused = apply_station_verb( state, state.players[0], Verb.PLATE_DISH, StationTarget(station=prep) ) dirty_denied = [e for e in dirty_refused if e.kind is EventKind.DENIED] assert len(dirty_denied) == 1 assert dict(dirty_denied[0].payload)["reason"] == "station_dirty" assert state.players[0].carrying is dish # A full hand cannot wash: the dish is set aside (the hand empties) # and the one wash runs. state = state.with_players((replace(state.players[0], carrying=None),)) state, _events = apply_station_verb( state, state.players[0], Verb.WASH, StationTarget(station=prep) ) assert state.stations[0].state is StationState.WORKING for _ in range(round(WASH_SECONDS / STEP_DT)): state, _events = step( state, STEP_DT, (PlayerInput(player_id="p0", action=True, received_at_tick=state.tick),), ) # The wash paid the plate back: the clean pool is the limit again, the # dirty pool is empty, the prep is washed (the `DIRTY -> IDLE` half). assert state.counters["plates_clean"] == 1 assert state.counters["plates_dirty"] == 0 # And the very next `PLATE_DISH` press starts (one wash, plating again — # the no-dead-end point, the whole of it). state = state.with_players((replace(state.players[0], carrying=dish),)) state, started = apply_station_verb( state, state.players[0], Verb.PLATE_DISH, StationTarget(station=prep) ) assert state.stations[0].state is StationState.WORKING assert not any(e.kind is EventKind.DENIED for e in started) assert state.counters["plates_clean"] == 0 # the new use took the one plate assert state.counters["plates_dirty"] == 1 def test_the_validator_keeps_zero_prop_levels_out_of_the_campaign() -> None: """The guarantee's other half, the content rule: a level with a zero plate or pot stock is refused at validation (`STATION_LIMIT_ZERO`) — a zero-plate kitchen is the dead end the guarantee rules out of existence, before the round's first tick.""" grid = [ "#####", "#SV.#", "#####", ] tiles = { ".": {"kind": "floor"}, "#": {"kind": "wall"}, "S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"}, "V": {"kind": "floor", "station": "serve"}, } orders = { "pool": ["soup"], "concurrent": 1, "spawn_interval": [16, 20], "deadline": 60, } book = _book() zero_plates = Level.build( LevelDef.model_validate( { "id": "04_zero_plates", "name": "Zero Plates", "order": 1, "time_limit": 150, "target_score": 45, "grid": grid, "tiles": tiles, "spawns": [[1, 1]], "orders": orders, "station_limits": {"plates": 0}, } ) ) report = validate_level(zero_plates, book) rules = [violation.rule for violation in report.violations] assert "STATION_LIMIT_ZERO" in rules assert any("plate" in v.message.lower() for v in limit_zero_messages(report)) zero_pots = Level.build( LevelDef.model_validate( { "id": "04_zero_pots", "name": "Zero Pots", "order": 1, "time_limit": 150, "target_score": 45, "grid": grid, "tiles": tiles, "spawns": [[1, 1]], "orders": orders, "station_limits": {"pots": 0}, } ) ) report = validate_level(zero_pots, book) rules = [violation.rule for violation in report.violations] assert "STATION_LIMIT_ZERO" in rules assert any("pot" in v.message.lower() for v in limit_zero_messages(report)) def test_the_1000_tick_run_is_conserved_too() -> None: """The task's 1 000-tick scripted sequence (the shorter half of the same run): the same conservation, the same floor, the same live cook — asserted per tick over the first 1 000 ticks of the worst case.""" state = build_kitchen(plates=1, pots=1) states, _events = 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, f"tick {index}" assert states[-1].result is None # --- helpers ------------------------------------------------------------------------ def limit_zero_messages(report) -> list: """The report's `STATION_LIMIT_ZERO` violations (the prose half).""" return [v for v in report.violations if v.rule == "STATION_LIMIT_ZERO"]