"""`build_state` + `step` — the kernel's contract: order, clamping, purity, determinism. Phase 03 task 01 promises four things, and each has its own test below rather than being inferred from a golden hash (that arrives with task 05): * **Clamping** — `dt` outside `[0, MAX_DT]` buys nothing, so a stalled process is never repaid with a burst of substeps; * **Accumulation** — the sim advances in whole `STEP_DT` substeps and carries the remainder, so 17 x 0.05 s is exactly 17 ticks and 0.03 s twice is one tick; * **Rejection, not crash** — input from a player this kitchen does not have becomes an `err` event; * **Purity + determinism** — `step()` never touches the state it was handed, and two states built from one seed stay bit-identical for hundreds of ticks. """ from __future__ import annotations import copy import math from collections.abc import Sequence from dataclasses import replace from typing import Any import pytest from undercooked.sim.level import Level, LevelDef from undercooked.sim.model import ( EventKind, Facing, KitchenState, PlayerState, RoundResult, Vec2, ) from undercooked.sim.purity import assert_pure_imports from undercooked.sim.recipe import RecipeBook from undercooked.sim.state_build import ( SEAT_COLORS, SeatSpec, StateBuildError, build_state, color_for_seat, ) from undercooked.sim.step import MAX_DT, STEP_DT, PlayerInput, resolve_inputs, step pytestmark = pytest.mark.unit # --- fixture content ------------------------------------------------------------------ TILES: dict[str, dict[str, str]] = { ".": {"kind": "floor"}, "#": {"kind": "wall"}, "S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"}, "C": {"kind": "floor", "station": "chop_board"}, "V": {"kind": "floor", "station": "serve"}, } GRID = [ "#######", "#S.C..#", "#.....#", "#....V#", "#######", ] RECIPES: dict[str, Any] = { "soup": { "id": "soup", "name": "Tomato Soup", "base_score": 40, "ingredients": [{"kind": "tomato", "form": "raw"}], "steps": [{"kind": "serve", "station": "serve"}], }, } def make_level(**overrides: Any) -> Level: data: dict[str, Any] = { "id": "01_kernel", "name": "Kernel Kitchen", "order": 1, "time_limit": 12, "target_score": 40, "difficulty": 1, "grid": copy.deepcopy(GRID), "tiles": copy.deepcopy(TILES), "spawns": [[1, 1]], "orders": {"pool": ["soup"], "concurrent": 1, "spawn_interval": [4, 6], "deadline": 30}, } data.update(overrides) return Level.build(LevelDef.model_validate(data)) def make_book() -> RecipeBook: return RecipeBook.model_validate({"recipes": copy.deepcopy(RECIPES)}) def seats(count: int, start: int = 0) -> tuple[SeatSpec, ...]: return tuple( SeatSpec(player_id=f"p{index}", seat=index, display_name=f"Cook {index}") for index in range(start, start + count) ) @pytest.fixture def level() -> Level: return make_level() @pytest.fixture def state(level: Level) -> KitchenState: """A three-cook round on the fixture level, seed 0x5EED0401.""" return build_state(level, seats(3), 0x5EED_0401, make_book()) # --- SeatSpec ------------------------------------------------------------------------- def test_seat_colours_are_a_fixed_palette_of_four() -> None: assert len(SEAT_COLORS) == 4 assert len(set(SEAT_COLORS)) == 4 assert all(color.startswith("#") and len(color) == 7 for color in SEAT_COLORS) @pytest.mark.parametrize( ("seat", "expected"), [(0, SEAT_COLORS[0]), (1, SEAT_COLORS[1]), (2, SEAT_COLORS[2]), (3, SEAT_COLORS[3])], ) def test_colour_for_seat_follows_the_palette(seat: int, expected: str) -> None: assert color_for_seat(seat) == expected def test_colour_for_seat_wraps_past_the_fourth_cook() -> None: assert color_for_seat(4) == SEAT_COLORS[0] assert color_for_seat(11) == SEAT_COLORS[3] def test_seat_spec_defaults_to_the_palette_colour() -> None: seat = SeatSpec(player_id="abc", seat=2, display_name="Reese") assert seat.resolved_color == SEAT_COLORS[2] def test_seat_spec_keeps_an_explicit_colour() -> None: seat = SeatSpec(player_id="abc", seat=0, display_name="Reese", color="#123456") assert seat.resolved_color == "#123456" def test_seat_spec_is_hashable_and_a_value() -> None: a = SeatSpec("abc", 1, "Ann", "") assert a == SeatSpec("abc", 1, "Ann", "") assert len({a, SeatSpec("abc", 1, "Ann", "")}) == 1 def test_negative_seat_is_refused() -> None: with pytest.raises(StateBuildError, match="non-negative"): SeatSpec(player_id="abc", seat=-1, display_name="Ann") # --- build_state ------------------------------------------------------------------------ def test_build_state_starts_at_tick_zero_with_the_level_clock( level: Level, state: KitchenState ) -> None: assert state.tick == 0 assert state.accumulator == 0.0 assert state.time_left == level.time_limit == 12 assert state.target_score == level.target_score == 40 assert state.level_id == "01_kernel" assert state.seed == 0x5EED_0401 assert state.rng_state == 0x5EED_0401 assert state.score == 0 assert state.streak == 0 assert state.result is None assert dict(state.counters) == {} def test_build_state_takes_the_grid_and_stations_from_the_level( level: Level, state: KitchenState ) -> None: assert state.grid is level.grid assert state.stations == level.stations assert {station.kind.value for station in state.stations} >= { "spawn_shelf", "chop_board", "serve", } def test_orders_and_hazards_start_empty_for_later_phases(state: KitchenState) -> None: assert state.orders == () assert state.hazards == () def test_players_are_built_in_seat_order(state: KitchenState) -> None: assert [player.id for player in state.players] == ["p0", "p1", "p2"] assert [player.seat for player in state.players] == [0, 1, 2] def test_players_start_at_the_spawn_centre_facing_down(level: Level, state: KitchenState) -> None: assert level.spawns == ((1, 1),) first = state.players[0] assert first.pos == Vec2(1.5, 1.5) assert first.facing is Facing.DOWN assert first.state is PlayerState.IDLE assert first.connected is True assert first.carrying is None assert first.vel == Vec2(0.0, 0.0) assert first.stun == 0.0 def test_cooks_sharing_a_spawn_are_offset_by_a_quarter_tile(state: KitchenState) -> None: _, second, third = state.players assert second.pos == Vec2(1.75, 1.5) assert third.pos == Vec2(1.5, 1.75) positions = {player.pos for player in state.players} assert len(positions) == 3 def test_a_full_kitchen_stays_inside_its_spawn_tile(state: KitchenState) -> None: four = build_state(make_level(), seats(4), 7, make_book()) assert [player.pos for player in four.players] == [ Vec2(1.5, 1.5), Vec2(1.75, 1.5), Vec2(1.5, 1.75), Vec2(1.75, 1.75), ] assert all( four.grid.is_walkable(int(player.pos.x), int(player.pos.y)) for player in four.players ) def test_seats_wrap_over_the_available_spawns() -> None: level = make_level(spawns=[[1, 1], [5, 3]]) built = build_state(level, seats(4), 1, make_book()) assert [(player.pos.x, player.pos.y) for player in built.players] == [ (1.5, 1.5), # seat 0 -> spawn 0 (5.5, 3.5), # seat 1 -> spawn 1 (1.75, 1.5), # seat 2 -> spawn 0 again, offset (5.75, 3.5), # seat 3 -> spawn 1 again, offset ] def test_seat_spec_order_does_not_matter() -> None: scrambled = ( SeatSpec("p2", 2, "C"), SeatSpec("p0", 0, "A"), SeatSpec("p1", 1, "B"), ) built = build_state(make_level(), scrambled, 5, make_book()) assert [player.id for player in built.players] == ["p0", "p1", "p2"] def test_no_seats_builds_an_empty_kitchen(level: Level) -> None: built = build_state(level, (), 3, make_book()) assert built.players == () assert built.state_hash() # still a perfectly hashable round def test_a_level_without_spawns_cannot_be_built(level: Level) -> None: barren = replace(level, spawns=()) with pytest.raises(StateBuildError, match="defines no spawns"): build_state(barren, seats(1), 1, make_book()) def test_build_state_is_reproducible(level: Level) -> None: first = build_state(level, seats(3), 99, make_book()) second = build_state(level, seats(3), 99, make_book()) assert first.state_hash() == second.state_hash() # --- step: clamping and accumulation ------------------------------------------------------ def tick(state: KitchenState, dt: float) -> int: """Ticks a single `step()` call buys from a fresh accumulator.""" advanced, _ = step(state, dt) return advanced.tick def test_one_substep_is_one_tick(state: KitchenState) -> None: assert tick(state, STEP_DT) == 1 @pytest.mark.parametrize("dt", [0.0, -0.0, -1.0, -STEP_DT, float("-inf")]) def test_a_non_positive_clock_advance_buys_nothing(state: KitchenState, dt: float) -> None: assert tick(state, dt) == 0 def test_a_nan_clock_advance_is_treated_as_no_time(state: KitchenState) -> None: dt: Any = float("nan") advanced, events = step(state, dt) assert advanced.tick == 0 assert events == () def test_one_call_never_runs_more_than_max_dt(state: KitchenState) -> None: assert tick(state, MAX_DT) == 2 assert tick(state, 60.0) == 2 # a minute of wall time is still two substeps assert tick(state, float("inf")) == 2 def test_the_ceiling_is_exactly_max_dt_over_step_dt() -> None: assert MAX_DT / STEP_DT == 2.0 def test_seventeen_slices_of_step_dt_are_seventeen_ticks(state: KitchenState) -> None: current = state for _ in range(17): current, _events = step(current, STEP_DT) assert current.tick == 17 assert current.accumulator == pytest.approx(0.0, abs=1e-12) def test_time_below_one_substep_is_carried_not_dropped(state: KitchenState) -> None: current = state ticks_seen: list[int] = [] for _ in range(10): # 10 x 0.03 s = 0.3 s -> six substeps, remainder 0.0 current, _events = step(current, 0.03) ticks_seen.append(current.tick) assert ticks_seen == [0, 1, 1, 2, 3, 3, 4, 4, 5, 6] assert current.accumulator == pytest.approx(0.0, abs=1e-9) def test_the_accumulator_holds_back_a_partial_substep(state: KitchenState) -> None: current, _events = step(state, 0.04) assert current.tick == 0 assert current.accumulator == pytest.approx(0.04) current, _events = step(current, 0.02) assert current.tick == 1 assert current.accumulator == pytest.approx(0.01) def test_the_accumulator_never_goes_negative(state: KitchenState) -> None: current = state for dt in (0.05, 0.07, 0.01, 0.001, 0.049): current, _events = step(current, dt) assert current.accumulator >= 0.0 assert current.accumulator < STEP_DT def test_the_clock_counts_down_with_the_ticks(state: KitchenState) -> None: current = state for _ in range(10): current, _events = step(current, STEP_DT) assert current.time_left == pytest.approx(12 - 10 * STEP_DT) # --- step: inputs ------------------------------------------------------------------------ def inputs(*specs: tuple[str, int, int]) -> tuple[PlayerInput, ...]: """`PlayerInput` from (player_id, seq, received_at_tick) triples.""" return tuple( PlayerInput(player_id=player_id, seq=seq, received_at_tick=received) for player_id, seq, received in specs ) def test_inputs_for_real_players_are_accepted_without_events(state: KitchenState) -> None: advanced, events = step(state, STEP_DT, inputs(("p0", 1, 0))) assert events == () assert advanced.tick == 1 def test_an_unknown_player_is_an_error_event_not_a_crash(state: KitchenState) -> None: advanced, events = step(state, STEP_DT, inputs(("ghost", 4, 0))) assert advanced.tick == 1 # the round keeps running assert len(events) == 1 assert events[0].kind is EventKind.ERROR assert dict(events[0].payload) == { "player_id": "ghost", "reason": "unknown_player", "seq": 4, } # Dropping the ghost must cost the real cooks nothing: compared against the same tick # with no input at all, not against `state`, because movement now separates the two # cooks who share a spawn tile and that is the kernel's doing, not the ghost's. frameless, _events = step(state, STEP_DT) assert advanced.players == frameless.players def test_a_mixed_batch_reports_only_the_ghost(state: KitchenState) -> None: _advanced, events = step( state, STEP_DT, inputs(("p1", 1, 0), ("nobody", 2, 0), ("p2", 1, 0)) ) assert [event.kind for event in events] == [EventKind.ERROR] assert events[0].payload["player_id"] == "nobody" def test_resolve_inputs_keeps_the_earliest_frame(state: KitchenState) -> None: resolved, events = resolve_inputs( state, inputs(("p0", 9, 5), ("p0", 10, 2), ("p0", 11, 7)) ) assert events == () assert resolved["p0"].seq == 10 def test_resolve_inputs_breaks_an_arrival_tie_by_seq(state: KitchenState) -> None: resolved, _events = resolve_inputs(state, inputs(("p0", 12, 3), ("p0", 11, 3))) assert resolved["p0"].seq == 11 def test_resolve_inputs_is_stable_on_a_full_tie(state: KitchenState) -> None: resolved, _events = resolve_inputs(state, inputs(("p0", 8, 3), ("p0", 8, 3))) assert resolved["p0"].seq == 8 def test_resolve_inputs_iterates_in_seat_order(state: KitchenState) -> None: resolved, _events = resolve_inputs(state, inputs(("p2", 1, 9), ("p0", 1, 4), ("p1", 1, 6))) assert list(resolved) == ["p0", "p1", "p2"] def test_resolve_inputs_drops_everything_for_players_that_are_not_here( state: KitchenState, ) -> None: resolved, events = resolve_inputs(state, inputs(("ghost", 1, 0), ("p0", 1, 0))) assert list(resolved) == ["p0"] assert len(events) == 1 def test_resolve_inputs_of_nothing_is_empty(state: KitchenState) -> None: assert resolve_inputs(state, ()) == ({}, ()) def test_an_empty_kitchen_rejects_every_input() -> None: barren = build_state(make_level(), (), 2, make_book()) resolved, events = resolve_inputs(barren, inputs(("p0", 1, 0))) assert resolved == {} assert len(events) == 1 def test_input_fields_survive_untouched(state: KitchenState) -> None: frame = PlayerInput( player_id="p0", seq=3, move=Vec2(0.6, -0.8), action=True, face=Facing.LEFT, received_at_tick=11, ) resolved, _events = resolve_inputs(state, (frame,)) kept = resolved["p0"] assert kept is frame assert (kept.move, kept.action, kept.face, kept.received_at_tick) == ( Vec2(0.6, -0.8), True, Facing.LEFT, 11, ) def test_player_input_defaults() -> None: frame = PlayerInput(player_id="p0") assert frame.seq == 0 assert frame.move == Vec2(0.0, 0.0) assert frame.action is False assert frame.face is None assert frame.received_at_tick == 0 def test_player_input_is_frozen(state: KitchenState) -> None: frame = PlayerInput(player_id="p0") with pytest.raises(AttributeError): frame.seq = 1 # type: ignore[misc] # --- step: purity ------------------------------------------------------------------------ def test_step_does_not_mutate_the_state_it_was_given(state: KitchenState) -> None: before_hash = state.state_hash() snapshot = replace(state) advanced, _events = step(state, 0.2, inputs(("p0", 1, 0), ("ghost", 1, 0))) assert state == snapshot assert state.state_hash() == before_hash assert advanced != state assert advanced.tick == 2 def test_the_events_are_a_tuple_in_order(state: KitchenState) -> None: _advanced, events = step(state, STEP_DT, inputs(("ghost-a", 1, 0), ("ghost-b", 1, 0))) assert isinstance(events, tuple) assert [event.payload["player_id"] for event in events] == ["ghost-a", "ghost-b"] def test_the_kernel_modules_stay_free_of_forbidden_imports() -> None: assert_pure_imports() # --- step: determinism -------------------------------------------------------------------- def run( state: KitchenState, script: Sequence[tuple[float, tuple[PlayerInput, ...]]], book: RecipeBook, ) -> KitchenState: current = state for dt, frames in script: current, _events = step(current, dt, frames, book) return current def test_two_rounds_from_one_seed_stay_identical_over_200_steps(level: Level) -> None: left = build_state(level, seats(3), 0x5EED_0401, make_book()) right = build_state(level, seats(3), 0x5EED_0401, make_book()) script = [ (STEP_DT, inputs(("p0", index, index), ("p1", index, index))) for index in range(200) ] assert run(left, script, make_book()).state_hash() == run( right, script, make_book() ).state_hash() def test_the_seed_reaches_the_state_so_two_seeds_differ(level: Level) -> None: one = build_state(level, seats(2), 1, make_book()) other = build_state(level, seats(2), 2, make_book()) assert one.state_hash() != other.state_hash() assert one.rng_state != other.rng_state def test_tick_is_monotonic_across_200_steps(state: KitchenState) -> None: current = state previous = current.tick for _ in range(200): current, _events = step(current, STEP_DT, book=make_book()) assert current.tick == previous + 1 assert current.time_left <= 12.0 previous = current.tick def test_a_step_that_runs_no_substeps_changes_nothing_but_the_accumulator( state: KitchenState, ) -> None: current, events = step(state, 0.01) assert events == () assert current.tick == 0 assert current.time_left == state.time_left assert current.players == state.players def test_batched_and_sliced_time_agree(state: KitchenState) -> None: batched, _ = step(state, MAX_DT) sliced = step(state, 0.025)[0] sliced = step(sliced, 0.025)[0] sliced = step(sliced, 0.025)[0] sliced, _ = step(sliced, 0.025) assert batched.tick == sliced.tick == 2 assert batched.state_hash() == sliced.state_hash() # --- step: the round timer (stage 7) -------------------------------------------------------- def test_the_round_ends_lost_when_the_clock_beats_the_score(state: KitchenState) -> None: current = state for _ in range(240): # 240 x 0.05 s = the whole 12 s clock current, _events = step(current, STEP_DT, book=make_book()) assert current.time_left == 0.0 assert current.result is RoundResult.LOST assert current.tick == 240 def test_the_round_is_won_on_the_buzzer_when_the_target_was_reached( state: KitchenState, ) -> None: winning = replace(state, score=state.target_score) current = winning for _ in range(240): current, _events = step(current, STEP_DT, book=make_book()) assert current.result is RoundResult.WON def test_the_clock_never_goes_negative_after_the_buzzer(state: KitchenState) -> None: current = replace(state, time_left=STEP_DT) current, _events = step(current, STEP_DT) assert current.time_left == 0.0 assert current.result is RoundResult.LOST current, _events = step(current, STEP_DT) assert current.time_left == 0.0 assert not math.isclose(current.time_left, -1.0) def test_an_already_decided_round_keeps_its_result(state: KitchenState) -> None: decided = replace(state, time_left=STEP_DT, result=RoundResult.WON, score=99) current, events = step(decided, STEP_DT) assert current.result is RoundResult.WON # The settle (phase 04 task 05): the clock is frozen at the verdict value # (this hand-built state's), and a decided substep fires no events. assert current.time_left == STEP_DT assert events == ()