"""Integration: the `Room` state machine (phase 05, task 02) — the guarded FSM in `server/room.py`. The whole contract, driven by hand on a `FakeClock` (no test sleeps): * **the transition table is data** — every `(state, event)` pair of the 5 states x 9 events is tested through `Room._fire`: the 14 legal pairs land on the table's target, the 31 illegal ones raise `IllegalTransition` and leave the state untouched (no ad-hoc state writes exist), * **the preconditions are named** — `request_start` refuses `"no_level"` and `"no_ready_players"` (≥ 1 ready non-host, or a solo host, PLAN §6), * **the countdown is 3 s** — it counts 3 frames of 1 s to zero (and the 20 Hz cadence: 60 frames of 0.05 s) and the round's state is built at `countdown_done` (level, a fresh seed, the shelf stock and the prop counters the room owns — `sim/stations.py`'s `DEFAULT_STOCK` is only the stand-in a room-less build sees), * **`round_over` stores the result** — after the sim's 1.0 s settle window (`sim/round_state.SETTLE_S`), the room is `ROUND_END` and `last_result` is `result_payload` of the state at the verdict, * **`retry` reuses the level with a new seed; `next_level` switches to an explicitly supplied level id** (task 04 wires that value from the session store), * **`close` is idempotent and frees the seats** (the universal teardown, legal from every live state), * **the idle rules** — `all_seats_empty` closes after the 30 s grace, `idle_expired` closes past `lobby_idle_timeout_s` (the room's own copy of the GC's rules), * **queued inputs are per-tick and PLAYING-only** — dropped outside `PLAYING`, an unknown player is a buffered `err` event, and `take_events` drains the buffer exactly once. """ from __future__ import annotations import copy from typing import Any import pytest from undercooked.config import Settings from undercooked.server.clock import FakeClock from undercooked.server.room import ( COUNTDOWN_S, ROOM_EVENTS, ROOM_TRANSITIONS, SEAT_GRACE_S, IllegalTransition, Room, RoomState, StartRefused, ) from undercooked.sim.content import ContentBundle from undercooked.sim.level import Level, LevelDef from undercooked.sim.model import ( EventKind, ItemForm, ItemKind, RoundResult, StationKind, ) from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step from undercooked.sim.step import PlayerInput pytestmark = pytest.mark.integration # ------------------------------------------------------------------ fixtures def make_book() -> RecipeBook: """A one-recipe book: the small levels' order pool names `soup`, and the room passes the book to `step`, so a due order spawn must find it.""" return RecipeBook( recipes={ "soup": Recipe( id="soup", name="Soup", base_score=40, ingredients=[Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW, count=1)], steps=[Step(kind="serve", station=StationKind.SERVE, seconds=0.0)], ) }, ingredients=[ItemKind.TOMATO], ) def make_level(level_id: str, *, time_limit: float = 3.0, target: int = 1000) -> Level: """A 7x4 kitchen: one spawn shelf, one serve window, one spawn point. `target` is above anything an idle cook scores, so a `PLAYING` round on this level always ends `LOST` on the buzzer — the shortest path to `ROUND_END`. """ grid = [ "#######", "#S...V#", "#.....#", "#######", ] tiles: dict[str, dict[str, str]] = { ".": {"kind": "floor"}, "#": {"kind": "wall"}, "S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"}, "V": {"kind": "floor", "station": "serve"}, } data: dict[str, object] = { "id": level_id, "name": f"FSM kitchen {level_id}", "order": 1, "time_limit": time_limit, "target_score": target, "grid": copy.deepcopy(grid), "tiles": copy.deepcopy(tiles), "spawns": [[2, 2]], "orders": { "pool": ["soup"], "concurrent": 1, "spawn_interval": [4, 6], "deadline": 30, }, } return Level.build(LevelDef.model_validate(data)) def make_bundle(*levels: Level) -> ContentBundle: return ContentBundle(recipe_book=make_book(), levels=levels) def make_room( bundle: ContentBundle | None = None, **settings: Any, ) -> tuple[Room, FakeClock]: """A `LOBBY` room on a `FakeClock`; `bundle=None` is the task-01 style (no content: the room cannot start a round it has no level for).""" clock = FakeClock() room = Room( code="TEST1", settings=Settings(**settings), clock=clock, content=bundle, level_id=bundle.levels[0].id if bundle and bundle.levels else None, ) return room, clock def seat_solo_host(room: Room, player_id: str = "tok-a", name: str = "Ada") -> None: room.add_seat(player_id, name, True) def seat_ready_non_host(room: Room, player_id: str = "tok-b", name: str = "Bob") -> None: seat = room.add_seat(player_id, name, False) assert room.set_ready(player_id, True) assert seat is not None def prime(room: Room, now: float = 0.0) -> None: """The first `tick_once` (dt 0) — the room's wall clock starts here.""" room.tick_once(now=now) def drive_countdown(room: Room, now: float = 0.0) -> float: """The 3 s countdown in 1 s frames (the task's own words); returns the final `now` (3.0), with the room in `PLAYING`.""" prime(room, now) for offset in (1.0, 2.0, 3.0): room.tick_once(now=now + offset) assert room.state is RoomState.PLAYING return now + 3.0 def play_round_to_end(room: Room, now: float = 3.0) -> float: """One second-frames through the 3.0 s round (each frame's `dt` clamps to the sim's `MAX_DT` of 0.1 s — 2 substeps) and its 1.0 s settle window (2 more frames), until `ROUND_END`; returns the final `now`.""" while room.state is RoomState.PLAYING: now += 1.0 room.tick_once(now=now) assert room.state is RoomState.ROUND_END return now # ------------------------------------------------- the transition matrix @pytest.mark.parametrize("state", list(RoomState)) @pytest.mark.parametrize("event", ROOM_EVENTS) def test_the_full_states_x_events_matrix(state: RoomState, event: str) -> None: """Every pair of the 5 states x 9 events: the 14 legal ones (the table) land on the target; the 31 illegal ones raise `IllegalTransition` and leave the state exactly where it was.""" room, _ = make_room() room.state = state # test setup only: `_fire` is the guarded path expected = ROOM_TRANSITIONS.get((state, event)) if expected is None: with pytest.raises(IllegalTransition): room._fire(event) assert room.state is state # unchanged else: assert room._fire(event) is expected def test_the_table_is_closed_over_the_state_and_event_namespaces() -> None: """The table only names known states and events, and every event is legal somewhere (no orphaned event, no transition out of the FSM).""" for (state, event), to in ROOM_TRANSITIONS.items(): assert isinstance(state, RoomState), (state, event) assert event in ROOM_EVENTS, (state, event) assert isinstance(to, RoomState), (state, event) assert {event for _, event in ROOM_TRANSITIONS} == set(ROOM_EVENTS) # `CLOSED` is an exit, never a source: no transition leaves it. assert not any(from_state is RoomState.CLOSED for (from_state, _) in ROOM_TRANSITIONS) def test_every_event_is_legal_from_the_states_the_diagram_names() -> None: """PLAN §6's diagram, spelled out: the legal pairs are exactly the table's 14 (so a future edit of either is a test failure).""" legal = { (RoomState.LOBBY, "host_start"), (RoomState.COUNTDOWN, "countdown_done"), (RoomState.PLAYING, "round_over"), (RoomState.ROUND_END, "to_lobby"), (RoomState.ROUND_END, "retry"), (RoomState.ROUND_END, "next_level"), (RoomState.LOBBY, "close"), (RoomState.COUNTDOWN, "close"), (RoomState.PLAYING, "close"), (RoomState.ROUND_END, "close"), (RoomState.LOBBY, "all_seats_empty"), (RoomState.ROUND_END, "all_seats_empty"), (RoomState.LOBBY, "idle_expired"), (RoomState.ROUND_END, "idle_expired"), } assert set(ROOM_TRANSITIONS) == legal assert len(legal) == 14 # 45 pairs - 31 illegal = 14 legal # ------------------------------------------------------------ preconditions def test_request_start_requires_a_solo_host_or_a_ready_non_host() -> None: bundle = make_bundle(make_level("05_start")) room, _ = make_room(bundle) seat_solo_host(room) room.request_start() # the solo host: no ready flag needed assert room.state is RoomState.COUNTDOWN assert room.round_no == 1 assert room.round_seed is not None def test_request_start_refuses_without_a_ready_non_host() -> None: bundle = make_bundle(make_level("05_start")) room, _ = make_room(bundle) seat_solo_host(room) room.add_seat("tok-b", "Bob", False) # not ready with pytest.raises(StartRefused, match="no_ready_players"): room.request_start() assert room.state is RoomState.LOBBY # the refusal leaves no state change def test_request_start_accepts_one_ready_non_host_among_the_unready() -> None: bundle = make_bundle(make_level("05_start")) room, _ = make_room(bundle) seat_solo_host(room) seat_ready_non_host(room, "tok-b", "Bob") room.add_seat("tok-c", "Cy", False) # unready, but one ready is enough room.request_start() assert room.state is RoomState.COUNTDOWN def test_request_start_refuses_without_a_level() -> None: room, _ = make_room(None) # no content bundle: no level to play seat_solo_host(room) with pytest.raises(StartRefused, match="no_level"): room.request_start() assert room.state is RoomState.LOBBY def test_request_start_is_refused_outside_the_lobby() -> None: bundle = make_bundle(make_level("05_start")) room, _ = make_room(bundle) seat_solo_host(room) room.request_start() with pytest.raises(IllegalTransition): room.request_start() # already COUNTDOWN # ---------------------------------------------------------------- countdown def test_the_countdown_counts_three_frames_of_one_second() -> None: bundle = make_bundle(make_level("05_count")) room, _ = make_room(bundle) seat_solo_host(room) room.request_start() assert room.state is RoomState.COUNTDOWN assert room.countdown_left == COUNTDOWN_S == 3.0 prime(room) room.tick_once(now=1.0) assert room.state is RoomState.COUNTDOWN and room.countdown_left == 2.0 room.tick_once(now=2.0) assert room.state is RoomState.COUNTDOWN and room.countdown_left == 1.0 room.tick_once(now=3.0) # the third second lands it on zero assert room.state is RoomState.PLAYING # `countdown_done` fired def test_the_countdown_runs_the_20_hz_cadence_too() -> None: """60 frames of 0.05 s are the same 3.0 s (the real loop's cadence).""" bundle = make_bundle(make_level("05_count")) room, _ = make_room(bundle) seat_solo_host(room) room.request_start() prime(room) for frame in range(1, 61): room.tick_once(now=frame * 0.05) if room.state is RoomState.PLAYING: assert frame >= 59 # float noise may cost one frame at most break assert room.state is RoomState.PLAYING def test_countdown_done_builds_the_round_from_level_seats_and_seed() -> None: bundle = make_bundle(make_level("05_build")) room, _ = make_room(bundle) seat_solo_host(room, "tok-a", "Ada") seat_ready_non_host(room, "tok-b", "Bob") room.request_start() drive_countdown(room) assert room.sim_state is not None state = room.sim_state assert state.level_id == "05_build" assert state.seed == room.round_seed # the fresh seed, on the state assert state.time_left == state.time_limit == 3.0 assert [player.id for player in state.players] == ["tok-a", "tok-b"] # seat order # The room's seeding job (sim/stations.py's DEFAULT_STOCK is only the # stand-in a room-less build sees): the shelf stock and the prop caps. shelf = next(s for s in state.stations if s.id.startswith("spawn_shelf")) assert len(shelf.contents) == 8 # the room's shelf seed assert state.counters == { "plates_clean": 99, "plates_dirty": 0, "pots_clean": 99, "pots_dirty": 0, } # A queued input from the countdown is gone (per-tick, cleared by the # round's start). assert room._queued_inputs == [] # ------------------------------------------------------------- round end def test_round_over_stores_last_result_and_the_payload() -> None: bundle = make_bundle(make_level("05_end")) room, _ = make_room(bundle) seat_solo_host(room) room.request_start() now = drive_countdown(room) now = play_round_to_end(room, now) # the 3 s round + the 1 s settle assert room.state is RoomState.ROUND_END assert room.last_result is not None payload = room.last_result # The exact key set of `result_payload` (PLAN §7's `round` body). assert set(payload) == { "result", "score", "target", "stars", "time_left", "counters", "longest_streak", "players", "level_id", "seed", "ticks", } assert payload["result"] == "lost" # the idle cook loses on the buzzer assert payload["score"] == 0 assert payload["stars"] == 0 assert payload["level_id"] == "05_end" assert payload["seed"] == room.round_seed # The settle window ran: the verdict tick is not the state's last tick. assert int(str(payload["ticks"])) > 0 def test_the_settle_window_keeps_the_room_playing_before_round_over() -> None: """The sim's `SETTLE_S` contract, on the room's side: the tick the verdict lands does not end the round — the room keeps ticking the frozen sim for 1.0 s of wall time first.""" bundle = make_bundle(make_level("05_settle")) room, _ = make_room(bundle) seat_solo_host(room) room.request_start() now = drive_countdown(room) # 1 s frames (each `dt` clamps to the sim's 0.1 s) until the verdict lands. for _ in range(32): now += 1.0 room.tick_once(now=now) if room.sim_state is not None and room.sim_state.result is not None: break assert room.sim_state is not None assert room.sim_state.result is RoundResult.LOST # The verdict tick: the settle window is open (the sim ticks on, frozen), # and the payload has not landed yet. assert room.state is RoomState.PLAYING # still settling assert room.last_result is None # the payload lands with `round_over` # The window closes 1.0 s of wall time after it opened. now += 1.0 room.tick_once(now=now) assert room.state is RoomState.ROUND_END assert room.last_result is not None and room.last_result["result"] == "lost" # ------------------------------------------------------------ retry / next def test_retry_reuses_the_level_with_a_new_seed() -> None: bundle = make_bundle(make_level("05_retry")) room, _ = make_room(bundle) seat_solo_host(room) room.request_start() seed_one = room.round_seed assert seed_one is not None now = drive_countdown(room) now = play_round_to_end(room, now) room.retry() assert room.state is RoomState.COUNTDOWN assert room.round_no == 2 assert room.round_seed is not None assert room.round_seed != seed_one # the seed is fresh assert room.level_id == "05_retry" # the level is the same drive_countdown(room, now) assert room.state is RoomState.PLAYING assert room.sim_state is not None assert room.sim_state.level_id == "05_retry" # reused assert room.sim_state.seed == room.round_seed # and fresh def test_next_level_switches_to_the_supplied_level_id() -> None: bundle = make_bundle(make_level("05_a"), make_level("05_b")) room, _ = make_room(bundle) seat_solo_host(room) room.request_start() now = drive_countdown(room) now = play_round_to_end(room, now) room.next_level("05_b") # the explicitly supplied id (task 04 wires it) assert room.state is RoomState.COUNTDOWN assert room.level_id == "05_b" and room.level is not None drive_countdown(room, now) assert room.state is RoomState.PLAYING assert room.sim_state is not None assert room.sim_state.level_id == "05_b" # the new level, on the state def test_next_level_refuses_an_id_outside_the_campaign_without_a_state_change() -> None: bundle = make_bundle(make_level("05_a")) room, _ = make_room(bundle) seat_solo_host(room) room.request_start() now = drive_countdown(room) now = play_round_to_end(room, now) with pytest.raises(ValueError, match="not in room"): room.next_level("05_missing") assert room.state is RoomState.ROUND_END # untouched assert room.level_id == "05_a" with pytest.raises(ValueError, match="no content bundle"): make_room(None)[0].select_level("05_a") # a bundle-less room def test_to_lobby_returns_to_the_lobby_and_resets_the_ready_flags() -> None: bundle = make_bundle(make_level("05_lobby")) room, _ = make_room(bundle) seat_solo_host(room) seat_ready_non_host(room) # Bob is ready room.request_start() now = drive_countdown(room) now = play_round_to_end(room, now) room.to_lobby() assert room.state is RoomState.LOBBY assert all(not seat.ready for seat in room.seats) # ready up again assert room.last_result is not None # kept for inspection # Ready up again, and the next round starts from the fresh lobby. assert room.set_ready("tok-b", True) room.request_start() assert room.state is RoomState.COUNTDOWN and room.round_no == 2 # ------------------------------------------------------------------ close async def test_close_is_idempotent_and_frees_the_seats() -> None: bundle = make_bundle(make_level("05_close")) room, _ = make_room(bundle) seat_solo_host(room) seat_ready_non_host(room) await room.close() assert room.state is RoomState.CLOSED assert room.seats == [] assert room.closed_at is not None await room.close() # idempotent: no error, no second state write assert room.state is RoomState.CLOSED assert room.closed_at is not None async def test_close_works_from_every_live_state() -> None: bundle = make_bundle(make_level("05_close_any")) for target in (RoomState.COUNTDOWN, RoomState.PLAYING, RoomState.ROUND_END): room, _ = make_room(bundle) seat_solo_host(room) room.state = target # test setup: the table's `close` row is what's tested await room.close() assert room.state is RoomState.CLOSED assert room.seats == [] def test_a_closed_room_ticks_nothing() -> None: bundle = make_bundle(make_level("05_closed_tick")) room, _ = make_room(bundle) seat_solo_host(room) room.state = RoomState.CLOSED # test setup room.tick_once(now=99.0) assert room.state is RoomState.CLOSED # ------------------------------------------------------- the idle rules def test_all_seats_empty_closes_after_the_grace() -> None: bundle = make_bundle(make_level("05_grace")) room, _ = make_room(bundle) seat_solo_host(room, "tok-a") room.add_seat("tok-b", "Bob", False) room.remove_seat("tok-b") room.remove_seat("tok-a") assert room.empty_since == 0.0 prime(room) room.tick_once(now=SEAT_GRACE_S) # exactly 30 s is not past the grace assert room.state is RoomState.LOBBY room.tick_once(now=SEAT_GRACE_S + 1.0) assert room.state is RoomState.CLOSED def test_idle_expired_closes_past_the_lobby_timeout() -> None: bundle = make_bundle(make_level("05_idle")) room, _ = make_room(bundle, lobby_idle_timeout_s=10.0) seat_solo_host(room) # occupied: the idle-lobby rule, not the grace prime(room) room.tick_once(now=10.0) # exactly the timeout is not past it assert room.state is RoomState.LOBBY room.tick_once(now=10.5) assert room.state is RoomState.CLOSED def test_the_idle_rules_run_in_round_end_too() -> None: bundle = make_bundle(make_level("05_idle_end")) room, _ = make_room(bundle, lobby_idle_timeout_s=10.0) seat_solo_host(room) room.request_start() now = drive_countdown(room) now = play_round_to_end(room, now) assert room.state is RoomState.ROUND_END room.tick_once(now=now + 10.5) # > 10 s of lobby-idle in ROUND_END assert room.state is RoomState.CLOSED # ------------------------------------------------------ defensive edges def test_the_settings_and_content_properties_expose_the_injected_values() -> None: bundle = make_bundle(make_level("06_props")) settings = Settings() room = Room(code="TEST1", settings=settings, clock=FakeClock(), content=bundle) assert room.settings is settings assert room.content is bundle empty = Room(code="TEST2", settings=Settings(), clock=FakeClock()) assert empty.content is None # content is optional (the supervisor always sets it) def test_a_full_room_refuses_new_seats() -> None: bundle = make_bundle(make_level("06_full")) room = Room( code="TEST1", settings=Settings(max_players=1), clock=FakeClock(), content=bundle, level_id=bundle.levels[0].id, ) first = room.add_seat("tok-a", "Ada", True) with pytest.raises(ValueError, match="is full"): room.add_seat("tok-b", "Bob", False) assert first is room.seats[0] def test_set_ready_for_an_unknown_token_is_a_noop() -> None: bundle = make_bundle(make_level("06_ready")) room, _ = make_room(bundle) assert room.set_ready("tok-ghost", True) is False assert room.set_ready("tok-ghost", False) is False def test_ticking_a_room_with_no_content_bundle_raises_an_internal_error() -> None: clock = FakeClock() room = Room(code="TEST1", settings=Settings(), clock=clock) # no content room.add_seat("tok-a", "Ada", True) # A level is attached (so `request_start` passes) but the bundle itself is # absent: the round build cannot load recipes or seats' props. room.level = make_level("06_nolevel") room.request_start() clock.advance(COUNTDOWN_S) # White-box: `tick_once` crash-isolates (by design), so the build is # exercised directly to pin the internal error. with pytest.raises(RuntimeError, match="no content bundle"): room._build_sim_state() def test_ticking_a_corrupted_playing_room_raises_the_internal_invariant() -> None: bundle = make_bundle(make_level("06_corrupt")) room, _ = make_room(bundle) seat_solo_host(room) room.request_start() for frame in range(62): # through the countdown: the round is built room.tick_once(now=frame * 0.05) assert room.sim_state is not None room.sim_state = None # corrupt: PLAYING with no built state # White-box: `tick_once` crash-isolates (by design), so the tick body is # exercised directly to pin the internal error. with pytest.raises(RuntimeError, match="no built state"): room._tick(0.0, 62 * 0.05) # -------------------------------------------------- input legality / drain def test_queued_inputs_are_dropped_outside_playing() -> None: bundle = make_bundle(make_level("05_input")) room, _ = make_room(bundle) seat_solo_host(room) frame = PlayerInput(player_id="tok-a", seq=1) assert room.queue_input("tok-a", frame) is False # LOBBY: dropped assert room.events_since_snapshot == [] # not an error: the client is not at fault room.state = RoomState.COUNTDOWN assert room.queue_input("tok-a", frame) is False def test_queue_input_rejects_an_unknown_player_with_an_err_event() -> None: bundle = make_bundle(make_level("05_input_err")) room, _ = make_room(bundle) seat_solo_host(room) room.request_start() drive_countdown(room) assert room.queue_input("tok-stranger", PlayerInput(player_id="tok-stranger")) is False events = room.take_events() assert len(events) == 1 assert events[0].kind is EventKind.ERROR assert events[0].payload == { "reason": "unknown_player", "player_id": "tok-stranger", "seq": 0, } # A known seat is queued, stamped with the sim's tick, and the queue # is per-tick: the next `tick_once` that advances a substep consumes # and clears it (the task-03 substep guard: a wall-clock tick too # short to fill a `STEP_DT` keeps the queue, so a frame is never # dropped without being applied — hence the 0.05 s frame off the # countdown's final `now`). frame = PlayerInput(player_id="tok-a", seq=7) assert room.queue_input("tok-a", frame) is True state = room.sim_state assert state is not None assert room._queued_inputs[-1].received_at_tick == state.tick assert room._queued_inputs[-1].seq == 7 room.tick_once(now=3.0 + 0.05) assert room._queued_inputs == [] def test_take_events_drains_the_buffer_exactly_once() -> None: bundle = make_bundle(make_level("05_drain")) room, _ = make_room(bundle) seat_solo_host(room) room.request_start() drive_countdown(room) room.queue_input("tok-stranger", PlayerInput(player_id="tok-stranger")) # an event first = room.take_events() assert len(first) == 1 and first[0].kind is EventKind.ERROR assert room.take_events() == () # the second drain is empty assert room.snapshot_seq == 2