"""`sim/orders.py` — the ticket rail: seeded spawning, the concurrency cap, the full-rail retry, tick-precise deadlines, and the miss bookkeeping. The spec the rail runs on is the level's `orders` copied as scalars in tick units onto the state (phase 04 task 02); `build_state` does the copy, and these tests pin both halves: the copy (from a real `Level`) and the stage itself (hand-built states, so a rule is one knob at a time). The reproducibility contract is a pair: the same seed replays the same order sequence (spawn ticks, recipes, ids, the `rng_state` thread), and a different seed diverges. """ from __future__ import annotations from dataclasses import replace from itertools import pairwise from typing import Any import pytest from undercooked.sim.grid import Grid from undercooked.sim.level import Level, LevelDef from undercooked.sim.model import ( Event, EventKind, KitchenState, Order, TileKind, ) from undercooked.sim.orders import ( FIRST_ORDER_DELAY_TICKS, MIN_RETRY_TICKS, TICK_SECONDS, order_summary, step_orders, to_ticks, ) from undercooked.sim.recipe import RecipeBook from undercooked.sim.rng import Rng from undercooked.sim.state_build import SeatSpec, build_state from undercooked.sim.step import STEP_DT, step pytestmark = pytest.mark.unit #: The recipe ids the tests draw from — none of them is a shipped recipe id. RECIPE_IDS: tuple[str, ...] = ("dish_a", "dish_b", "dish_c", "dish_d") #: `base_score` per recipe id: the value an order carries, checked by the tests. BASE_SCORES: dict[str, int] = {"dish_a": 40, "dish_b": 30, "dish_c": 50, "dish_d": 70} SEED = 0x204F_0002 def book() -> RecipeBook: return RecipeBook.model_validate( { "recipes": { recipe_id: { "id": recipe_id, "name": recipe_id.title(), "base_score": BASE_SCORES[recipe_id], "ingredients": [{"kind": "tomato", "form": "raw"}], "steps": [{"kind": "serve", "station": "serve"}], } for recipe_id in RECIPE_IDS } } ) def make_state( *, pool: tuple[str, ...] = ("dish_a", "dish_b"), concurrent: int = 2, spawn_min: int = 40, spawn_max: int = 80, deadline: int = 120, penalty: int = 10, next_order_at: int = FIRST_ORDER_DELAY_TICKS, next_order_id: int = 1, seed: int = SEED, tick: int = 0, orders: tuple[Order, ...] = (), target_score: int = 100, ) -> KitchenState: """A hand-built round: a bare floor, the spec scalars, nothing else.""" return KitchenState( level_id="04_orders", seed=seed, grid=Grid(width=8, height=8, tiles=(TileKind.FLOOR,) * 64), tick=tick, orders=orders, order_pool=pool, order_concurrent=concurrent, order_spawn_min=spawn_min, order_spawn_max=spawn_max, order_deadline=deadline, order_penalty=penalty, next_order_at=next_order_at, next_order_id=next_order_id, rng_state=seed, target_score=target_score, time_left=600.0, ) def run_ticks( state: KitchenState, ticks: int, value_book: RecipeBook | None = None, ) -> tuple[KitchenState, list[Event]]: """`ticks` substeps of the order stage, driven the way the kernel drives them: the stage runs on the pre-increment tick, then the tick advances. The events come back in substep order. """ events: list[Event] = [] current = state for _ in range(ticks): current, produced = step_orders(current, STEP_DT, value_book) current = replace(current, tick=current.tick + 1) events.extend(produced) return current, events def spawn_log( state: KitchenState, ticks: int ) -> tuple[list[int], list[str], list[int]]: """Run the stage for `ticks` substeps; return the spawn (tick, recipe, id) log. A spawn is detected by `next_order_id` rising — that also covers the busy-rail case where a spawn lands on the same substep an order expires and the rail length does not change. """ spawn_ticks_found: list[int] = [] recipes: list[str] = [] ids: list[int] = [] current = state book_value = book() for _ in range(ticks): due = current.tick # the pre-increment tick this substep runs on before_id = current.next_order_id current, _ = step_orders(current, STEP_DT, book_value) current = replace(current, tick=current.tick + 1) if current.next_order_id != before_id: spawned = next(o for o in current.orders if o.id == before_id) spawn_ticks_found.append(due) recipes.append(spawned.recipe_id) ids.append(spawned.id) return spawn_ticks_found, recipes, ids def rng_after_draws(seed: int, draws: int) -> int: """The `Rng` counter after exactly `draws` `next_u64`s from `seed`.""" rng = Rng(seed) for _ in range(draws): rng.next_u64() return rng.state # --- the constants and the conversion ---------------------------------------- def test_the_first_order_delay_is_one_and_a_half_seconds() -> None: assert FIRST_ORDER_DELAY_TICKS == 30 assert pytest.approx(1.5) == FIRST_ORDER_DELAY_TICKS * TICK_SECONDS def test_the_full_rail_retry_gap_is_one_second() -> None: assert MIN_RETRY_TICKS == 20 assert pytest.approx(1.0) == MIN_RETRY_TICKS * TICK_SECONDS @pytest.mark.parametrize( ("seconds", "ticks"), [(4.0, 80), (6.0, 120), (30.0, 600), (16.0, 320), (0.04, 1), (0.0, 1)], ) def test_to_ticks_converts_and_clamps_to_at_least_one( seconds: float, ticks: int ) -> None: assert to_ticks(seconds) == ticks # --- the first-order delay ---------------------------------------------------- def test_nothing_spawns_before_the_first_order_delay() -> None: state = make_state() assert state.next_order_at == FIRST_ORDER_DELAY_TICKS for _ in range(FIRST_ORDER_DELAY_TICKS): state, events = step_orders(state, STEP_DT, book()) state = replace(state, tick=state.tick + 1) assert events == () assert state.orders == (), f"a spawn before tick {FIRST_ORDER_DELAY_TICKS}" assert state.next_order_at == FIRST_ORDER_DELAY_TICKS assert state.rng_state == SEED # no draw before the first spawn def test_the_first_order_spawns_at_the_delay_and_schedules_the_next() -> None: state = make_state(spawn_min=40, spawn_max=80, deadline=120) state, _ = run_ticks(state, FIRST_ORDER_DELAY_TICKS + 1, book()) # spawn on tick 30 assert state.tick == FIRST_ORDER_DELAY_TICKS + 1 assert len(state.orders) == 1 order = state.orders[0] assert order.id == 1 assert order.recipe_id in ("dish_a", "dish_b") assert order.value == BASE_SCORES[order.recipe_id] assert order.deadline == FIRST_ORDER_DELAY_TICKS + 120 assert order.time_left == 120 * TICK_SECONDS assert state.next_order_id == 2 assert FIRST_ORDER_DELAY_TICKS + 40 <= state.next_order_at <= FIRST_ORDER_DELAY_TICKS + 80 # a spawn is exactly two draws: the choice, then the interval. assert state.rng_state == rng_after_draws(SEED, 2) def test_a_single_recipe_pool_spawns_that_recipe() -> None: state = make_state(pool=("dish_c",)) state, _ = run_ticks(state, FIRST_ORDER_DELAY_TICKS + 1, book()) assert len(state.orders) == 1 assert state.orders[0].recipe_id == "dish_c" assert state.orders[0].value == 50 # BASE_SCORES["dish_c"] # --- the full-rail retry (its own test) -------------------------------------- def test_a_full_rail_retries_after_one_second_without_consuming_a_draw() -> None: """The rule, stated: a due tick the rail cannot serve retries `MIN_RETRY_TICKS` later and spends neither the recipe draw nor the interval draw — otherwise a full rail would silently warp the cadence.""" state = make_state(concurrent=1, spawn_min=50, spawn_max=50, deadline=100) state, _ = run_ticks(state, FIRST_ORDER_DELAY_TICKS + 1, book()) # spawn on tick 30 assert len(state.orders) == 1 assert state.orders[0].deadline == FIRST_ORDER_DELAY_TICKS + 100 after_first = state.rng_state # due at 80 (30 + 50): the order lives until 130, so the rail is full: # retry from 80 to 100. state, _ = run_ticks(state, 50, book()) # substeps on ticks 31..80 assert state.tick == 81 assert len(state.orders) == 1 assert state.next_order_at == 80 + MIN_RETRY_TICKS assert state.rng_state == after_first, "a retry must not consume a draw" # due at 100 and 120: still full, still no draw. state, _ = run_ticks(state, 20, book()) assert state.tick == 101 assert state.next_order_at == 100 + MIN_RETRY_TICKS assert state.rng_state == after_first state, _ = run_ticks(state, 20, book()) assert state.tick == 121 assert state.next_order_at == 120 + MIN_RETRY_TICKS assert state.rng_state == after_first # the order expires on the tick it reaches 0 (30 + 100 = 130) — a miss. state, events = run_ticks(state, 10, book()) # substeps on ticks 121..130 assert state.tick == 131 assert state.orders == () assert state.counters["missed"] == 1 assert len(events) == 1 and events[0].kind is EventKind.ORDER_MISSED # due at 140: the rail is free — the *first* draw after the first spawn # is this spawn's, and the interval draw it consumed was the retry's, not # one of the blocked due ticks'. state, _ = run_ticks(state, 10, book()) # substeps on ticks 131..140 assert state.tick == 141 assert len(state.orders) == 1 assert state.orders[0].id == 2 assert state.rng_state == rng_after_draws(SEED, 4) def test_an_empty_pool_never_spawns_and_never_retries() -> None: state = make_state(pool=(), next_order_at=FIRST_ORDER_DELAY_TICKS) for _ in range(100): state, events = step_orders(state, STEP_DT, None) # no book needed state = replace(state, tick=state.tick + 1) assert events == () assert state.orders == () assert state.next_order_id == 1 assert state.rng_state == SEED def test_never_two_spawns_on_the_same_tick_even_with_a_zero_tick_span() -> None: """A `spawn_min` of 0 ticks (a hand-built state the schema would refuse) still schedules at least one tick ahead: the clamp is the guard.""" state = make_state(spawn_min=0, spawn_max=0, deadline=1000) state, _ = run_ticks(state, FIRST_ORDER_DELAY_TICKS + 1, book()) # spawn on tick 30 assert len(state.orders) == 1 assert state.next_order_at == FIRST_ORDER_DELAY_TICKS + 1 # clamped, one ahead state, _ = run_ticks(state, 1, book()) # the substep on tick 31 assert len(state.orders) == 2 # the clamp bought exactly the *next* tick # --- deadline expiry, to the tick --------------------------------------------- def test_an_order_lives_exactly_its_deadline_substeps() -> None: deadline_ticks = 10 state = make_state(deadline=deadline_ticks, spawn_min=1000, spawn_max=1000) state, _ = run_ticks(state, FIRST_ORDER_DELAY_TICKS + 1, book()) # spawn on tick 30 assert len(state.orders) == 1 spawn_tick = state.tick - 1 assert state.orders[0].deadline == spawn_tick + deadline_ticks # the second substep of life: one tick has gone, 0.05 s of clock remains # per remaining tick. for expected_remaining in range(deadline_ticks - 1, 0, -1): state, events = step_orders(state, STEP_DT, book()) state = replace(state, tick=state.tick + 1) assert events == () assert len(state.orders) == 1 assert state.orders[0].time_left == pytest.approx( expected_remaining * TICK_SECONDS, abs=1e-9 ) # on the tick that reaches 0 the order is gone, missed, and named. expire_tick = spawn_tick + deadline_ticks state, events = step_orders(state, STEP_DT, book()) state = replace(state, tick=state.tick + 1) assert state.tick == expire_tick + 1 assert state.orders == () assert state.counters["missed"] == 1 assert len(events) == 1 assert events[0].kind is EventKind.ORDER_MISSED def test_the_order_missed_event_names_the_order_and_the_tick() -> None: state = make_state(deadline=10, spawn_min=1000, spawn_max=1000) state, _ = run_ticks(state, FIRST_ORDER_DELAY_TICKS + 1, book()) # spawn on tick 30 order = state.orders[0] _state, events = run_ticks(state, 10, book()) # substeps on ticks 31..40: expiry at 40 event = events[0] assert event.kind is EventKind.ORDER_MISSED assert event.x == 0.0 and event.y == 0.0 # the rail is HUD, not floor assert dict(event.payload) == { "id": order.id, "r": order.recipe_id, "tick": order.deadline, } def test_a_miss_is_counted_per_order_and_the_penalty_is_on_the_state() -> None: state = make_state(concurrent=2, deadline=10, spawn_min=1000, spawn_max=1000) state, _ = run_ticks(state, FIRST_ORDER_DELAY_TICKS + 1, book()) # spawn on tick 30 assert len(state.orders) == 1 # a second order cannot spawn (the interval is 1000 ticks away), so fill # the rail by hand: a pre-placed order that expires on the next substep. other = Order( id=99, recipe_id="dish_b", time_left=0.05, deadline=float(state.tick), # due this substep: it is the miss value=30, ) state = replace(state, orders=(*state.orders, other)) state, events = step_orders(state, STEP_DT, book()) assert state.counters["missed"] == 1 assert len(events) == 1 assert state.order_penalty == 10 # task 03's scoring spends it, not this stage # --- 200 seconds: cadence bounds, the cap, and unbounded growth --------------- def test_spawn_gaps_stay_in_the_interval_bounds_over_200_seconds() -> None: """A rail that is always free when a spawn is due (the orders outlive no interval): every gap is a plain interval draw, so all of them are in `[min, max]` — and the cap, the ids and the first delay hold for the whole 200 simulated seconds.""" min_ticks, max_ticks, deadline = 40, 120, 30 state = make_state( pool=RECIPE_IDS, concurrent=4, spawn_min=min_ticks, spawn_max=max_ticks, deadline=deadline, seed=0x204F_0003, ) total_ticks = 200 * round(1.0 / TICK_SECONDS) # 200 s of substeps spawns: list[int] = [] seen_ids: list[int] = [] max_rail = 0 for _ in range(total_ticks): before = state state, _ = step_orders(state, STEP_DT, book()) state = replace(state, tick=state.tick + 1) assert len(state.orders) <= 4, "the rail exceeded its cap" max_rail = max(max_rail, len(state.orders)) if state.next_order_id != before.next_order_id: spawns.append(before.tick) # the pre-increment tick the spawn ran on seen_ids.append(before.next_order_id) assert spawns, "200 seconds with a live pool must spawn orders" assert spawns[0] == FIRST_ORDER_DELAY_TICKS gaps = [b - a for a, b in pairwise(spawns)] assert all(min_ticks <= gap <= max_ticks for gap in gaps), gaps assert seen_ids == list(range(1, len(seen_ids) + 1)), "ids are monotonic, unique" assert max_rail >= 1 # the cap actually bound somewhere is proven by the busy-rail test; here # the orders outlive no interval, so the rail never fills. assert state.counters.get("missed", 0) >= 0 def test_the_rail_never_exceeds_the_cap_under_a_busy_rail_over_200_seconds() -> None: """A rail that is often full: the cap holds on every tick, spawns only ever land on due ticks, the full-rail retry path is actually exercised, and the id sequence stays a contiguous run.""" state = make_state( pool=RECIPE_IDS, concurrent=2, spawn_min=20, spawn_max=40, deadline=400, # the orders outlive the interval: the rail stays busy seed=0x204F_0004, ) total_ticks = 200 * round(1.0 / TICK_SECONDS) seen_ids: list[int] = [] retries = 0 for _ in range(total_ticks): before = state state, _ = step_orders(state, STEP_DT, book()) state = replace(state, tick=state.tick + 1) assert len(state.orders) <= 2, "the rail exceeded its cap" if state.next_order_id != before.next_order_id: assert before.tick >= before.next_order_at, "a spawn off a non-due tick" seen_ids.append(before.next_order_id) elif before.tick >= before.next_order_at and before.order_pool: # a due tick the full rail could not serve: the documented retry. assert state.next_order_at == before.tick + MIN_RETRY_TICKS retries += 1 assert seen_ids, "a busy 200 s must spawn orders" assert seen_ids == list(range(1, len(seen_ids) + 1)), "ids are monotonic, unique" assert retries >= 1, "a busy rail must exercise the full-rail retry path" # --- reproducibility ---------------------------------------------------------- def test_the_same_seed_replays_the_identical_order_sequence() -> None: def run(seed: int) -> tuple[list[int], list[str], list[int], int]: state = make_state( pool=RECIPE_IDS, concurrent=3, spawn_min=30, spawn_max=90, deadline=120, seed=seed, ) ticks, recipes, ids = spawn_log(state, 6000) # 300 s return ticks, recipes, ids, state.rng_state first = run(SEED) second = run(SEED) assert first == second assert len(first[0]) >= 2, "300 s must spawn more than one order" assert len(set(first[1])) >= 2, "a four-recipe pool must show at least two dishes" # counters agree too: rebuild one side and compare the miss count. state = make_state( pool=RECIPE_IDS, concurrent=3, spawn_min=30, spawn_max=90, deadline=120, seed=SEED ) state, _ = run_ticks(state, 6000, book()) assert state.counters.get("missed", 0) >= 1 def test_a_different_seed_diverges() -> None: def run(seed: int) -> tuple[list[int], list[str], list[int]]: state = make_state( pool=RECIPE_IDS, concurrent=3, spawn_min=30, spawn_max=90, deadline=120, seed=seed, ) return spawn_log(state, 6000) one = run(0x204F_0005) two = run(0x204F_0006) assert one[1] != two[1] or one[0] != two[0], "different seeds must diverge" # --- order_summary ------------------------------------------------------------ def test_order_summary_is_the_compact_rail() -> None: state = make_state( pool=("dish_a",), next_order_at=5, deadline=20, spawn_min=100, spawn_max=100 ) state, _ = run_ticks(state, 6, book()) # the spawn lands on pre-increment tick 5 assert state.tick == 6 assert len(state.orders) == 1 summary = order_summary(state) assert len(summary) == 1 assert dict(summary[0]) == { "id": 1, "r": "dish_a", "l": 0.95, # (20 - 1) / 20, two decimals: one substep of life has gone "pts": 40, } def test_order_summary_of_an_empty_rail_is_empty() -> None: assert order_summary(make_state()) == () def test_order_summary_clamps_the_fraction_to_the_unit_interval() -> None: state = make_state(deadline=20) # a hand-placed order whose deadline already passed: the fraction is 0, not negative overdue = Order(id=7, recipe_id="dish_b", time_left=0.0, deadline=10.0, value=30) state = replace(state, tick=11, orders=(overdue,)) summary = order_summary(state) assert dict(summary[0]) == {"id": 7, "r": "dish_b", "l": 0.0, "pts": 30} # --- build_state: the spec copy ----------------------------------------------- TILES: dict[str, dict[str, str]] = { ".": {"kind": "floor"}, "#": {"kind": "wall"}, "S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"}, "V": {"kind": "floor", "station": "serve"}, } GRID = [ "#####", "#S.V#", "#...#", "#####", ] def make_level(**overrides: Any) -> Level: data: dict[str, Any] = { "id": "04_orders_build", "name": "Orders Kitchen", "order": 1, "time_limit": 120, "target_score": 40, "difficulty": 1, "grid": list(GRID), "tiles": {key: dict(value) for key, value in TILES.items()}, "spawns": [[2, 1]], "orders": { "pool": ["dish_a", "dish_b"], "concurrent": 3, "spawn_interval": [4.0, 6.0], "deadline": 30.0, "penalty": 15, }, } data.update(overrides) return Level.build(LevelDef.model_validate(data)) def test_build_state_copies_the_orders_spec_as_scalars() -> None: state = build_state(make_level(), (SeatSpec("p0", 0, "A"),), 7, book()) assert state.order_pool == ("dish_a", "dish_b") assert state.order_concurrent == 3 assert state.order_spawn_min == 80 # 4 s / TICK_SECONDS assert state.order_spawn_max == 120 # 6 s / TICK_SECONDS assert state.order_deadline == 600 # 30 s / TICK_SECONDS assert state.order_penalty == 15 assert state.next_order_at == FIRST_ORDER_DELAY_TICKS assert state.next_order_id == 1 assert state.orders == () def test_a_round_built_from_a_level_spawns_through_the_kernel() -> None: state = build_state(make_level(), (SeatSpec("p0", 0, "A"),), 7, book()) for _ in range(FIRST_ORDER_DELAY_TICKS + 1): # the spawn is on tick 30 state, _ = step(state, STEP_DT, book=book()) assert len(state.orders) == 1 order = state.orders[0] assert order.value in (BASE_SCORES["dish_a"], BASE_SCORES["dish_b"]) assert order.deadline == FIRST_ORDER_DELAY_TICKS + 600 def test_the_kernel_is_loud_when_a_spawning_round_has_no_book() -> None: state = build_state(make_level(), (SeatSpec("p0", 0, "A"),), 7, book()) for _ in range(FIRST_ORDER_DELAY_TICKS): state, _ = step(state, STEP_DT) # the 29 ticks before the delay are book-free with pytest.raises(ValueError, match="recipe book"): step(state, STEP_DT) # tick 30 would spawn: a `None` book names itself def test_a_never_spawning_round_runs_book_free() -> None: state = make_state(pool=()) for _ in range(60): state, events = step(state, STEP_DT) # no book: the pool is empty assert events == () assert state.orders == () assert state.state_hash() # still a perfectly hashable round