"""Integration: the `Room`'s tick ownership (phase 05, task 02) — the 20 Hz loop, the synchronous `tick_once` core, and crash isolation. What this file pins: * `tick_once` drives the sim forward — a scripted `move` input moves the cook (the room queues the frame, the kernel steps it, the buffer drains), * the cadence has no drift — 100 manual calls at 0.05 s land on exactly 100 substeps and exactly 5.0 s of wall time, and the async loop (deadline-scheduled, PLAN §6) makes one tick per 0.05 s of wall time for 100 ticks, * a clock jump resynchronises instead of paying a catch-up burst (the sim's `MAX_DT` clamp makes a burst worthless; the tick count proves there was none), * the event buffer drains exactly once (`take_events`), * crash isolation — an exception in the tick (the `step` seam, or the round build) is logged (code + round, never a player token), aborts the sim (`sim.round_state.abort`), buffers an `err` event, fires `close`, and never propagates out of `tick_once` or the loop; the loop runs to its own exit (the task ends normally, not cancelled), * a crash closes *only* that room: a second live room (registered in the supervisor, tick task running) keeps ticking, and the supervisor stays alive, * a `Room` driven by `tick_once` reaches `WON` on the shipped level 1 with a scripted cook (the phase-04 full-round policy, through the room's own build, queue and settle). All time is a `FakeClock`; no test sleeps in real time. """ from __future__ import annotations import asyncio import logging import sys from pathlib import Path from typing import Any import pytest from test_room_fsm import ( make_bundle, make_level, make_room, seat_solo_host, ) from undercooked.config import Settings from undercooked.server.clock import FakeClock from undercooked.server.room import Room, RoomState from undercooked.server.rooms import RoomSupervisor from undercooked.server.sessions import new_session from undercooked.sim.content import ContentBundle, load_level_file from undercooked.sim.model import Event, EventKind, ItemKind, KitchenState, RoundResult, Vec2 from undercooked.sim.recipe import RecipeBook from undercooked.sim.step import PlayerInput from undercooked.sim.step import step as real_step pytestmark = pytest.mark.integration #: The scripted full-round harness lives with the sim's tests (test-only). _SIM_TESTS = Path(__file__).resolve().parents[1] / "sim" if str(_SIM_TESTS) not in sys.path: sys.path.insert(0, str(_SIM_TESTS)) from helpers import ( # noqa: E402 # type: ignore[reportMissingImports] LEVEL1_YAML, CookController, load_book, shelf_id_for, ) #: One 20 Hz tick, in wall seconds (the sim's `STEP_DT`). PERIOD = 0.05 def drive_countdown_via_clock(room: Room, clock: FakeClock) -> None: """The 3.0 s countdown in 0.05 s wall frames (clock-driven, the way the manual `tick_once` tests run); leaves the room in `PLAYING`.""" for _ in range(80): clock.advance(PERIOD) room.tick_once() if room.state is RoomState.PLAYING: break assert room.state is RoomState.PLAYING assert room.sim_state is not None def make_playing_room(**settings: Any) -> tuple[Room, FakeClock]: """A room in `PLAYING` with a built sim state (the countdown already ran).""" bundle = make_bundle(make_level("06_playing")) room, clock = make_room(bundle, **settings) seat_solo_host(room) room.request_start() drive_countdown_via_clock(room, clock) return room, clock # ------------------------------------------------------------- tick_once def test_tick_once_drives_the_sim_forward_under_a_scripted_input() -> None: room, clock = make_playing_room() state = room.sim_state assert state is not None start_x = state.players[0].pos.x # An empty press at open floor: a named refusal (a sim event in the # buffer, drained with the rest). The cook is on open floor at spawn, # facing down into a wall row: no target in reach at all. room.queue_input( "tok-a", PlayerInput(player_id="tok-a", action=True, face=state.players[0].facing), ) clock.advance(PERIOD) room.tick_once() events = room.take_events() denied = [e for e in events if e.kind is EventKind.DENIED] assert denied, "the empty press must land a named refusal in the buffer" assert denied[0].payload["reason"] == "nothing_here" # And 50 scripted move ticks move the cook (the queue feeds `step`). for _ in range(50): room.queue_input("tok-a", PlayerInput(player_id="tok-a", move=Vec2(1.0, 0.0))) clock.advance(PERIOD) room.tick_once() state = room.sim_state assert state is not None assert state.tick == 51 # one 0.05 s substep per wall 0.05 s: no slip player = state.players[0] assert player.pos.x > start_x # the scripted intent moved the cook assert player.facing is not None and player.facing.value == "right" # ---------------------------------------------------------------- cadence def test_tick_cadence_has_no_drift_over_100_manual_calls() -> None: # A 60 s level: the 100 ticks (5.0 s of sim) must not reach the verdict. bundle = make_bundle(make_level("06_cadence", time_limit=60.0)) room, clock = make_room(bundle) seat_solo_host(room) room.request_start() drive_countdown_via_clock(room, clock) assert room.sim_state is not None and room.sim_state.tick == 0 wall_at_start = clock.now() for _ in range(100): clock.advance(PERIOD) room.tick_once() state = room.sim_state assert state is not None assert state.tick == 100 # exactly 100 substeps: no tick lost or doubled # Wall time and sim time agree to the float: 100 ticks x 0.05 s = 5.0 s. assert abs((clock.now() - wall_at_start) - 5.0) < 1e-9 async def test_the_tick_loop_schedules_on_deadlines_without_drift() -> None: """The async loop (PLAN §6 "one asyncio tick task") at the 20 Hz deadline cadence: 100 ticks over exactly 5.0 s of `FakeClock` time — the first the room's prime (dt 0), 60 the 3.0 s countdown, 39 the round — with the wall time and the sim tick count agreeing to the float.""" clock = FakeClock() # A 60 s level: the 5.0 s of sim must not reach the verdict. bundle = make_bundle(make_level("06_loop", time_limit=60.0)) async def yield_sleep(_seconds: float) -> None: await asyncio.sleep(0) # the test is the time source (no pre-advance) room = Room( code="TEST1", settings=Settings(), clock=clock, content=bundle, level_id=bundle.levels[0].id, sleep=yield_sleep, ) seat_solo_host(room) room.request_start() room.start_ticking() assert room.task is not None and not room.task.done() await asyncio.sleep(0) # the room runs its first wait (0.05 s) and parks for _ in range(100): clock.advance(PERIOD) await asyncio.sleep(0) # the room ticks to the deadline: one tick per period state = room.sim_state assert state is not None assert room.state is RoomState.PLAYING assert state.tick == 39 # 100 ticks: 1 prime (dt 0) + 60 countdown + 39 playing assert abs(clock.now() - 5.0) < 1e-9 # 100 x 0.05 s, no drift assert room.task is not None and not room.task.done() # still ticking await room.close() assert room.state is RoomState.CLOSED and room.task.cancelled() async def test_the_tick_loop_resynchronises_after_a_clock_jump() -> None: """A clock jump (a lid, a test advancing the `FakeClock`) must not buy a catch-up burst: the room takes at most one (clamped) catch-up tick and then waits the normal period. A burst would show as an inflated sim tick count (each catch-up tick clamps to 0.1 s = 2 substeps).""" clock = FakeClock() bundle = make_bundle(make_level("06_jump")) async def yield_sleep(_seconds: float) -> None: await asyncio.sleep(0) # the test is the time source (no pre-advance) room = Room( code="TEST1", settings=Settings(), clock=clock, content=bundle, level_id=bundle.levels[0].id, sleep=yield_sleep, ) seat_solo_host(room) room.request_start() room.start_ticking() for _ in range(10): # 0.5 s: 1 prime + 9 decrements, the countdown is at 2.55 s clock.advance(PERIOD) await asyncio.sleep(0) clock.advance(10.0) # the jump await asyncio.sleep(0) # the one catch-up tick: dt 10.0 (clamped), the countdown ends for _ in range(10): # 0.5 s of playing clock.advance(PERIOD) await asyncio.sleep(0) state = room.sim_state assert state is not None assert room.state is RoomState.PLAYING assert state.tick == 10 # the 0.5 s of playing: no burst (a burst would be >= 200) assert abs(clock.now() - 11.0) < 1e-9 # 0.5 + 10.0 + 0.5 await room.close() # --------------------------------------------------------- crash isolation def test_an_injected_exception_aborts_and_closes_without_raising( monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: caplog.set_level(logging.ERROR, logger="undercooked.server.room") room, clock = make_playing_room() assert room.sim_state is not None and room.sim_state.result is None def boom(*args: object, **kwargs: object) -> tuple[KitchenState, tuple[Event, ...]]: msg = "sim exploded" raise RuntimeError(msg) monkeypatch.setattr("undercooked.server.room.step", boom) clock.advance(PERIOD) room.tick_once() # must not propagate assert room.state is RoomState.CLOSED assert room.sim_state is not None assert room.sim_state.result is RoundResult.ABORTED # the sim's `abort` seam assert room.abort_reason is not None assert "RuntimeError" in room.abort_reason events = room.take_events() crash_events = [ e for e in events if e.kind is EventKind.ERROR and e.payload.get("reason") == "tick_crash" ] assert len(crash_events) == 1 assert crash_events[0].payload["round"] == room.round_no # The log line carries the code and the round — never a player token. assert "TEST1" in caplog.text and "round 1" in caplog.text assert "tok-a" not in caplog.text def test_a_crash_building_the_round_closes_the_room( monkeypatch: pytest.MonkeyPatch, ) -> None: """`countdown_done` builds the round; a build that raises (a room with no level can be put in `COUNTDOWN` by the table directly) is crash-isolated the same way — the room ends `CLOSED`, no state, no propagation.""" room, clock = make_room(None) # no content bundle seat_solo_host(room) room._fire("host_start") # test setup: the table's row for _ in range(80): clock.advance(PERIOD) room.tick_once() assert room.state is RoomState.CLOSED # the build raised; the crash isolated it assert room.sim_state is None assert room.abort_reason is not None and "RuntimeError" in room.abort_reason async def test_the_tick_loop_swallows_the_crash_and_exits_cleanly( monkeypatch: pytest.MonkeyPatch, ) -> None: clock = FakeClock() bundle = make_bundle(make_level("06_loop_crash")) async def advancing_sleep(seconds: float) -> None: clock.advance(seconds) await asyncio.sleep(0) room = Room( code="TEST1", settings=Settings(), clock=clock, content=bundle, level_id=bundle.levels[0].id, sleep=advancing_sleep, ) seat_solo_host(room) room.request_start() def boom(*args: object, **kwargs: object) -> tuple[KitchenState, tuple[Event, ...]]: msg = "sim exploded" raise RuntimeError(msg) monkeypatch.setattr("undercooked.server.room.step", boom) room.start_ticking() for _ in range(80): if room.task is not None and room.task.done(): break await asyncio.sleep(0) assert room.state is RoomState.CLOSED assert room.task is not None and room.task.done() assert not room.task.cancelled() # the loop ran to its own exit assert room.sim_state is not None assert room.sim_state.result is RoundResult.ABORTED assert await room.task is None # no exception escaped the loop async def test_a_crash_closes_only_that_room(monkeypatch: pytest.MonkeyPatch) -> None: """The phase's completion criterion: an injected sim exception aborts and closes one room; a second live room (supervisor-registered, tick task running) keeps ticking, and the supervisor stays alive.""" clock = FakeClock() bundle = make_bundle(make_level("06_bad"), make_level("06_good")) async def parked_sleep(_seconds: float) -> None: await asyncio.Event().wait() # never set: the tasks run but never step supervisor = RoomSupervisor(Settings(), clock, bundle, sleep=parked_sleep) room_bad = supervisor.create_room(new_session("Ada", clock), "Ada") room_good = supervisor.create_room(new_session("Bob", clock), "Bob") await supervisor.start() # starts the GC loop and every room's tick task room_good.select_level("06_good") room_bad.request_start() room_good.request_start() for _ in range(80): # both to PLAYING clock.advance(PERIOD) room_bad.tick_once() room_good.tick_once() await asyncio.sleep(0) assert room_bad.state is RoomState.PLAYING assert room_good.state is RoomState.PLAYING assert room_good.task is not None and not room_good.task.done() # a live room assert room_good.sim_state is not None good_tick_before = room_good.sim_state.tick def selective( state: KitchenState, dt: float, inputs: tuple[PlayerInput, ...], book: RecipeBook | None, ) -> tuple[KitchenState, tuple[Event, ...]]: if state.level_id == "06_bad": msg = "sim exploded in the bad room" raise RuntimeError(msg) return real_step(state, dt, inputs, book) monkeypatch.setattr("undercooked.server.room.step", selective) for _ in range(30): clock.advance(PERIOD) room_bad.tick_once() # the first one crashes; the rest are no-ops room_good.tick_once() await asyncio.sleep(0) # The bad room: aborted and closed, its exception isolated. assert room_bad.state is RoomState.CLOSED assert room_bad.sim_state is not None assert room_bad.sim_state.result is RoundResult.ABORTED assert room_bad.abort_reason is not None # The good room: untouched — still playing, still ticking, task alive. assert room_good.state is RoomState.PLAYING assert room_good.sim_state is not None assert room_good.sim_state.tick == good_tick_before + 30 assert room_good.task is not None and not room_good.task.done() # And the supervisor itself is alive. assert supervisor.stats()["rooms"] == 2 await supervisor.shutdown() assert room_bad.state is RoomState.CLOSED assert room_good.state is RoomState.CLOSED # the shutdown teardown, not a crash # ------------------------------------------------------------------ level 1 def test_a_room_driven_by_tick_once_reaches_won_on_level_1() -> None: """The phase's completion criterion: a `Room` driven by `tick_once` — its own build (level + seat + seed + the room's shelf/prop seeding), its input queue, its settle — reaches `WON` on the shipped level 1 with the phase-04 scripted cook (the same policy `test_full_rounds.py` proves headless, through the room instead of the bare kernel).""" level = load_level_file(LEVEL1_YAML) bundle = ContentBundle(recipe_book=load_book(), levels=(level,)) clock = FakeClock() room = Room( code="TEST1", settings=Settings(), clock=clock, content=bundle, level_id=level.id, seed=0x5EED_0406, # the phase-04 harness's seed: round 1 plays it as-is ) room.add_seat("tok-0", "cook 1", True) room.request_start() # the solo host drive_countdown_via_clock(room, clock) assert room.sim_state is not None assert room.sim_state.seed == room.round_seed controller = CookController(0, shelf_id_for(room.sim_state, ItemKind.TOMATO), "any") for _ in range(4000): # 150 s of level + the settle, with slack if room.state is not RoomState.PLAYING: break clock.advance(PERIOD) if room.sim_state.result is None: frame = controller.next_frame(room.sim_state, room.sim_state.tick) assert room.queue_input(frame.player_id, frame) room.tick_once() assert room.state is RoomState.ROUND_END state = room.sim_state assert state is not None assert state.result is RoundResult.WON assert state.score >= state.target_score assert room.last_result is not None assert room.last_result["result"] == "won" assert room.last_result["score"] == state.score assert room.last_result["stars"] in (1, 2, 3) # a win grades at least one star # The verdict's events (incl. the one `ROUND_END`) landed in the buffer, # and the buffer drains exactly once. events = room.take_events() assert any(e.kind is EventKind.ROUND_END for e in events) assert any(e.kind is EventKind.SERVED for e in events) assert room.take_events() == () # the second drain is empty assert room.snapshot_seq == 2