Files
undercooked/tests/integration/test_room_fsm.py
T
ducoterra f26849c1f3 phase: 06_realtime_websocket_protocol
All green. Final verification complete — writing the report.

**Final pass report — phase 06 `realtime_websocket_protocol`**

- Verified: all 6 task files present in `complete/`; this was the final verification pass (no implementation work outstanding)
- `uv run ruff check .` → all checks passed; `uv run pyright` → 0 errors
- `uv run undercooked-levels validate` → content OK; `uv run pytest` (full suite) → **1751 passed, 0 failed, coverage 97.30% (>90% floor)**; slow sim stage (`-m slow tests/sim --no-cov`) → 2 passed; repo hygiene clean
- App launches: `/healthz` 200, `/` 200, `/api/levels`, `/api/config`, `POST /api/rooms` (201), `/api/stats` all verified live on an ephemeral port (server cleaned up)
- Criteria: scripted client room→hello→ready→start→countdown→≥20 snaps→round — PASS (`test_ws_round_two_clients.py`); identical `round` payloads both clients — PASS (`round_a == round_b`); version mismatch → `err protocol_mismatch` + close `4400` — PASS; 64 KB refused / 4 KB boundary, 500-burst → `rate_limited` → `4404` with bounded queue, 2000 malformed + 200 handshakes within memory bound — PASS; snap ≤ 1.5 KB (4p/4o/3h) and 64×64 ≤ 4 KB, downlink ≤ 60 KB/s over 100 ticks — PASS; `docs/PROTOCOL.md` model-sync test-enforced in both directions + full-round sequence diagram present — PASS
- Docs: README.md had 3 stale spots — status section (claimed 01–05 complete, 06 pending → now 01–06 complete, 07 pending), "until phases 06/07 wire the transport" wording, and the `PROTOCOL.md` layout note ("phase 06 completes it"). All fixed. AGENTS.md verified consistent — no changes needed
- No defects found in completed phases; no live infrastructure touched

Next pending phase: `07_canvas_client_and_hud`.
2026-10-09 11:13:25 -04:00

700 lines
25 KiB
Python

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