Files
ducoterra f51ba919af phase: 05_anonymous_lobbies_and_room_lifecycle
All green. Final report:

**Phase 05 final verification pass — complete, 100% green.**

**Verified (all 5 tasks already in `complete/`):**
- `RoomSupervisor` import, `POST /api/rooms` → 5-char Crockford code (`VBE8Y`), `GET /api/rooms/{code}` → seat list, join/session/404 flows — all live over HTTP
- `docs/ROOMS.md` present with FSM diagram, crash-isolation and "What is lost on restart" sections
- Phase tests all pass: lifecycle 4-seat `WON`→`LOBBY` (7 tests), crash isolation, GC/idle via `FakeClock` (no sleeps), FSM, tick cadence, supervisor, sessions

**Results:**
- `uv run pytest` → **1506 passed, 0 failed**; coverage **98.89%** (>90% floor; `room.py` 99%, `rooms.py` 97%, `sessions.py` 99% — all ≥95% per-module gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run undercooked-levels validate` → content OK; server boots, `/healthz` 200, client at `/` 200
- e2e US-01 suite re-run after fixes: 10 passed

**Documentation — inconsistencies found and fixed:**
- `README.md`: status now lists phases 01–05 complete, next pending `06_realtime_websocket_protocol`; dropped "(phase 05, in progress)"; 3 stale "Not implemented until phase 05." references corrected
- `app/static/js/app.js` + `tests/e2e/test_us01_landing_surface.py`: placeholder string updated in lockstep to "Not implemented until phase 07." (phase 07 task 02 wires the buttons), comments updated to reflect phase-05 completion
- `AGENTS.md`: reviewed in full — §6 already documents all phase-05 modules accurately; no changes needed

**Decisions:** no code behavior changes beyond the user-visible placeholder string; no live-infrastructure touch (only a local loopback server, stopped).

**Next pending phase:** `06_realtime_websocket_protocol`.
2026-10-08 05:17:21 -04:00

816 lines
31 KiB
Python

"""Integration: the lobby (phase 05, task 03; US-02, US-03) — seats,
ready-up, the host's authority, level picking and spectators.
The contract, driven by hand on a `FakeClock` (no test sleeps):
* **ready-up** — `Seat.toggle_ready` / the room's `set_ready`, visible in
`lobby_payload` (the `ready` flags and `can_start`: a solo host, or ≥ 1
ready non-host, and a selected level, and a crown holder),
* **start** — `Room.start(token)`: `LOBBY` only, host only
(`NotInLobby` / `NotHost`), then task 02's preconditions as
`NothingToStart` (a `StartRefused` — the reason rides along),
* **level pick** — `Room.set_level(token, level_id)`: `LOBBY` only, host
only, the id in the campaign (`UnknownLevel`) and its `order` within
the requesting session's `unlocked_level` (`LevelLocked`; a token
without a session record counts as unlocked 1),
* **the host rules** — the creator's seat 0 is the host; when the crown
holder leaves or their connection drops in `LOBBY`/`ROUND_END` the
crown moves to the lowest occupied, *connected* seat; in
`COUNTDOWN`/`PLAYING` the host slot is emptied and **no mid-round
election happens** (asserted explicitly); a returning host does not
automatically regain the crown,
* **spectators** — a `join` in `COUNTDOWN`/`PLAYING`/`ROUND_END` adds a
spectator (no seat, `role="spectator"`, a colour from the same
first-free palette); a spectator's input is an `err not_seated`; a
`leave` removes the entry; `to_lobby` promotes them into the free
seats (PLAN §6: "late joins wait in `SPECTATING` until `LOBBY`"),
* **colours** — the 4-colour palette, first free in seat order; a
token's colour is remembered for the life of the room (a reconnect
keeps it),
* **emotes** — the `EMOTES` whitelist (`UnknownEmote`), the 2-per-1.0 s
per-token throttle, re-broadcast as an `ev` event,
* **names** — sanitized at the boundary (a markup name comes back ≤ 16
chars, markup-free).
"""
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 Room, RoomState, StartRefused
from undercooked.server.rooms import PlayerNotInRoom, RoomSupervisor
from undercooked.server.seats import (
EMOTE_EVENT_KIND,
EMOTES,
SEAT_PALETTE,
LevelLocked,
NothingToStart,
NotHost,
NotInLobby,
Seat,
Spectator,
UnknownEmote,
UnknownLevel,
)
from undercooked.server.sessions import SessionRecord, new_session
from undercooked.sim.content import ContentBundle
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import EventKind, ItemForm, ItemKind, 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`."""
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, order: int, *, time_limit: float = 3.0, target: int = 1000
) -> Level:
"""A 7x4 kitchen (one spawn shelf, one serve window) at campaign
`order`; `target` above any idle score, so a round 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"Lobby kitchen {level_id}",
"order": order,
"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 two_level_bundle() -> ContentBundle:
return make_bundle(make_level("05a_first", 1), make_level("05b_second", 2))
def make_room(
bundle: ContentBundle | None = None,
sessions: dict[str, SessionRecord] | None = None,
**settings: Any,
) -> tuple[Room, FakeClock]:
"""A `LOBBY` room on a `FakeClock`; `sessions` (or its absence) is
the room's token → session lookup (`None` = no lookup: a token counts
as unlocked 1)."""
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,
sessions=(sessions or {}).get,
)
return room, clock
def make_supervisor(
bundle: ContentBundle | None = None, **settings: Any
) -> tuple[RoomSupervisor, FakeClock]:
clock = FakeClock()
supervisor = RoomSupervisor(Settings(**settings), clock, bundle or two_level_bundle())
return supervisor, clock
def prime(room: Room, now: float = 0.0) -> None:
room.tick_once(now=now)
def drive_countdown(room: Room, now: float = 0.0) -> float:
"""The 3 s countdown in 1 s frames; returns the final `now`, with the
room in `PLAYING` (the round built)."""
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:
"""1 s frames through the 3.0 s round and its 1.0 s settle window,
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
def seat(room: Room, token: str) -> Seat:
"""Narrow `seat_for_token` for the tests (the token is seated)."""
got = room.seat_for_token(token)
assert got is not None
return got
def watching(room: Room, token: str) -> Spectator:
"""Narrow `spectator_for_token` for the tests (the token is watching)."""
got = room.spectator_for_token(token)
assert got is not None
return got
def seat_host_and_ready(room: Room) -> None:
room.add_seat("tok-a", "Ada", True)
room.add_seat("tok-b", "Bob", False)
assert room.set_ready("tok-b", True)
# ---------------------------------------------------------------- ready / start
def test_ready_toggles_are_visible_in_the_lobby_payload() -> None:
room, _ = make_room(two_level_bundle())
room.add_seat("tok-a", "Ada", True)
room.add_seat("tok-b", "Bob", False)
cy = room.add_seat("tok-c", "Cy", False)
payload: dict[str, Any] = room.lobby_payload()
assert [seat["ready"] for seat in payload["seats"]] == [False, False, False]
assert payload["can_start"] is False # a host, a level, but nobody ready
assert cy.toggle_ready() is True
assert cy.toggle_ready() is False # the toggle flips back
assert seat(room, "tok-b").toggle_ready() is True
lobby_view: dict[str, Any] = room.lobby_payload()
ready = {seat["index"]: seat["ready"] for seat in lobby_view["seats"]}
assert ready == {0: False, 1: True, 2: False}
assert room.lobby_payload()["can_start"] is True # one ready non-host is enough
assert room.set_ready("tok-b", False)
assert room.lobby_payload()["can_start"] is False
def test_solo_start_is_allowed_and_the_payload_says_can_start() -> None:
room, _ = make_room(two_level_bundle())
room.add_seat("tok-a", "Ada", True)
assert room.lobby_payload()["can_start"] is True # a solo host: no ready needed
room.start("tok-a")
assert room.state is RoomState.COUNTDOWN
assert room.round_no == 1
def test_start_requires_a_ready_non_host_and_names_the_refusal() -> None:
room, _ = make_room(two_level_bundle())
seat_host_and_ready(room)
seat(room, "tok-b").ready = False # the ready-up is not there yet
with pytest.raises(NothingToStart, match="no_ready_players") as exc_info:
room.start("tok-a")
assert exc_info.value.reason == "no_ready_players"
assert isinstance(exc_info.value, StartRefused) # the task-02 contract rides along
assert room.state is RoomState.LOBBY # the refusal leaves no state change
room.set_ready("tok-b", True)
room.start("tok-a")
assert room.state is RoomState.COUNTDOWN
def test_start_without_a_level_refuses_no_level() -> None:
room, _ = make_room(None) # no content bundle: no level selected
room.add_seat("tok-a", "Ada", True)
with pytest.raises(NothingToStart, match="no_level"):
room.start("tok-a")
assert room.state is RoomState.LOBBY
def test_start_and_set_level_by_a_non_host_are_rejected() -> None:
bundle = two_level_bundle()
sessions = {"tok-b": SessionRecord("tok-b", "Bob", 0.0, 2)}
room, _ = make_room(bundle, sessions)
room.add_seat("tok-a", "Ada", True)
room.add_seat("tok-b", "Bob", False)
room.set_ready("tok-b", True) # so the host's start can run past the guards
with pytest.raises(NotHost):
room.start("tok-b")
with pytest.raises(NotHost):
room.start("tok-stranger")
with pytest.raises(NotHost):
room.set_level("tok-b", "05a_first")
# Outside the lobby, neither verb runs — even for the host.
room.start("tok-a")
assert room.state is RoomState.COUNTDOWN
with pytest.raises(NotInLobby):
room.start("tok-a")
with pytest.raises(NotInLobby):
room.set_level("tok-a", "05a_first")
# ------------------------------------------------------------- level picking
def test_set_level_requires_a_known_unlocked_level() -> None:
sessions = {
"tok-a": SessionRecord("tok-a", "Ada", 0.0, 1),
"tok-b": SessionRecord("tok-b", "Bob", 0.0, 2),
}
room, _ = make_room(two_level_bundle(), sessions)
room.add_seat("tok-a", "Ada", True)
with pytest.raises(UnknownLevel):
room.set_level("tok-a", "05_zzz")
with pytest.raises(LevelLocked, match="order 2"):
room.set_level("tok-a", "05b_second") # the session is unlocked 1
assert room.level_id == "05a_first" # the refusal leaves the pick unchanged
room.add_seat("tok-b", "Bob", False)
seat(room, "tok-a").host = False # the crown moves to Bob (unlocked 2)
seat(room, "tok-b").host = True
level = room.set_level("tok-b", "05b_second")
assert level.id == "05b_second"
assert room.level_id == "05b_second"
def test_set_level_without_a_session_lookup_counts_as_unlocked_1() -> None:
room, _ = make_room(two_level_bundle()) # no sessions map at all
room.add_seat("tok-a", "Ada", True)
with pytest.raises(LevelLocked):
room.set_level("tok-a", "05b_second")
with pytest.raises(UnknownLevel):
room.set_level("tok-a", "05_zzz")
picked = room.set_level("tok-a", "05a_first") # the floor is always pickable
assert picked.id == "05a_first"
def test_set_level_refuses_an_id_outside_a_bundleless_campaign() -> None:
room, _ = make_room(None) # no content bundle: no campaign to pick from
room.add_seat("tok-a", "Ada", True)
with pytest.raises(UnknownLevel):
room.set_level("tok-a", "05a_first")
# ---------------------------------------------------------------- the host rules
def test_host_handoff_in_the_lobby_goes_to_the_lowest_connected_seat() -> None:
room, _ = make_room(two_level_bundle())
room.add_seat("tok-a", "Ada", True)
room.add_seat("tok-b", "Bob", False)
room.add_seat("tok-c", "Cy", False)
# A dropped host connection is a host leave: the crown moves to the
# lowest occupied, *connected* seat.
assert room.mark_seat_disconnected("tok-a")
assert not seat(room, "tok-a").host
assert seat(room, "tok-b").host is True # seat 1, the lowest connected
assert room.lobby_payload()["host_seat"] == 1
# The host's leave does the same: on to the next connected seat.
assert room.remove_seat("tok-b")
assert seat(room, "tok-c").host is True # seat 2
# When no connected seat remains, the crown is left empty.
room.mark_seat_disconnected("tok-c")
room.mark_seat_disconnected("tok-a")
assert not any(seat.host for seat in room.seats)
assert room.lobby_payload()["host_seat"] is None
assert room.lobby_payload()["can_start"] is False # nobody can press Start
def test_host_handoff_in_round_end_elects_the_lowest_connected_seat() -> None:
room, _ = make_room(two_level_bundle())
seat_host_and_ready(room)
room.start("tok-a")
now = drive_countdown(room)
now = play_round_to_end(room, now)
assert room.state is RoomState.ROUND_END
assert room.mark_seat_disconnected("tok-a")
assert seat(room, "tok-b").host is True # elected in ROUND_END
assert room.lobby_payload()["host_seat"] == 1
def test_no_mid_round_host_election_happens() -> None:
"""The documented rule, asserted explicitly (not by omission): in
`COUNTDOWN`/`PLAYING` the host slot is emptied when the host leaves
or drops — and the round continues, with no election, until
`ROUND_END` (and no election happens on the state change either)."""
room, _ = make_room(two_level_bundle())
seat_host_and_ready(room)
room.start("tok-a")
now = drive_countdown(room) # PLAYING
assert room.mark_seat_disconnected("tok-a")
assert all(not seat.host for seat in room.seats) # the host slot is empty
assert room.lobby_payload()["host_seat"] is None
assert room.state is RoomState.PLAYING # the round continues
now = play_round_to_end(room, now)
assert all(not seat.host for seat in room.seats) # still empty at ROUND_END
assert room.lobby_payload()["host_seat"] is None
assert room.lobby_payload()["can_start"] is False
# The host's *leave* mid-round: the same rule.
room2, _ = make_room(two_level_bundle())
seat_host_and_ready(room2)
room2.start("tok-a")
drive_countdown(room2)
assert room2.remove_seat("tok-a")
assert all(not seat.host for seat in room2.seats)
assert room2.state is RoomState.PLAYING
def test_a_returning_host_does_not_regain_the_crown() -> None:
room, _ = make_room(two_level_bundle())
room.add_seat("tok-a", "Ada", True)
room.add_seat("tok-b", "Bob", False)
room.mark_seat_disconnected("tok-a") # the crown moves to Bob
assert seat(room, "tok-b").host is True
# Ada's connection returns: the seat is live again, the crown stays.
assert room.mark_seat_connected("tok-a")
assert seat(room, "tok-a").connected is True
assert not seat(room, "tok-a").host
assert seat(room, "tok-b").host is True
# Ada leaves the lobby and re-joins: the fresh seat is not the host.
room.remove_seat("tok-a")
again = room.add_seat("tok-a", "Ada", False)
assert again.host is False
assert seat(room, "tok-b").host is True
# --------------------------------------------------------------- spectators
def test_join_during_play_becomes_a_spectator() -> None:
supervisor, clock = make_supervisor(two_level_bundle())
ada = new_session("Ada", clock)
room = supervisor.create_room(ada, "Ada")
bob = new_session("Bob", clock)
supervisor.join(room.code, bob.token, "Bob")
assert room.set_ready(bob.token, True)
room.start(ada.token)
drive_countdown(room) # PLAYING
cy = new_session("Cy", clock)
supervisor.join(room.code, cy.token, "Cy")
assert room.seat_for_token(cy.token) is None # no seat
spec = room.spectator_for_token(cy.token)
assert spec is not None
assert spec.role == "spectator" and spec.connected
assert spec.display_name == "Cy"
assert spec.color == SEAT_PALETTE[2] # first free, after the two seats
payload = room.lobby_payload(ada.token)
assert payload["spectators"] == [{"name": "Cy", "color": SEAT_PALETTE[2]}]
assert supervisor.stats()["spectators"] == 1
# The token index still finds the watcher (by_token works for them).
assert supervisor.by_token(cy.token) is room
def test_a_full_room_still_accepts_spectators() -> None:
supervisor, clock = make_supervisor(two_level_bundle())
eve = new_session("Eve", clock)
room = supervisor.create_room(eve, "Eve")
tokens = [eve.token]
for name in ("Fay", "Gus", "Hal"):
token = new_session(name, clock).token
supervisor.join(room.code, token, name)
tokens.append(token)
assert len(room.seats) == 4 # max_players
room.set_ready(tokens[1], True)
room.start(eve.token)
drive_countdown(room)
imran = new_session("Imran", clock)
supervisor.join(room.code, imran.token, "Imran") # the 5th player: a watcher
assert room.seat_for_token(imran.token) is None
assert room.spectator_for_token(imran.token) is not None
# Four seats take the whole palette: the late watcher has no colour.
assert watching(room, imran.token).color == ""
def test_join_during_countdown_becomes_a_spectator() -> None:
supervisor, clock = make_supervisor(two_level_bundle())
ida = new_session("Ida", clock)
room = supervisor.create_room(ida, "Ida")
room.start(ida.token) # a solo host: the countdown runs
prime(room) # the 3 s is in flight
jon = new_session("Jon", clock)
supervisor.join(room.code, jon.token, "Jon")
assert room.seat_for_token(jon.token) is None
assert room.spectator_for_token(jon.token) is not None
def test_spectator_input_is_rejected_with_not_seated() -> None:
"""A spectator's `input` is ignored — as a named `err`, not a
silent no-op (and distinct from `unknown_player`)."""
supervisor, clock = make_supervisor(two_level_bundle())
ada = new_session("Ada", clock)
room = supervisor.create_room(ada, "Ada")
room.start(ada.token)
drive_countdown(room)
cy = new_session("Cy", clock)
supervisor.join(room.code, cy.token, "Cy")
assert room.queue_input(cy.token, PlayerInput(player_id=cy.token)) is False
events = room.take_events()
assert len(events) == 1
assert events[0].kind is EventKind.ERROR
assert events[0].payload == {
"reason": "not_seated",
"player_id": cy.token,
"seq": 0,
}
def test_spectator_leave_removes_the_entry_and_disconnect_keeps_it() -> None:
supervisor, clock = make_supervisor(two_level_bundle())
ada = new_session("Ada", clock)
room = supervisor.create_room(ada, "Ada")
room.start(ada.token)
drive_countdown(room)
cy = new_session("Cy", clock)
supervisor.join(room.code, cy.token, "Cy")
supervisor.mark_disconnected(room.code, cy.token) # the entry is kept
spec = room.spectator_for_token(cy.token)
assert spec is not None and spec.connected is False
supervisor.leave(room.code, cy.token)
assert room.spectator_for_token(cy.token) is None
assert supervisor.stats()["spectators"] == 0
with pytest.raises(PlayerNotInRoom):
supervisor.leave(room.code, cy.token)
def test_spectator_rejoin_keeps_the_entry_and_the_color() -> None:
supervisor, clock = make_supervisor(two_level_bundle())
ada = new_session("Ada", clock)
room = supervisor.create_room(ada, "Ada")
room.start(ada.token)
drive_countdown(room)
cy = new_session("Cy", clock)
supervisor.join(room.code, cy.token, "Cy")
color = watching(room, cy.token).color
assert color == SEAT_PALETTE[1] # first free, after Ada's seat
supervisor.mark_disconnected(room.code, cy.token)
supervisor.join(room.code, cy.token, "Cy") # the re-watch
spec = watching(room, cy.token)
assert spec.connected is True
assert spec.color == color # the same entry, the same colour
def test_spectators_are_promoted_to_free_seats_on_to_lobby() -> None:
supervisor, clock = make_supervisor(two_level_bundle())
ada = new_session("Ada", clock)
room = supervisor.create_room(ada, "Ada")
bob = new_session("Bob", clock)
supervisor.join(room.code, bob.token, "Bob")
assert room.set_ready(bob.token, True)
room.start(ada.token)
now = drive_countdown(room)
cy = new_session("Cy", clock)
di = new_session("Di", clock)
supervisor.join(room.code, cy.token, "Cy")
supervisor.join(room.code, di.token, "Di")
now = play_round_to_end(room, now)
room.to_lobby()
assert room.state is RoomState.LOBBY
assert len(room.seats) == 4 # both watchers were promoted
assert room.spectators == []
assert not any(seat.ready for seat in room.seats) # a fresh lobby readies up
for token in (cy.token, di.token):
seat = room.seat_for_token(token)
assert seat is not None and seat.host is False and seat.connected
def test_a_spectator_of_a_full_room_stays_watching_on_to_lobby() -> None:
supervisor, clock = make_supervisor(two_level_bundle())
eve = new_session("Eve", clock)
room = supervisor.create_room(eve, "Eve")
tokens = [eve.token]
for name in ("Fay", "Gus", "Hal"):
token = new_session(name, clock).token
supervisor.join(room.code, token, name)
tokens.append(token)
room.set_ready(tokens[1], True)
room.start(eve.token)
now = drive_countdown(room)
imran = new_session("Imran", clock)
supervisor.join(room.code, imran.token, "Imran")
now = play_round_to_end(room, now)
room.to_lobby()
assert len(room.seats) == 4 # the room is full: nobody new can sit
assert len(room.spectators) == 1
assert room.spectator_for_token(imran.token) is not None
# ------------------------------------------------------------------ colours
def test_colors_are_first_free_in_seat_order() -> None:
room, _ = make_room(two_level_bundle())
ada = room.add_seat("tok-a", "Ada", True)
bob = room.add_seat("tok-b", "Bob", False)
cy = room.add_seat("tok-c", "Cy", False)
dee = room.add_seat("tok-d", "Dee", False)
assert (ada.color, bob.color, cy.color, dee.color) == SEAT_PALETTE
# Seat 2 leaves: the next joiner takes the first free colour (c2).
room.remove_seat("tok-c")
eve = room.add_seat("tok-e", "Eve", False)
assert eve.seat_index == 2 and eve.color == SEAT_PALETTE[2]
# Seat 0 leaves: the next joiner takes the first free colour (c0).
room.remove_seat("tok-a")
fred = room.add_seat("tok-f", "Fred", False)
assert fred.seat_index == 0 and fred.color == SEAT_PALETTE[0]
def test_a_returning_token_keeps_its_color_over_the_first_free() -> None:
room, _ = make_room(two_level_bundle())
room.add_seat("tok-a", "Ada", True) # palette[0]
room.add_seat("tok-b", "Bob", False) # palette[1]
room.add_seat("tok-c", "Cy", False) # palette[2]
room.remove_seat("tok-b")
room.add_seat("tok-d", "Dee", False) # seat 1, first free = palette[1]
room.remove_seat("tok-a") # palette[0] is now the first free
bob2 = room.add_seat("tok-b", "Bob", False) # seat 0: first free = palette[0]
assert bob2.seat_index == 0
assert bob2.color == SEAT_PALETTE[1] # Bob's remembered colour wins
# ------------------------------------------------------------------ emotes
def test_emote_whitelist_and_throttle() -> None:
room, _ = make_room(two_level_bundle())
room.add_seat("tok-a", "Ada", True)
assert room.emote("tok-a", "wave") is True # t=0
assert room.emote("tok-a", "cheer", now=0.1) is True
assert room.emote("tok-a", "star", now=0.2) is False # the 3rd in 1 s: dropped
assert room.emote("tok-a", "star", now=1.1) is True # the window has passed
events = room.take_events()
assert [(e.kind, dict(e.payload)) for e in events] == [
(EMOTE_EVENT_KIND, {"emote": "wave", "player_id": "tok-a"}),
(EMOTE_EVENT_KIND, {"emote": "cheer", "player_id": "tok-a"}),
(EMOTE_EVENT_KIND, {"emote": "star", "player_id": "tok-a"}),
]
# The per-token windows are independent.
room.add_seat("tok-b", "Bob", False)
assert room.emote("tok-b", "wave", now=0.2) is True # unthrottled for Bob
with pytest.raises(UnknownEmote):
room.emote("tok-a", "dance") # outside the whitelist: a named refusal
assert room.emote("tok-stranger", "wave") is False # an unknown token: dropped
assert set(EMOTES) == {"wave", "cheer", "thumbs_up", "thumbs_down", "angry", "star"}
def test_emotes_are_dropped_outside_the_emote_states_and_for_spectators_too() -> None:
room, _ = make_room(two_level_bundle())
room.add_seat("tok-a", "Ada", True)
room.add_spectator("tok-b", "Bob")
assert room.emote("tok-b", "wave") is True # a seated-or-watching token may
room.start("tok-a") # COUNTDOWN
assert room.emote("tok-a", "wave") is False # dropped, not an error
events = room.take_events()
# Only the accepted LOBBY emote is buffered; the dropped one is not.
assert len(events) == 1
assert events[0].kind is EMOTE_EVENT_KIND
assert events[0].payload == {"emote": "wave", "player_id": "tok-b"}
# ------------------------------------------------------------- the payload
def test_the_lobby_payload_shape_and_viewer_unlock() -> None:
sessions = {"tok-a": SessionRecord("tok-a", "Ada", 0.0, 2)}
room, _ = make_room(two_level_bundle(), sessions)
room.add_seat("tok-a", "Ada", True)
room.add_seat("tok-b", "Bob", False)
room.set_ready("tok-b", True)
room.add_spectator("tok-c", "Cy")
payload: dict[str, Any] = room.lobby_payload("tok-a")
assert set(payload) == {
"code",
"state",
"level",
"host_seat",
"can_start",
"seats",
"spectators",
"unlocked",
"round_no",
}
assert payload["code"] == room.code
assert payload["state"] == "lobby"
assert payload["level"] == "05a_first"
assert payload["host_seat"] == 0
assert payload["can_start"] is True
assert payload["round_no"] == 0
assert payload["unlocked"] == 2 # the viewer's session
assert set(payload["seats"][0]) == {
"index",
"name",
"color",
"ready",
"host",
"connected",
}
assert [seat["host"] for seat in payload["seats"]] == [True, False]
assert [seat["name"] for seat in payload["seats"]] == ["Ada", "Bob"]
assert payload["seats"][0]["color"] == SEAT_PALETTE[0]
assert payload["spectators"] == [{"name": "Cy", "color": SEAT_PALETTE[2]}]
# An anonymous viewer sees no unlock, and a token never leaks out.
assert room.lobby_payload()["unlocked"] is None
assert room.lobby_payload("tok-stranger")["unlocked"] is None
blob = str(room.lobby_payload("tok-a"))
assert "tok-a" not in blob and "tok-c" not in blob
# -------------------------------------------------- names at the boundary
def test_a_markup_display_name_comes_back_sanitized() -> None:
"""US-02 criterion 6, at the boundary: the markup name is stored
sanitized (≤ 16 chars, markup-free) for a create and a join."""
supervisor, clock = make_supervisor(two_level_bundle())
room = supervisor.create_room(new_session(None, clock), "<script>alert(1)</script>")
name = room.seats[0].display_name
assert name == "scriptalert(1)/s"
assert len(name) <= 16
assert not {"<", ">", '"'} & set(name)
cy = new_session(None, clock)
supervisor.join(room.code, cy.token, "<script>alert(1)</script>")
assert seat(room, cy.token).display_name == "scriptalert(1)/s"
# A whitespace-only name falls back to the seat number.
dee = new_session(None, clock)
supervisor.join(room.code, dee.token, " ")
assert seat(room, dee.token).display_name == "Cook #3"
# -------------------------------------------------- seats' own methods
def test_the_seat_and_spectator_verbs() -> None:
"""The `Seat`/`Spectator` verbs and the room's bookkeeping edges:
`is_host`, `next_seat_index` on a full room, the not-seated / not-
watching refusals, the `add_spectator` reclaim, the session lookup's
`None` branch."""
room, _ = make_room(two_level_bundle())
host = room.add_seat("tok-a", "Ada", True)
room.add_seat("tok-b", "Bob", False)
assert host.is_host is True
assert seat(room, "tok-b").is_host is False
assert room.mark_seat_connected("tok-stranger") is False # not seated
assert room.mark_spectator_connected("tok-stranger") is False # not watching
# The reclaim path: a second `add_spectator` for the same token keeps
# the entry, re-connects it, and updates the name when a new one is
# typed (an empty name keeps the old one).
first = room.add_spectator("tok-b", "Bob")
assert first.color == SEAT_PALETTE[2] # first free, after the two seats
again = room.add_spectator("tok-b", "Bobby")
assert again is first and again.connected is True
assert again.display_name == "Bobby"
room.add_spectator("tok-b", "")
assert again.display_name == "Bobby" # an empty name keeps the old one
# A full room: `next_seat_index` names the refusal, `add_seat` refuses.
room.add_seat("tok-c", "Cy", False)
room.add_seat("tok-d", "Dee", False)
assert room.is_full
with pytest.raises(IndexError, match="no free seat"):
room.next_seat_index()
with pytest.raises(ValueError, match="is full"):
room.add_seat("tok-e", "Eve", False)
# A test-built room (no `sessions` argument): the lookup is `None`, and
# `_session_for` says so (a token counts as unlocked 1).
bare2 = Room(code="T2", settings=Settings(), clock=FakeClock())
assert bare2.sessions is None
assert bare2._session_for("tok-anyone") is None
# -------------------------------------------------- sessions at the boundary
def test_a_join_with_an_unknown_token_mints_a_session() -> None:
supervisor, clock = make_supervisor(two_level_bundle())
supervisor.create_room(new_session("Ada", clock), "Ada")
room = supervisor.all_rooms()[0]
assert supervisor.session_for("tok-unknown") is None
supervisor.join(room.code, "tok-unknown", "Zed")
record = supervisor.session_for("tok-unknown")
assert record is not None
assert record.display_name == "Zed"
assert record.unlocked_level == 1 # a fresh session: the campaign's floor
def test_a_known_session_record_is_left_alone_on_rejoin() -> None:
supervisor, clock = make_supervisor(two_level_bundle())
supervisor.create_room(new_session("Ada", clock), "Ada")
room = supervisor.all_rooms()[0]
unlocked = SessionRecord("tok-u2", "U2", 0.0, 3)
supervisor.create_room(unlocked, None) # U2 owns a room of their own
supervisor.join(room.code, "tok-u2", None) # evicted from their room
assert supervisor.session_for("tok-u2") is unlocked # the record is intact
assert unlocked.unlocked_level == 3