"""`sim/scoring.py` — 500 seeded random serves: the invariants. Property-style, the task's contract: a fixed seed drives 500 random dishes against one threaded round (500 live `soup` orders, a random quality, form and ingredient kind on each dish), and every serve re-checks: * points are always ≥ 0 (and never past the cap bound `round(value x 1.0 x MAX_STREAK_MULT x 1.5)` — the streak multiplier never exceeds its cap, and quality is in `(0, 1]` by the time the math runs); * the `served` counter equals the orders removed (one less on the rail per matched serve, the counters and the rail agree, and a rejected serve removes nothing — the state is the same object back, the dish stays in the hand); * no order is served twice (the matched ids stay unique across the run); * the per-player contribution sums to the round's score as it grows. Two runs of the same seed end in identical state hashes (the A21 determinism contract, in the shape the task asks for: a fixed seed, 500 serves). """ from __future__ import annotations from dataclasses import replace from typing import Any from undercooked.sim.grid import Grid from undercooked.sim.model import ( EventKind, Item, ItemForm, ItemKind, KitchenState, Order, Player, TileKind, ) from undercooked.sim.recipe import RecipeBook from undercooked.sim.rng import Rng from undercooked.sim.scoring import MAX_STREAK_MULT, resolve_serve #: The seed the 500 serves run on (fixed — the property is reproducible). SEED = 0x5C0B # "scor" in hex, on purpose #: The run length: the task's 500 random serves. SERVES = 500 #: The round's deadline span in ticks (the orders' `time_left` is a random #: share of `DEADLINE_TICKS x 0.05 s`). DEADLINE_TICKS = 120 #: The value range the orders draw from (20 + k x 10, k in 0..4 — the cap #: bound below is measured against the top of it). VALUES: tuple[int, ...] = (20, 30, 40, 50, 60) 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 _round() -> KitchenState: """One round: `SERVES` live `soup` orders (ids 1..500, a random value and a random share of the deadline) and one cook at (5.5, 5.5).""" rng = Rng(SEED) orders = tuple( Order( id=number + 1, recipe_id="soup", time_left=rng.range_int(1, DEADLINE_TICKS) * 0.05, deadline=float(DEADLINE_TICKS), value=VALUES[rng.range_int(0, len(VALUES) - 1)], ) for number in range(SERVES) ) return KitchenState( level_id="04_scoring_property", seed=SEED, grid=Grid(width=8, height=8, tiles=(TileKind.FLOOR,) * 64), tick=0, players=(Player(id="p0", seat=0),), orders=orders, order_pool=(), order_concurrent=0, order_deadline=DEADLINE_TICKS, order_penalty=0, next_order_at=10**9, score=0, target_score=10**9, time_left=3600.0, rng_state=SEED, ) def _random_dish(rng: Rng) -> Item: """One random dish: an ingredient kind (usually a tomato — the one `soup` requires, occasionally a lettuce — the one it does not), a form (usually `PLATED`, occasionally a cooked ingredient or a burnt one), and a quality (a uniform share of the 1.0 → 0.0 scale; 0.0 for the burnt shapes).""" kind = ItemKind.TOMATO if rng.next_u64() % 5 else ItemKind.LETTUCE shape = rng.range_int(0, 9) if shape < 8: form, quality = ItemForm.PLATED, 0.01 + rng.next_float() * 0.99 elif shape == 8: form, quality = ItemForm.COOKED, 0.01 + rng.next_float() * 0.99 else: form, quality = ItemForm.BURNT, 0.0 return Item(kind=kind, form=form, quality=quality, dish_id=None) def _run() -> tuple[str, int]: """The 500 serves: `(final state hash, matched serves)`. The invariants are asserted inside — a violation fails the run where it happens, with the iteration named. """ state = _round() rng = Rng(SEED + 1) # a stream of its own — the state's stays the state's served_ids: list[int] = [] points_total = 0 for iteration in range(SERVES): dish = _random_dish(rng) cook = replace(state.players[0], carrying=dish) state = replace(state, players=(cook,)) before = len(state.orders) state, events = resolve_serve(state, state.players[0], dish, book()) served = [e for e in events if e.kind is EventKind.SERVED] denied = [e for e in events if e.kind is EventKind.DENIED] assert len(served) + len(denied) == 1, f"iter {iteration}: one effect per serve" if served: assert len(events) == 2, f"iter {iteration}: `SERVED` then `SCORE`" payload: dict[str, Any] = dict(served[0].payload) points = int(payload["points"]) order_id = int(payload["order_id"]) # invariant 1: points are ≥ 0 and never past the cap bound assert points >= 0, f"iter {iteration}: points {points} < 0" bound = round(VALUES[-1] * 1.0 * MAX_STREAK_MULT * 1.5) assert points <= bound, ( f"iter {iteration}: points {points} past the cap bound {bound}" ) # invariant 2: the `served` counter equals the orders removed assert len(state.orders) == before - 1, f"iter {iteration}: one order off the rail" assert state.counters["served"] == len(served_ids) + 1, ( f"iter {iteration}: the `served` counter disagrees with the rail" ) # invariant 3: no order is served twice assert order_id not in served_ids, f"iter {iteration}: order {order_id} served twice" served_ids.append(order_id) points_total += points # invariant 4: the contribution sums to the round's score as it grows assert state.score == points_total, ( f"iter {iteration}: the score disagrees with its points" ) assert state.players[0].score == points_total assert state.players[0].dishes_served == len(served_ids) assert state.players[0].carrying is None, ( f"iter {iteration}: the dish is consumed" ) assert 0.0 < float(payload["quality"]) <= 1.0, ( f"iter {iteration}: the quality is in (0, 1]" ) assert int(payload["streak"]) == len(served_ids), ( f"iter {iteration}: the streak is the serves so far" ) assert int(payload["streak"]) >= 1 mult = 1.0 + 0.1 * min(int(payload["streak"]), 5) assert mult <= MAX_STREAK_MULT + 1e-9, f"iter {iteration}: the multiplier is capped" else: # a rejection: the state is the same object back, the dish stays in # the hand, the rail is untouched assert len(state.orders) == before, f"iter {iteration}: a rejection removes nothing" assert state.players[0].carrying is dish, ( f"iter {iteration}: the dish stays in the hand" ) assert state.score >= 0 assert state.counters["served"] == len(served_ids) return state.state_hash(), len(served_ids) def test_fifty_seeded_random_serves_hold_the_invariants() -> None: final_hash, matched = _run() # the run must have matched at least one and rejected at least one — the # two paths were both exercised, not just the lucky half. assert 0 < matched < SERVES assert final_hash # a non-empty, deterministic digest def test_two_runs_of_the_same_seed_end_identical() -> None: """The A21 contract in the task's shape: same seed, 500 serves — the second run's final hash is the first's.""" first_hash, matched = _run() second_hash, matched_again = _run() assert (first_hash, matched) == (second_hash, matched_again)