"""`sim/scoring.py` — serve resolution and scoring: the window and the miss. The task's contract, pinned one rule at a time: * the five hand-computed serves — `round(base x quality x (1 + 0.1 x min(streak, 5)) x (1 + 0.5 x fraction))` — with the expected integers written as literals (the math is shown in the table's comment; the test re-derives nothing); * every rejection path (`burnt`, `no_target`, `no_orders`, `wrong_recipe`, `incomplete`) leaving the dish in the hand and the order intact — the state is the same object back; * FIFO matching: the *oldest* compatible live order (the smallest id — the rail order is deliberately reversed in the fixture to prove the match is by id, not by position), including an undeclared dish composing two recipes; * the streak cap (the multiplier never past `MAX_STREAK_MULT = 1.5`) and the score floor (`MIN_SCORE_FLOOR = 0` — the miss deduction never drives the score negative); * the per-player contribution summing to the round's score; * the wiring: the `SERVE` press through the whole `step()` (a scored press, a rejected press) and the miss through the kernel's injected `on_miss` (penalty + streak reset on the exact expiry tick, the floor, and the callback contract — without it, the rail's retirement leaves score and streak untouched). """ from __future__ import annotations from dataclasses import replace from typing import Any import pytest from undercooked.sim.grid import Grid from undercooked.sim.model import ( Event, EventKind, Facing, Item, ItemForm, ItemKind, KitchenState, Order, Player, Station, StationKind, TileKind, Vec2, ) from undercooked.sim.orders import step_orders from undercooked.sim.recipe import RecipeBook from undercooked.sim.scoring import ( MAX_STREAK_MULT, MIN_SCORE_FLOOR, Matched, Rejected, ServeReason, apply_miss, evaluate_serve, resolve_serve, streak_mult, time_bonus, ) from undercooked.sim.step import STEP_DT, PlayerInput, step pytestmark = pytest.mark.unit # --- fixtures --------------------------------------------------------------------------- def make_state( *, players: tuple[Player, ...] = (), orders: tuple[Order, ...] = (), tick: int = 0, streak: int = 0, score: int = 0, order_deadline: int = 120, order_penalty: int = 10, ) -> KitchenState: """A hand-built round: a bare floor, the order spec, nothing that would spawn (an empty pool, the due tick far off).""" return KitchenState( level_id="04_scoring", seed=7, grid=Grid(width=8, height=8, tiles=(TileKind.FLOOR,) * 64), tick=tick, stations=(), players=players, orders=orders, order_pool=(), order_concurrent=0, order_deadline=order_deadline, order_penalty=order_penalty, next_order_at=10**6, next_order_id=10_000, score=score, target_score=1000, time_left=90.0, streak=streak, counters={}, rng_state=7, ) def cook( player_id: str = "p0", *, seat: int = 0, carrying: Item | None = None, facing: Facing = Facing.UP, ) -> Player: return Player( id=player_id, pos=Vec2(5.5, 5.5), facing=facing, seat=seat, carrying=carrying, ) def order( order_id: int, recipe_id: str, value: int, time_left: float, *, tick: int = 0, deadline_ticks: int = 120, ) -> Order: return Order( id=order_id, recipe_id=recipe_id, time_left=time_left, deadline=float(tick + deadline_ticks), value=value, ) def dish( kind: ItemKind = ItemKind.TOMATO, form: ItemForm = ItemForm.PLATED, quality: float = 1.0, dish_id: str | None = "soup", ) -> Item: return Item(kind=kind, form=form, quality=quality, dish_id=dish_id) #: The recipe book the tests serve against: `soup` (a tomato, 40), `salad` #: (a lettuce and a tomato, 30), `burger` (a patty, 70). `ghost` is a recipe #: id no book knows — the `WRONG_RECIPE` fixture. RECIPES: dict[str, tuple[tuple[str, ...], int]] = { "soup": (("tomato",), 40), "salad": (("lettuce", "tomato"), 30), "burger": (("patty",), 70), } def book() -> RecipeBook: return RecipeBook.model_validate( { "recipes": { recipe_id: { "id": recipe_id, "name": recipe_id.title(), "base_score": base, "ingredients": [ {"kind": kind, "form": "raw"} for kind in kinds ], "steps": [{"kind": "serve", "station": "serve"}], } for recipe_id, (kinds, base) in RECIPES.items() } } ) def kinds(events: list[Event] | tuple[Event, ...]) -> list[EventKind]: return [EventKind(str(event.kind)) for event in events] # --- the hand-computed math (the task's five cases) -------------------------------------- # # The formula, for the reader: # # points = round(base x quality x (1 + 0.1 x min(streak + 1, 5)) # x (1 + 0.5 x fraction)) # # `streak` is the round's streak *before* the serve; `fraction` the share of # the 120-tick (6.0 s) deadline still unspent (`time_left = fraction x 6.0`). # The expected integers below are hand-computed against it and asserted as # literals — the test re-derives nothing. HAND_CHECKS: tuple[dict[str, Any], ...] = ( # base 40 x 1.0 x 1.1 (streak 1) x 1.5 (full deadline) = 66.0 { "name": "full_deadline_first_serve", "base": 40, "quality": 1.0, "streak": 0, "streak_after": 1, "fraction": 1.0, "time_bonus": 1.5, "points": 66, }, # 40 x 0.8 x 1.3 (streak 3) x 1.25 (half the deadline) = 52.0 { "name": "half_quality_half_deadline", "base": 40, "quality": 0.8, "streak": 2, "streak_after": 3, "fraction": 0.5, "time_bonus": 1.25, "points": 52, }, # 100 x 1.0 x 1.5 (streak 6 — the cap) x 1.0 (the edge) = 150.0 { "name": "streak_capped_at_the_edge", "base": 100, "quality": 1.0, "streak": 5, "streak_after": 6, "fraction": 0.0, "time_bonus": 1.0, "points": 150, }, # 80 x 0.5 x 1.1 (streak 1) x 1.4 (four-fifths of the deadline) = 61.6 { "name": "quarter_deadline_low_quality", "base": 80, "quality": 0.5, "streak": 0, "streak_after": 1, "fraction": 0.8, "time_bonus": 1.4, "points": 62, }, # 20 x 0.9 x 1.2 (streak 2) x 1.125 (a quarter of the deadline) = 24.3 { "name": "low_base_quarter_fraction", "base": 20, "quality": 0.9, "streak": 1, "streak_after": 2, "fraction": 0.25, "time_bonus": 1.125, "points": 24, }, ) @pytest.mark.parametrize("row", HAND_CHECKS, ids=lambda row: str(row["name"])) def test_the_hand_computed_points(row: dict[str, Any]) -> None: """One serve per row, the expected integer asserted as the literal. `order_deadline` is 120 ticks (6.0 s), so the order's `time_left` is `fraction x 6.0`; the order's value is the row's `base` (frozen at spawn in a real round — here, on the fixture's order). """ fraction = float(row["fraction"]) base = int(row["base"]) state = make_state( players=(cook("p0", carrying=dish(quality=float(row["quality"]))),), orders=(order(7, "soup", base, fraction * 6.0),), streak=int(row["streak"]), ) outcome = evaluate_serve(state, dish(quality=float(row["quality"])), book()) assert isinstance(outcome, Matched) assert outcome.order_id == 7 assert outcome.points == row["points"] assert outcome.quality == row["quality"] assert outcome.streak == row["streak_after"] assert outcome.time_bonus == pytest.approx(row["time_bonus"]) new_state, events = resolve_serve( state, state.players[0], dish(quality=float(row["quality"])), book() ) assert kinds(events) == [EventKind.SERVED, EventKind.SCORE] assert events[0].x == 5.5 and events[0].y == 5.5 # at the cook assert dict(events[0].payload) == { "order_id": 7, "points": row["points"], "quality": row["quality"], "streak": row["streak_after"], "player_id": "p0", } assert dict(events[1].payload) == { "delta": row["points"], "order_id": 7, "player_id": "p0", } assert new_state.score == row["points"] assert new_state.streak == row["streak_after"] assert new_state.orders == () # the matched order is off the rail served = new_state.players[0] assert served.carrying is None # the dish is consumed assert served.score == row["points"] assert served.dishes_served == 1 assert new_state.counters == { "served": 1, # the high-water mark the serve raised: max(0, the streak after the # increment) — the row's `streak_after` (task 05) "longest_streak": int(row["streak_after"]), } # the dirty props moved at use, not at serve (task 04's dirty loop) # --- the multiplier and the deadline share ---------------------------------------------- def test_the_streak_multiplier_is_capped_at_max_streak_mult() -> None: assert streak_mult(0) == 1.0 assert streak_mult(1) == pytest.approx(1.1) assert streak_mult(5) == MAX_STREAK_MULT assert streak_mult(6) == MAX_STREAK_MULT # the cap: a sixth is worth no more assert streak_mult(50) == MAX_STREAK_MULT def test_the_time_bonus_is_the_deadline_share_clamped() -> None: state = make_state(order_deadline=120) # 6.0 s assert time_bonus(order(1, "soup", 40, 6.0), state) == 1.5 # full deadline assert time_bonus(order(1, "soup", 40, 3.0), state) == pytest.approx(1.25) assert time_bonus(order(1, "soup", 40, 0.0), state) == 1.0 # the edge assert time_bonus(order(1, "soup", 40, 9.0), state) == 1.5 # clamped to 1 assert ( time_bonus(order(1, "soup", 40, 3.0), make_state(order_deadline=0)) == 1.0 ) # a non-positive span buys no bonus, not a division by zero # --- the rejection paths: the dish and the order stay intact ----------------------------- @pytest.mark.parametrize( ("reason", "expected", "dish_kwargs", "orders"), [ # a burnt dish (quality 0.0, still `PLATED`) is `BURNT` — never scored ( "a_burnt_dish", ServeReason.BURNT, {"quality": 0.0, "dish_id": "soup"}, (order(1, "soup", 40, 6.0),), ), # a burnt *form* is `BURNT` too ( "a_burnt_form", ServeReason.BURNT, {"form": ItemForm.BURNT, "quality": 0.0, "dish_id": "soup"}, (order(1, "soup", 40, 6.0),), ), # not a dish at all: a cooked ingredient is `NO_TARGET` ( "not_a_dish", ServeReason.NO_TARGET, {"form": ItemForm.COOKED, "dish_id": None}, (order(1, "soup", 40, 6.0),), ), # nothing on the rail: `NO_ORDERS` ( "no_orders", ServeReason.NO_ORDERS, {"dish_id": "soup"}, (), ), # a declared recipe the book does not know: `WRONG_RECIPE` ( "an_unknown_recipe", ServeReason.WRONG_RECIPE, {"dish_id": "ghost"}, (order(1, "soup", 40, 6.0),), ), # an undeclared dish whose ingredient no recipe requires: `WRONG_RECIPE` ( "no_recipe_composes_the_ingredient", ServeReason.WRONG_RECIPE, {"kind": ItemKind.FISH, "dish_id": None}, (order(1, "soup", 40, 6.0),), ), # a declared recipe the book knows, but no live order names it: `WRONG_RECIPE` ( "no_live_order_for_the_recipe", ServeReason.WRONG_RECIPE, {"kind": ItemKind.PATTY, "dish_id": "burger"}, (order(1, "soup", 40, 6.0),), ), # the dish's ingredient is not one the declared recipe requires: `INCOMPLETE` ( "an_incomplete_dish", ServeReason.INCOMPLETE, {"kind": ItemKind.LETTUCE, "dish_id": "soup"}, (order(1, "soup", 40, 6.0),), ), ], ids=lambda value: str(value) if isinstance(value, str) else "", ) def test_a_rejected_serve_leaves_the_dish_and_the_order_intact( reason: str, expected: ServeReason, dish_kwargs: dict[str, Any], orders: tuple[Order, ...], ) -> None: the_dish = dish(**dish_kwargs) state = make_state(players=(cook("p0", carrying=the_dish),), orders=orders, score=15) assert evaluate_serve(state, the_dish, book()) == Rejected(expected) new_state, events = resolve_serve(state, state.players[0], the_dish, book()) assert new_state is state # the state is the same object back assert kinds(events) == [EventKind.DENIED] assert dict(events[0].payload) == {"player_id": "p0", "reason": expected.value, "verb": "serve"} # the dish stays in the hand; the rail, the score and the streak are untouched assert state.players[0].carrying is the_dish assert state.orders == orders assert state.score == 15 assert state.streak == 0 # --- FIFO matching ---------------------------------------------------------------------- def test_the_oldest_compatible_order_is_the_one_served() -> None: """Two compatible orders are live (ids 9 and 5 — the *rail order* has the newer first, on purpose): the match is the smallest id, and the other stays on the rail.""" younger = order(9, "soup", 40, 3.0) older = order(5, "soup", 40, 6.0) state = make_state( players=(cook("p0", carrying=dish()),), orders=(younger, older), # the newer one first, on purpose ) outcome = evaluate_serve(state, dish(), book()) assert isinstance(outcome, Matched) assert outcome.order_id == 5 # the oldest (smallest id), not the first in the rail new_state, _events = resolve_serve(state, state.players[0], dish(), book()) assert [o.id for o in new_state.orders] == [9] # the other stays on the rail def test_the_match_is_by_recipe_not_by_rail_position() -> None: """A `salad` order (id 3) and a `soup` order (id 5) are live; the dish is `soup`. The match is id 5 — the `salad` order is untouched.""" state = make_state( players=(cook("p0", carrying=dish()),), orders=(order(3, "salad", 30, 6.0), order(5, "soup", 40, 6.0)), ) outcome = evaluate_serve(state, dish(), book()) assert isinstance(outcome, Matched) assert outcome.order_id == 5 new_state, _events = resolve_serve(state, state.players[0], dish(), book()) assert [o.id for o in new_state.orders] == [3] def test_an_undeclared_dish_composes_every_recipe_that_requires_its_ingredient() -> None: """`soup` (a tomato) and `salad` (a lettuce *and* a tomato) both require a tomato: an undeclared tomato dish composes both, and the oldest live order among them — the `salad` (id 3) — is the one served.""" state = make_state( players=(cook("p0", carrying=dish(dish_id=None)),), orders=(order(7, "soup", 40, 6.0), order(3, "salad", 30, 6.0)), ) outcome = evaluate_serve(state, dish(dish_id=None), book()) assert isinstance(outcome, Matched) assert outcome.order_id == 3 # the salad — the older of the two it composes new_state, _events = resolve_serve(state, state.players[0], dish(dish_id=None), book()) assert [o.id for o in new_state.orders] == [7] def test_an_undeclared_dish_falls_to_a_single_composing_recipe() -> None: """With only the `soup` order live, the same undeclared tomato dish fills it (its one candidate).""" state = make_state( players=(cook("p0", carrying=dish(dish_id=None)),), orders=(order(7, "soup", 40, 6.0),), ) outcome = evaluate_serve(state, dish(dish_id=None), book()) assert isinstance(outcome, Matched) assert outcome.order_id == 7 # --- the caps and the floors ------------------------------------------------------------ def test_the_streak_cap_holds_a_forty_ninth_serve() -> None: """A streak of 49 (forty-ninth consecutive serve): the multiplier is the cap — `100 x 1.0 x 1.5 x 1.0 = 150`, the same as the sixth.""" state = make_state( players=(cook("p0", carrying=dish()),), orders=(order(1, "soup", 100, 0.0),), streak=49, ) outcome = evaluate_serve(state, dish(), book()) assert isinstance(outcome, Matched) assert outcome.streak == 50 assert outcome.points == 150 # the cap: no more than the sixth serve's 1.5 def test_the_miss_floors_the_score_at_min_score_floor() -> None: """A miss on a score of 5 with a penalty of 10: the score floors at 0, the applied delta is -5 (not -10), the streak resets, `SCORE` carries it.""" state = make_state(score=5, streak=4, order_penalty=10) new_state, events = apply_miss(state, order(1, "soup", 40, 0.0)) assert new_state.score == MIN_SCORE_FLOOR assert new_state.streak == 0 assert kinds(events) == [EventKind.SCORE] assert dict(events[0].payload) == {"delta": -5, "order_id": 1} assert events[0].x == 0.0 and events[0].y == 0.0 # at the rail — it is HUD, not floor def test_the_miss_deducts_the_penalty_and_resets_the_streak() -> None: state = make_state(score=30, streak=4, order_penalty=10) new_state, events = apply_miss(state, order(1, "soup", 40, 0.0)) assert new_state.score == 20 assert new_state.streak == 0 assert kinds(events) == [EventKind.SCORE] assert dict(events[0].payload) == {"delta": -10, "order_id": 1} def test_a_miss_that_changes_nothing_changes_nothing() -> None: """No penalty configured and no streak to reset: the state is the same object back and no `SCORE` fires — the `ORDER_MISSED` names it.""" state = make_state(score=5, streak=0, order_penalty=0) new_state, events = apply_miss(state, order(1, "soup", 40, 0.0)) assert new_state is state assert events == () def test_a_zero_penalty_miss_still_resets_the_streak() -> None: state = make_state(score=5, streak=3, order_penalty=0) new_state, events = apply_miss(state, order(1, "soup", 40, 0.0)) assert new_state.score == 5 assert new_state.streak == 0 assert kinds(events) == [EventKind.SCORE] assert dict(events[0].payload) == {"delta": 0, "order_id": 1} # --- the per-player contribution --------------------------------------------------------- def test_the_per_player_contribution_sums_to_the_round_score() -> None: """Two cooks, three serves (p0, p1, p0): 40 x 1.0 x 1.1 x 1.5 = 66, 40 x 1.0 x 1.2 x 1.5 = 72, 40 x 1.0 x 1.3 x 1.5 = 78 — the round's 216 is the cooks' 144 + 72, and the `dishes_served` sum is the `served` counter.""" state = make_state( players=(cook("p0"), cook("p1", seat=1)), orders=( order(1, "soup", 40, 6.0), order(2, "soup", 40, 6.0), order(3, "soup", 40, 6.0), ), ) sequence = ("p0", "p1", "p0") for player_id in sequence: cook_index = 0 if player_id == "p0" else 1 state, _events = resolve_serve( state, state.players[cook_index], dish(), book() ) assert state.score == 216 # 66 + 72 + 78, hand-computed assert state.streak == 3 assert sum(p.score for p in state.players) == state.score assert [p.score for p in state.players] == [144, 72] # 66 + 78, then 72 assert sum(p.dishes_served for p in state.players) == state.counters["served"] == 3 # The dirty props are not moved by the serve (task 04): they left the # clean stock when the work that made each dish started, and this test # serves pre-plate dishes, so no stock counter is touched at all. assert state.orders == () # --- the named caller bugs ---------------------------------------------------------------- def test_a_bookless_serve_is_the_named_value_error() -> None: state = make_state(players=(cook("p0", carrying=dish()),)) with pytest.raises(ValueError, match="recipe book"): evaluate_serve(state, dish(), None) with pytest.raises(ValueError, match="recipe book"): resolve_serve(state, state.players[0], dish(), None) def test_a_serve_by_a_cook_the_state_does_not_have_is_the_named_value_error() -> None: state = make_state(orders=(order(1, "soup", 40, 6.0),)) stranger = cook("ghost") with pytest.raises(ValueError, match="not in the state"): resolve_serve(state, stranger, dish(), book()) # --- the wiring: the kernel delivers the scoring ------------------------------------------ def _serve_kitchen( *, carrying: Item, orders: tuple[Order, ...], tick: int = 0, score: int = 0, streak: int = 0, order_penalty: int = 10, order_deadline: int = 120, ) -> KitchenState: """The integration kitchen: a serve window at (5, 4), the cook at (5, 5) facing it, the order spec, nothing that would spawn.""" window = Station(id="serve-5-4", kind=StationKind.SERVE, x=5, y=4) return replace( make_state( players=(cook("p0", carrying=carrying),), orders=orders, tick=tick, score=score, streak=streak, order_penalty=order_penalty, order_deadline=order_deadline, ), stations=(window,), ) def test_the_kernel_scores_a_serve_press_at_the_window() -> None: """Through the whole `step()`: the press at the window is the scoring's — `SERVED` then `SCORE`, 40 x 1.0 x 1.1 x 1.5 = 66 (the full deadline), the order off the rail, the hand empty, the counters paid.""" state = _serve_kitchen( carrying=dish(), orders=(order(1, "soup", 40, 6.0),), ) new_state, events = step( state, STEP_DT, (PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0),), book(), ) assert kinds(events) == [EventKind.SERVED, EventKind.SCORE] assert dict(events[0].payload)["points"] == 66 assert dict(events[0].payload)["streak"] == 1 assert dict(events[1].payload)["delta"] == 66 assert new_state.score == 66 assert new_state.streak == 1 assert new_state.orders == () assert new_state.players[0].carrying is None assert new_state.players[0].dishes_served == 1 assert new_state.counters["served"] == 1 # The dirty props are not moved by the serve (task 04): the plate left # the clean stock when the `PLATE_DISH` work started, and this press # serves a pre-plate dish, so no stock counter is touched here. assert new_state.orders == () def test_the_kernel_rejects_a_serve_press_with_the_dish_and_order_intact() -> None: """Through the whole `step()`: an `INCOMPLETE` dish (a lettuce at a `soup` order) is one `DENIED` with the reason, the dish stays in the hand and the order stays on the rail (its clock advances with the substep, as usual — the order itself is untouched).""" bad_dish = dish(kind=ItemKind.LETTUCE, dish_id="soup") state = _serve_kitchen( carrying=bad_dish, orders=(order(1, "soup", 40, 6.0),), score=25, streak=2, ) new_state, events = step( state, STEP_DT, (PlayerInput(player_id="p0", seq=0, action=True, received_at_tick=0),), book(), ) assert kinds(events) == [EventKind.DENIED] assert dict(events[0].payload)["reason"] == "incomplete" assert new_state.players[0].carrying == bad_dish # the dish stays in the hand assert [o.id for o in new_state.orders] == [1] # the order stays on the rail assert new_state.score == 25 # untouched assert new_state.streak == 2 # untouched def test_the_kernel_applies_the_miss_penalty_and_streak_reset_on_the_expiry_tick() -> None: """Through the whole `step()`: an order whose deadline is the state's tick expires on this substep — `ORDER_MISSED`, then the injected `apply_miss`' `SCORE`: score 30 - 10 = 20, the streak 4 → 0, `counters["missed"]` 1.""" state = _serve_kitchen( carrying=dish(), orders=(order(1, "soup", 40, 0.0, tick=100, deadline_ticks=0),), tick=100, score=30, streak=4, ) new_state, events = step(state, STEP_DT, (), book()) assert kinds(events) == [EventKind.ORDER_MISSED, EventKind.SCORE] assert dict(events[0].payload) == {"id": 1, "r": "soup", "tick": 100} assert dict(events[1].payload) == {"delta": -10, "order_id": 1} assert new_state.score == 20 assert new_state.streak == 0 assert new_state.counters["missed"] == 1 assert new_state.orders == () def test_the_kernel_floors_the_miss_deduction_at_zero() -> None: """A miss on a score of 5: the applied delta is -5 (the floor), not -10.""" state = _serve_kitchen( carrying=dish(), orders=(order(1, "soup", 40, 0.0, tick=100, deadline_ticks=0),), tick=100, score=5, streak=4, ) new_state, events = step(state, STEP_DT, (), book()) assert kinds(events) == [EventKind.ORDER_MISSED, EventKind.SCORE] assert dict(events[1].payload)["delta"] == -5 assert new_state.score == 0 assert new_state.streak == 0 def test_step_orders_without_the_callback_leaves_score_and_streak_untouched() -> None: """The injection contract: a direct `step_orders` call without `on_miss` retires the order (the counter, the `ORDER_MISSED`) but leaves `score` and `streak` where they were — the scoring half is the callback's to apply.""" state = make_state( orders=(order(1, "soup", 40, 0.0, tick=100, deadline_ticks=0),), tick=100, score=30, streak=4, ) without, events = step_orders(state, STEP_DT, book()) assert kinds(events) == [EventKind.ORDER_MISSED] assert without.score == 30 # untouched without the callback assert without.streak == 4 assert without.counters["missed"] == 1 with_callback, events = step_orders(state, STEP_DT, book(), on_miss=apply_miss) assert kinds(events) == [EventKind.ORDER_MISSED, EventKind.SCORE] assert with_callback.score == 20 assert with_callback.streak == 0