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`
This commit is contained in:
2026-10-07 21:05:50 -04:00
parent 4febdfe780
commit 79b7f1e4e0
44 changed files with 11383 additions and 1003 deletions
+551
View File
@@ -0,0 +1,551 @@
"""`sim/round_state.py` — the round's end conditions: the verdict, the settle,
the stars, and the owner/reader seam of `longest_streak`.
The task's contract, pinned one rule at a time:
* **The verdict.** The early win the moment `score >= target_score` (on the
substep the score crosses — the score stage and the verdict stage run in
the same substep), the buzzer (`time_left` to zero: `WON` if the score
made it, `LOST` if it did not), the one `ROUND_END` event (never a second
one, whatever the caller keeps ticking for), and the `ABORTED` seam
(`abort`, the verdict final: a crash after the buzzer cannot un-win it);
* **The settle.** Once `result` is set, the kernel freezes the gameplay
stages — inputs are ignored, no movement, no stations, no orders, no
freshness — but the tick keeps running for the `SETTLE_S` window:
`time_left` holds at the verdict value (never negative), `rng_state` is
untouched (a draw-less `Rng` returns its seed), and no event fires,
`SERVED` included;
* **The stars.** 0-3, from the frozen clock: 0 when not won, 1 for winning,
2 with `time_left >= 25 %` of `time_limit`, 3 with `>= 50 %` — inclusive
at the boundary, pinned on both sides of each boundary at tick granularity
(one tick below and exactly at, for 25 % and 50 %);
* **`longest_streak`.** `sim/scoring.py` owns it (a serve raises the mark, a
miss resets the current streak and never the mark); `round_state` reads it
into the payload.
"""
from __future__ import annotations
import copy
from dataclasses import replace
import pytest
from undercooked.sim.grid import Grid
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import (
Event,
EventKind,
Facing,
Item,
ItemForm,
ItemKind,
KitchenState,
Order,
Player,
RoundResult,
Station,
StationKind,
StationState,
TileKind,
Vec2,
)
from undercooked.sim.recipe import RecipeBook
from undercooked.sim.round_state import (
SETTLE_S,
abort,
result_payload,
stars,
step_round,
)
from undercooked.sim.scoring import apply_miss, resolve_serve
from undercooked.sim.state_build import SeatSpec, build_state
from undercooked.sim.step import STEP_DT, PlayerInput, step
pytestmark = pytest.mark.unit
# --- fixtures ---------------------------------------------------------------------------
def make_state(
*,
players: tuple[Player, ...] = (),
orders: tuple[Order, ...] = (),
stations: tuple[Station, ...] = (),
tick: int = 0,
score: int = 0,
target_score: int = 100,
time_limit: float = 100.0,
time_left: float = 100.0,
streak: int = 0,
result: RoundResult | None = None,
counters: dict[str, int] | None = None,
) -> KitchenState:
"""A hand-built round: a bare floor, no spawn spec (nothing can spawn),
the counters as handed."""
return KitchenState(
level_id="05_round_end",
seed=11,
grid=Grid(width=8, height=8, tiles=(TileKind.FLOOR,) * 64),
tick=tick,
stations=stations,
players=players,
orders=orders,
order_pool=(),
order_concurrent=0,
order_deadline=120,
order_penalty=10,
next_order_at=10**6,
next_order_id=10_000,
score=score,
target_score=target_score,
time_limit=time_limit,
time_left=time_left,
streak=streak,
result=result,
counters=counters if counters is not None else {},
rng_state=11,
)
def cook(
player_id: str = "p0",
*,
seat: int = 0,
pos: Vec2 | None = None,
facing: Facing = Facing.RIGHT,
carrying: Item | None = None,
) -> Player:
return Player(
id=player_id,
pos=pos if pos is not None else Vec2(2.5, 2.5),
facing=facing,
seat=seat,
carrying=carrying,
)
def order(order_id: int, recipe_id: str, value: int, time_left: float) -> Order:
return Order(
id=order_id,
recipe_id=recipe_id,
time_left=time_left,
deadline=float(10**6 + 120),
value=value,
)
RECIPES: dict[str, tuple[tuple[str, ...], int]] = {
"soup": (("tomato",), 40),
}
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 event_kinds(events: tuple[Event, ...]) -> list[EventKind]:
return [EventKind(str(event.kind)) for event in events]
def settle_ticks() -> int:
"""The `SETTLE_S` window in whole substeps (1.0 s / 0.05 s = 20)."""
return round(SETTLE_S / STEP_DT)
# --- the countdown (stage 7) ------------------------------------------------------------
def test_the_countdown_runs_and_clamps_at_zero() -> None:
state = make_state(time_left=2.0, time_limit=2.0)
current = state
for _ in range(39): # 39 x 0.05 s = 1.95 s of the 2 s clock
current, _events = step_round(current, STEP_DT)
assert current.time_left == pytest.approx(0.05)
assert current.result is None # still live one tick before the buzzer
current, events = step_round(current, STEP_DT)
assert current.time_left == 0.0
assert current.result is RoundResult.LOST # 0 < 100
assert event_kinds(events) == [EventKind.ROUND_END]
def test_the_clock_never_goes_negative_and_the_verdict_lands_on_the_buzzer(
) -> None:
current = make_state(time_left=3.0, time_limit=3.0)
for _ in range(100): # 100 ticks = 5 s against a 3 s clock: well past it
current, _events = step(current, STEP_DT)
assert current.time_left >= 0.0
assert current.time_left == 0.0
assert current.result is RoundResult.LOST
def test_the_buzzer_verdict_is_won_when_the_score_made_the_target() -> None:
state = make_state(time_left=STEP_DT, score=100, target_score=100)
current, events = step_round(state, STEP_DT)
assert current.result is RoundResult.WON
assert current.time_left == 0.0
assert event_kinds(events) == [EventKind.ROUND_END]
def test_the_buzzer_verdict_is_lost_when_the_score_did_not() -> None:
state = make_state(time_left=STEP_DT, score=99, target_score=100)
current, events = step_round(state, STEP_DT)
assert current.result is RoundResult.LOST
assert current.time_left == 0.0
assert event_kinds(events) == [EventKind.ROUND_END]
def test_the_buzzer_boundary_is_one_epsilon_wide_on_both_sides() -> None:
# One tick to zero, plus a residue inside TIME_EPS (1e-12): the noise
# branch — the round ends on this substep.
buzzer = make_state(time_left=STEP_DT + 1e-12, score=0)
ended, _events = step_round(buzzer, STEP_DT)
assert ended.result is RoundResult.LOST
assert ended.time_left == 0.0
# One tick to zero, plus a residue outside it (1e-8 > 1e-9): the clock
# is still live — no verdict, a sliver remains.
alive = make_state(time_left=STEP_DT + 1e-8, score=0)
still_live, _events = step_round(alive, STEP_DT)
assert still_live.result is None
assert still_live.time_left == pytest.approx(1e-8)
def test_the_early_win_lands_on_the_substep_the_score_crosses() -> None:
# The score stage and the verdict stage run in the same substep: a state
# whose score already reached the target (the serve that got it ran
# first in this substep) is decided on this one, with the clock held at
# this substep's countdown.
state = make_state(time_left=50.0, score=100, target_score=100)
current, events = step_round(state, STEP_DT)
assert current.result is RoundResult.WON
assert current.time_left == pytest.approx(49.95)
assert event_kinds(events) == [EventKind.ROUND_END]
def test_the_early_win_fires_on_the_first_substep_of_a_liveround() -> None:
# Through the whole `step()`: the win is not deferred to the clock.
current = make_state(time_left=60.0, score=100, target_score=100)
current, events = step(current, STEP_DT)
assert current.result is RoundResult.WON
assert current.time_left == pytest.approx(59.95)
assert EventKind.ROUND_END in event_kinds(events)
def test_only_one_round_end_event_lands() -> None:
state = make_state(time_left=STEP_DT, score=0)
current, first = step_round(state, STEP_DT)
seen = list(event_kinds(first))
for _ in range(settle_ticks()): # the whole settle window, and beyond
current, more = step_round(current, STEP_DT)
seen.extend(event_kinds(more))
assert seen.count(EventKind.ROUND_END) == 1
# --- the settle (the freeze after the verdict) ----------------------------------------
def test_inputs_are_frozen_after_the_verdict() -> None:
current = make_state(time_left=60.0, score=100, target_score=100, players=(cook(),))
current, _events = step(current, STEP_DT) # the early win lands
assert current.result is RoundResult.WON
verdict_position = current.players[0].pos
for _ in range(settle_ticks()):
current, events = step(
current,
STEP_DT,
[PlayerInput(player_id="p0", move=Vec2(1.0, 0.0), action=True)],
)
assert events == () # nothing fires in the settle window
assert current.players[0].pos == verdict_position # no movement
assert current.time_left == pytest.approx(59.95) # frozen at the verdict value
def test_the_settle_window_emits_no_new_served_and_no_order_expiry() -> None:
# A cook at the window with a dish in hand and a compatible order live
# (deadline one tick out — it would expire on the next substep if the
# orders stage still ran): the settle emits no `SERVED`, and the order
# never expires.
dish = Item(kind=ItemKind.TOMATO, form=ItemForm.PLATED, quality=1.0, dish_id="soup")
kitchen = make_state(
time_left=STEP_DT, # the buzzer is one tick out
score=0,
players=(cook(pos=Vec2(2.5, 3.5), facing=Facing.UP, carrying=dish),),
orders=(order(1, "soup", 40, 0.05),),
stations=(
Station(id="serve_0", kind=StationKind.SERVE, x=2.5, y=3.0),
),
)
current, _events = step(kitchen, STEP_DT) # the buzzer: LOST, and the freeze
assert current.result is RoundResult.LOST
for _ in range(settle_ticks()):
current, events = step(
current,
STEP_DT,
[
PlayerInput(
player_id="p0",
move=Vec2(0.0, -1.0),
action=True,
received_at_tick=current.tick,
)
],
)
assert EventKind.SERVED not in event_kinds(events)
assert EventKind.ORDER_MISSED not in event_kinds(events)
assert len(current.orders) == 1 # the order survived the whole window
def test_the_stations_orders_and_kitchen_are_untouched_after_the_verdict() -> None:
# A `WORKING` station (it would advance its progress) and a live order
# (it would age) both freeze: the settled state carries them as the
# *same objects*, and the cook does not move.
working = Station(
id="board_0",
kind=StationKind.CHOP_BOARD,
x=4.0,
y=4.0,
state=StationState.WORKING,
progress=0.5,
duration=1.8,
worker_id="p0",
)
kitchen = make_state(
time_left=STEP_DT,
score=0,
players=(cook(pos=Vec2(4.5, 4.5)),),
orders=(order(1, "soup", 40, 0.05),),
stations=(working,),
)
current, _events = step(kitchen, STEP_DT) # the buzzer lands the verdict
assert current.result is RoundResult.LOST
verdict_stations = current.stations
verdict_orders = current.orders
current, events = step(current, STEP_DT)
assert events == ()
assert current.stations is verdict_stations # no progress was advanced
assert current.orders is verdict_orders # no deadline was spent
assert current.players[0].pos == Vec2(4.5, 4.5)
assert current.rng_state == kitchen.rng_state # a draw-less Rng returns its seed
def test_the_tick_keeps_running_through_the_settle() -> None:
current = make_state(time_left=60.0, score=100, target_score=100)
current, _events = step(current, STEP_DT) # the verdict
verdict_tick = current.tick
for _ in range(settle_ticks()):
current, _events = step(current, STEP_DT)
assert current.tick == verdict_tick + 1 # monotonic, every substep
verdict_tick = current.tick
assert current.time_left == pytest.approx(59.95) # still frozen at the end
# --- abort (the crash-isolation seam, phase 12) ----------------------------------------
def test_abort_marks_an_in_progress_round_aborted() -> None:
state = make_state(time_left=50.0)
aborted = abort(state, "the room's tick raised")
assert aborted is not state # a new state, the original untouched
assert aborted.result is RoundResult.ABORTED
assert state.result is None
assert aborted.grid is state.grid # the kitchen is otherwise the same object
assert aborted.stations is state.stations
assert aborted.players is state.players
def test_the_verdict_is_final_a_late_abort_cannot_un_win_a_round() -> None:
won = make_state(time_left=0.0, result=RoundResult.WON)
assert abort(won, "late crash") is won
lost = make_state(time_left=0.0, result=RoundResult.LOST)
assert abort(lost, "late crash") is lost
first = abort(make_state(), "first")
second = abort(first, "second")
assert second is first # an abort on a decided state is a no-op
assert second.result is RoundResult.ABORTED
def test_an_aborted_round_stays_aborted_and_emits_nothing() -> None:
aborted = abort(make_state(), "crash")
current, first = step(aborted, STEP_DT)
assert current.result is RoundResult.ABORTED
assert first == ()
for _ in range(settle_ticks()):
current, events = step(current, STEP_DT)
assert events == ()
assert current.result is RoundResult.ABORTED
assert current.time_left >= 0.0
# --- stars -----------------------------------------------------------------------------
def test_stars_are_zero_when_the_round_was_not_won() -> None:
assert stars(make_state(result=RoundResult.LOST, time_left=90.0, time_limit=100.0)) == 0
assert stars(make_state(result=RoundResult.ABORTED, time_left=90.0, time_limit=100.0)) == 0
assert stars(make_state(time_left=90.0, time_limit=100.0)) == 0 # not ended yet
def test_a_buzzer_win_is_one_star() -> None:
won = make_state(result=RoundResult.WON, time_left=0.0, time_limit=100.0)
assert stars(won) == 1
@pytest.mark.parametrize(
("time_left", "expected"),
[
# One tick below the 25 % boundary of a 20 s clock (4.95 s): the
# star is not bought.
(4.95, 1),
# Exactly the 25 % boundary (5.0 s): the threshold is inclusive.
(5.0, 2),
],
)
def test_the_25_percent_star_boundary_on_both_sides(time_left: float, expected: int) -> None:
won = make_state(result=RoundResult.WON, time_left=time_left, time_limit=20.0)
assert stars(won) == expected
@pytest.mark.parametrize(
("time_left", "expected"),
[
# One tick below the 50 % boundary of a 20 s clock (9.95 s): two
# stars, not three.
(9.95, 2),
# Exactly the 50 % boundary (10.0 s): the threshold is inclusive.
(10.0, 3),
],
)
def test_the_50_percent_star_boundary_on_both_sides(time_left: float, expected: int) -> None:
won = make_state(result=RoundResult.WON, time_left=time_left, time_limit=20.0)
assert stars(won) == expected
def test_stars_are_monotonic_in_the_clock_and_never_out_of_range() -> None:
previous = 0
for time_left in (0.0, 4.9, 5.0, 9.9, 10.0, 15.0, 19.99):
count = stars(make_state(result=RoundResult.WON, time_left=time_left, time_limit=20.0))
assert 1 <= count <= 3
assert count >= previous
previous = count
def test_a_win_without_a_clock_is_the_win_star_only() -> None:
won = make_state(result=RoundResult.WON, time_left=0.0, time_limit=0.0)
assert stars(won) == 1
def test_stars_are_stable_across_the_settle() -> None:
# The clock freezes at the verdict value, so the grade cannot drift while
# the room keeps ticking: a win at 5.05 s of a 20 s clock lands on 5.0
# — exactly the 25 % boundary — two stars, on the win substep and
# twenty ticks later.
current = make_state(time_left=5.05, time_limit=20.0, score=100, target_score=100)
current, _events = step(current, STEP_DT) # the early win at 5.0 s
assert stars(current) == 2
for _ in range(settle_ticks()):
current, _events = step(current, STEP_DT)
assert stars(current) == 2
# --- longest_streak: the owner and the reader ------------------------------------------
def test_a_serve_raises_longest_streak_and_the_payload_reads_it() -> None:
player = cook()
dish = Item(kind=ItemKind.TOMATO, form=ItemForm.PLATED, quality=1.0, dish_id="soup")
hand = replace(player, carrying=dish)
state = make_state(
players=(hand,),
orders=(order(1, "soup", 40, 30.0),),
streak=0,
)
state, _events = resolve_serve(state, hand, dish, book())
assert state.counters["longest_streak"] == 1
assert state.streak == 1
assert result_payload(state)["longest_streak"] == 1
def test_longest_streak_is_the_high_water_mark_a_miss_never_resets_it() -> None:
player = cook()
dish = Item(kind=ItemKind.TOMATO, form=ItemForm.PLATED, quality=1.0, dish_id="soup")
state = make_state(
players=(player,),
orders=(order(1, "soup", 40, 30.0), order(2, "soup", 40, 30.0)),
)
hand = replace(player, carrying=dish)
state, _events = resolve_serve(state, hand, dish, book())
state, _events = resolve_serve(state, hand, dish, book())
assert state.streak == 2
assert state.counters["longest_streak"] == 2
# The miss: the current streak resets, the mark holds.
state, _events = apply_miss(state, order(3, "soup", 40, 0.0))
assert state.streak == 0
assert state.counters["longest_streak"] == 2
assert result_payload(state)["longest_streak"] == 2
# --- build_state wiring ----------------------------------------------------------------
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() -> Level:
data: dict[str, object] = {
"id": "05_build",
"name": "Round End Kitchen",
"order": 1,
"time_limit": 45.0,
"target_score": 30,
"difficulty": 1,
"grid": copy.deepcopy(GRID),
"tiles": copy.deepcopy(TILES),
"spawns": [[1, 1]],
"orders": {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [4, 6],
"deadline": 30,
},
}
return Level.build(LevelDef.model_validate(data))
def test_build_state_copies_time_limit_and_the_seat_name() -> None:
level = make_level()
seats = (SeatSpec(player_id="p0", seat=0, display_name="Reese"),)
state = build_state(level, seats, 7, book())
assert state.time_limit == 45.0
assert state.time_left == 45.0 # the countdown starts at the limit
assert state.players[0].display_name == "Reese"
# And the stars grade off the copied limit: a win with the full clock
# would be three stars.
won = replace(state, result=RoundResult.WON)
assert stars(won) == 3