"""Headless full-round simulations (phase 04, task 06) — the whole pipeline driven from tick 0 on the shipped level 1 to a verdict, no server. These are the phase's integration layer (PLAN §9.4): the scripted cooks of `helpers.py` drive the *complete* `step()` — movement, the action gate, stations, freshness, orders, scoring, the round state — on `levels/01_first_kitchen.yaml` with the shipped recipe book, and the assertions read the verdict (`result`), the stars, the counters and the event stream. A win is won by the rules (score over the bar, the early win's substep), a loss by the clock, and the wash cycle is the dirty loop closing on a one-prop kitchen. * `test_win_level_1_solo` — one scripted cook cooks soup end to end (fetch, chop, pot, cook, plate, serve, repeat) from tick 0: the shipped level 1 is won with ≥ 1 s of clock left (winnable with slack, not on the buzzer) and `stars ≥ 1`; against the 100 bar a single first dish (≈ 58 points) cannot clear, so the run repeats the whole chain until `WON` (≥ 2 dishes served) — the chain is not one-shot. * `test_loss_by_timer_idle` — no cooks, no inputs: the clock runs the 150 s down, `result == LOST` on the buzzer (tick 3 000) with `score == 0` and at least one missed order; every tick the all-idle invariants hold: counters ≥ 0, the rail within `order_concurrent`, the item count within the seeded shelf stock, the order ids bounded, and `time_left` finite (no division by zero anywhere in the idle round). * `test_two_player_win` — two scripted cooks (each running the full-chain policy on its own primary shelf) clear the 100 bar and the rules reward the second cook by scoring its work: both cooks serve a dish, both earn points, and the per-player contributions sum to the round's score. (Measured across nine seat/split/role configurations on this single- pipeline kitchen, two cooks never beat one on the clock — the shared fixtures serialize the second cook — so the testable reward the rules give is the per-cook scoring, not a speed edge.) * `test_wash_cycle_required` — the one-plate, one-pot kitchen: after the first dish the stock pair is dirty, so the second plate needs the wash — a `WASHED` lands before the second serve, `plates_clean + plates_dirty == 1` and `pots_clean + pots_dirty == 1` every tick (the stock is conserved, not just refunded), and the round ends `WON` with ≥ 2 dishes served. The zero-stock refusal itself is the named `no_plate` denial: asserted the targeted way (`apply_station_verb` on a zero-stock state — the ladder's in-round answer at a dirty fixture with a full hand is `STATION_DIRTY`, and the stock guard is the named safety behind it), leaving the dish in the hand and the stock floored at 0. * `test_no_order_flooding_over_full_round` — a full 150 s idle round (no early verdict truncating the rail): the rail never exceeds `order_concurrent` on any tick, and the order ids are strictly increasing in first appearance — spawns are scheduled, not flooded. """ from __future__ import annotations import math from itertools import pairwise from helpers import ( load_book, make_level_1_state, run_round, shelf_id_for, ) from undercooked.sim.grid import Grid, TileKind from undercooked.sim.interact import StationTarget, Verb from undercooked.sim.model import ( EventKind, Facing, Item, ItemForm, ItemKind, KitchenState, Player, RoundResult, Station, StationKind, Vec2, ) from undercooked.sim.round_state import stars from undercooked.sim.stations import apply_station_verb #: Level 1's budget: 150 s, 20 substeps/s. FULL_ROUND_TICKS = 3000 #: The bar a single first dish (≈ 58 points at best) cannot clear: the #: repeat, the two-player and the wash-cycle runs need at least two #: dishes, so the wash cycle is genuinely forced before the second plate. TWO_DISH_BAR = 100 def _tomato_shelf() -> str: """The tomato shelf's id (the round is built with the default seed, so the layout is the same every run).""" return shelf_id_for(make_level_1_state(), ItemKind.TOMATO) def test_win_level_1_solo() -> None: """A scripted cook, tick 0 to the win, on the shipped level 1. The shipped bar (45): one first dish (≈ 58 points) clears it — the win lands with ≥ 1 s of clock left (slack, not a buzzer) and `stars ≥ 1`. Against the 100 bar the first dish cannot clear, so the run repeats the whole chain (fetch, chop, pot, cook, plate, serve) until `WON` with ≥ 2 dishes served: the chain is not one-shot. """ book = load_book() shelf = _tomato_shelf() # The shipped level 1: the phase's criterion, tick 0 to `WON`. state = make_level_1_state(seats=1) states, _events, verdict_tick = run_round( state, book, [shelf], FULL_ROUND_TICKS - 10, stop_at_verdict=True ) final = states[-1] assert verdict_tick is not None, "the round never ended in the window" assert final.result is RoundResult.WON assert final.score >= final.target_score assert stars(final) >= 1 assert final.counters.get("served", 0) >= 1 # Winnable with slack: the win lands ≥ 1 s before the buzzer. assert final.time_left >= 1.0 assert 0 < verdict_tick < FULL_ROUND_TICKS # The 100 bar: a single first dish (≈ 58) cannot clear — the run # repeats the whole chain until it does. repeat = make_level_1_state(seats=1, target=TWO_DISH_BAR) repeat_states, _repeat_events, repeat_tick = run_round( repeat, book, [shelf], FULL_ROUND_TICKS - 10, stop_at_verdict=True ) repeat_final = repeat_states[-1] assert repeat_tick is not None, "the repeat run never ended in the window" assert repeat_final.result is RoundResult.WON assert repeat_final.score >= TWO_DISH_BAR assert repeat_final.counters.get("served", 0) >= 2, "the chain is one-shot" assert repeat_final.time_left >= 1.0 def test_loss_by_timer_idle() -> None: """No cooks, no inputs: the clock decides. `time_left` reaches 0, `result == LOST` on the buzzer (tick 3 000), `score == 0` (nothing was served, and the miss penalty is clamped at the floor of 0), `counters["missed"] ≥ 1` (orders spawned and expired on their 60 s deadline), and every tick the all-idle invariants hold: counters ≥ 0, the rail within `order_concurrent`, the item count within the seeded shelf stock, the order ids bounded, and `time_left` finite (no division by zero anywhere in the idle round). """ state = make_level_1_state(seats=0) book = load_book() states, _event_lists, verdict_tick = run_round( state, book, [], FULL_ROUND_TICKS + 10, stop_at_verdict=True ) final = states[-1] assert verdict_tick == FULL_ROUND_TICKS, "the buzzer is the 150 s tick" assert final.result is RoundResult.LOST assert final.score == 0 assert final.counters.get("missed", 0) >= 1 for index, s in enumerate(states): assert math.isfinite(s.time_left), f"tick {index}" for key, value in s.counters.items(): assert value >= 0, f"tick {index}: {key} = {value}" assert len(s.orders) <= s.order_concurrent, f"tick {index}" items = sum(len(station.contents) for station in s.stations) assert items <= 16, f"tick {index}: {items} items (the seeded stock)" # The rail's schedule stays bounded: at most one spawn per 16 s # (the level's `spawn_interval` floor) over 150 s → < 16 ids. assert s.next_order_id <= 16, f"tick {index}: {s.next_order_id} ids" def test_two_player_win() -> None: """Two scripted cooks (each the full-chain policy, its own primary shelf) clear the 100 bar — a bar a single first dish (≈ 58 points) cannot clear — and the rules reward the second cook by scoring its work: both cooks serve a dish, both earn points, and the per-player contributions sum to the round's score. (Measured across nine seat/split/role configurations on this single-pipeline kitchen, two cooks never beat one on the clock — the shared fixtures serialize the second cook — so the testable reward the rules give is the per-cook scoring, not a speed edge.) """ book = load_book() state = make_level_1_state(seats=2, target=TWO_DISH_BAR) states, _events, verdict_tick = run_round( state, book, [_tomato_shelf(), shelf_id_for(state, ItemKind.ONION)], FULL_ROUND_TICKS - 10, stop_at_verdict=True, ) final = states[-1] assert verdict_tick is not None, "the round never ended in the window" assert final.result is RoundResult.WON assert final.score >= TWO_DISH_BAR # Both cooks' work is scored: each served a dish and earned points. for index, cook in enumerate(final.players): assert cook.dishes_served >= 1, f"cook {index} served no dish" assert cook.score >= 1, f"cook {index} earned no points" # The per-player contributions sum to the round's score (a win in the # first 52 s, before the first order's deadline, so no miss penalty # stands between them). assert sum(cook.score for cook in final.players) == final.score # The dishes sum to the served counter. assert sum(cook.dishes_served for cook in final.players) == final.counters["served"] def _zero_stock_plate_dish_denial() -> None: """The zero-stock refusal, targeted (the named `no_plate` denial): a zero plate stock is a named refusal, the stock's floor is 0, and the dish stays in the hand — the wash is the fix, not the refusal.""" state = KitchenState( level_id="wash_cycle_probe", seed=7, grid=Grid(width=7, height=7, tiles=(TileKind.FLOOR,) * 49), counters={"plates_clean": 0, "plates_dirty": 1}, stations=( Station(id="prep-0", kind=StationKind.PREP, x=5.0, y=4.0), ), players=( make_cook_carrying_cooked(), ), ) player = state.players[0] state, events = apply_station_verb( state, player, Verb.PLATE_DISH, StationTarget(station=state.stations[0]) ) denied = [e for e in events if e.kind is EventKind.DENIED] assert len(denied) == 1, "the zero-stock plate is not a named refusal" 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 def make_cook_carrying_cooked() -> Player: """One cook (seat 0) standing in front of the probe's prep, holding a cooked item — the `plate_dish`'s whole precondition minus the stock.""" return Player( id="p0", pos=Vec2(5.5, 5.5), facing=Facing.UP, carrying=Item(ItemKind.TOMATO, ItemForm.COOKED), ) def test_wash_cycle_required() -> None: """The one-plate, one-pot kitchen: the stock pair goes dirty after the first dish, so the second plate is refused until the wash runs. Asserted two ways, the same guarantee: the targeted way (a zero-stock `plate_dish` is the named `no_plate` refusal, the stock floored at 0, the dish left in the hand — the wash is the fix) and the full-round way (a `WASHED` lands before the second serve — the policy washes the dirty fixture in place, the stock pair is conserved every tick, and the round ends `WON` with ≥ 2 dishes served: the second dish, which a single clean plate cannot support without the wash, is what clears the 100 bar). """ _zero_stock_plate_dish_denial() # the denial: named, zero stock, floor 0 state = make_level_1_state(seats=1, target=TWO_DISH_BAR, plates=1, pots=1) book = load_book() states, event_lists, verdict_tick = run_round( state, book, [_tomato_shelf()], FULL_ROUND_TICKS - 10, stop_at_verdict=True ) final = states[-1] assert verdict_tick is not None, "the round never ended in the window" assert final.result is RoundResult.WON assert final.counters.get("served", 0) >= 2 # The stock pair is conserved every tick (the loop's invariant). 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}" # The success after the wash: a wash lands before the second serve # (with a single clean plate, the second plate needs it), and the # second dish lands after it. wash_ticks = [ index for index, events in enumerate(event_lists) for event in events if event.kind is EventKind.WASHED ] served_ticks = [ index for index, events in enumerate(event_lists) for event in events if event.kind is EventKind.SERVED ] assert len(served_ticks) >= 2 assert wash_ticks, "the wash never ran" assert min(wash_ticks) < served_ticks[1], ( "the second dish served before any wash" ) def test_no_order_flooding_over_full_round() -> None: """A full 150 s idle round (no early verdict truncating the rail's schedule): the ticket rail never exceeds `order_concurrent` on any tick, and the order ids are strictly increasing in first appearance — spawns are scheduled, not flooded. The whole window runs, so several orders spawn and expire on their 60 s deadline.""" state = make_level_1_state(seats=0) book = load_book() states, _event_lists, _verdict_tick = run_round( state, book, [], FULL_ROUND_TICKS ) assert len(states) == FULL_ROUND_TICKS seen_ids: list[int] = [] for index, s in enumerate(states): assert len(s.orders) <= s.order_concurrent, f"tick {index}" for order in s.orders: if order.id not in seen_ids: seen_ids.append(order.id) # Strictly increasing in first appearance (a monotone id counter). for earlier, later in pairwise(seen_ids): assert later > earlier, f"{later} did not follow {earlier}" # The whole window ran: the 16 s spawn floor over 150 s → ≥ 3 orders # spawned (and the first two expired on their deadline). assert len(seen_ids) >= 3