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`.
700 lines
25 KiB
Python
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
|