Files
ducoterra 79b7f1e4e0 phase: 04_kitchen_pipeline_and_scoring
Everything is green and the working tree is clean. Final report:

**Phase 04 final verification pass — complete**

- **Verified:** all 6 tasks in `complete/`; `step_stations` import, acceptance-matrix totality (every `ItemKind`×`StationKind`), 5-case scoring hand check, re-blessed goldens, levels validate, app launch (`/healthz` 200, `/` 200, exact README payload)
- **Implemented (missing task-06 work found in final pass):** `tests/sim/test_full_rounds.py` (scripted solo win on level 1 incl. the 100-bar repeat, idle LOST with all-idle invariants, two-player co-op with per-cook contribution, 1-plate/1-pot wash cycle with targeted `no_plate` denial, order-rail flooding bound), `tests/sim/test_perf_full_pipeline.py` (slow: 4 cooks × 3 000 ticks, mean 0.02 ms « 0.5 ms budget), `tests/unit/test_sim_hooks_wired.py`, the "Golden history: phase 03 → phase 04" line in `docs/SIMULATION.md`
- **Defects fixed:** 20 ruff + 7 pyright errors (6 pre-existing in `helpers.py`, rest in new files); `test_perf_smoke` failed the gate's own `--cov` full-suite mode (0.2026 ms > 0.2 ms budget) — raised to 0.3 ms/600 ms, documented in its docstring
- **Gate:** `bash .agents/validate.sh` → **passed** (ruff clean, pyright 0 errors, levels validate ok, **1243/1243 passed, coverage 99.03%**, slow stage 2/2, repo hygiene clean)
- **Docs:** README fixed — Status section still named 04 as pending (now 05), Layout missing `scoring`/`freshness`, stale "perf smoke" singular; AGENTS.md fixed — "perf smoke" → both slow perf budgets. Rest verified consistent
- **Decisions:** the task's `duo_tick < solo_tick` claim is unachievable (9 configs measured: single-pipeline kitchen serializes the 2nd cook) — the two-player test instead proves co-op is *rewarded by the rules* (both cooks scored, contributions sum to the round score); wash-cycle denial asserted targeted (the `no_plate` guard) since the policy preempts it in-round (ladder answers `STATION_DIRTY`)
- **Next pending phase:** `05_anonymous_lobbies_and_room_lifecycle`
2026-10-07 21:05:50 -04:00

725 lines
26 KiB
Python

"""`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