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