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`.
This commit is contained in:
2026-10-09 11:13:25 -04:00
parent f51ba919af
commit f26849c1f3
44 changed files with 11525 additions and 72 deletions
+45
View File
@@ -0,0 +1,45 @@
# levels/01_first_kitchen.yaml — campaign level 1, the tutorial kitchen.
# Schema: docs/LEVELS.md · validated at startup and by `undercooked-levels
# validate`. Every '#' is wall, every station stands on walkable floor.
id: 01_first_kitchen
name: First Kitchen
order: 1
difficulty: 1
time_limit: 150
target_score: 45
blurb: >-
Your first shift: grab a tomato and an onion from the shelves, chop them,
cook the soup, plate it, and run it to the serve window before the clock
runs out. One dish, one of everything, nowhere to bump into each other.
grid:
- "############"
- "#T..C...S..#"
- "#..........#"
- "#.P......X.#"
- "#..........#"
- "#..K....O..#"
- "#..........#"
- "#..........#"
- "############"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"O": {kind: floor, station: spawn_shelf, item: onion}
"C": {kind: floor, station: chop_board}
"S": {kind: floor, station: stove}
"P": {kind: floor, station: prep}
"X": {kind: floor, station: serve}
"K": {kind: floor, station: sink}
spawns: [[2, 7], [9, 7]]
orders:
pool: [soup]
concurrent: 1
spawn_interval: [16, 20]
deadline: 60
hazards: []
escalation_notes: >-
Tutorial baseline (PLAN §8 campaign plan): one recipe, one order at a time,
slowest spawn interval, longest time budget, open floor, no hazards. Every
later level moves at least two axes up from these numbers.
+80
View File
@@ -0,0 +1,80 @@
# levels/recipes.yaml — the campaign recipe book (PLAN §5/§8, A8).
#
# Every dish the game knows lives here; no Python enumerates recipes. The
# schema is `src/undercooked/sim/recipe.py` and the reference is
# docs/LEVELS.md — every model is strict (`extra="forbid"`), so a typo here
# fails the load loudly instead of silently defaulting.
#
# Step lists mirror PLAN §5's station pipelines: pick from the spawn shelf,
# chop (~0.9 s), cook on the stove, plate at the prep station, serve at the
# window. `seconds` feeds only the validator's greedy target-plausibility
# bound; the simulation (phase 04) re-enacts these steps per tick.
# Catalog of the item kinds the campaign's dishes consume (documentation of
# intent; spawn shelves are what actually put items on the floor).
ingredients: [tomato, onion, lettuce, dough, patty, rice, fish, plate]
recipes:
# tomato + onion -> chop -> pot on the stove -> plate
soup:
id: soup
name: Tomato Soup
base_score: 40
ingredients:
- {kind: tomato, form: raw}
- {kind: onion, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5.0}
- {kind: plate, station: prep, seconds: 0.4}
- {kind: serve, station: serve}
tags: [hot, soup]
# lettuce + tomato -> chop -> plate; the fast, no-fire dish
salad:
id: salad
name: Garden Salad
base_score: 30
ingredients:
- {kind: lettuce, form: raw}
- {kind: tomato, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: plate, station: prep, seconds: 0.4}
- {kind: serve, station: serve}
tags: [cold, quick]
# rice + onion straight onto the stove (PLAN §5: raw rice needs no board)
fried_rice:
id: fried_rice
name: Fried Rice
base_score: 50
ingredients:
- {kind: rice, form: raw}
- {kind: onion, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: cook, station: stove, seconds: 6.0}
- {kind: plate, station: prep, seconds: 0.4}
- {kind: serve, station: serve}
tags: [hot, grain]
# patty + dough + lettuce -> chop + pan on the stove -> plate. Defined now;
# later levels (phase 11) put it in an order pool.
burger:
id: burger
name: Chef's Burger
base_score: 70
ingredients:
- {kind: patty, form: raw}
- {kind: dough, form: raw}
- {kind: lettuce, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5.0}
- {kind: plate, station: prep, seconds: 0.5}
- {kind: serve, station: serve}
tags: [hot, stacked]
+59
View File
@@ -0,0 +1,59 @@
# tests/fixtures/levels_round/01_first_kitchen.yaml — the shipped campaign
# level 1, verbatim in layout and content, with two numbers tuned for the
# phase-06 two-client round suite (tests/integration/test_ws_round_two_clients.py).
#
# `target_score` is 20 (the shipped 45): one served soup scores 44-66
# (`round(40 x 1.0 x 1.1 x (1 + 0.5 x fraction))` — sim/scoring.py), so the
# first serve always crosses the target and the round ends `WON` on that
# substep, whatever the wall-clock jitter of a live 20 Hz tick. The shipped
# 150 s / 45 target would make a live-suite win depend on choreography timing
# (a second serve within the 60 s order deadline); the wire contract the
# suite locks (the round's events, payload and lobby return) is the same.
#
# `time_limit` is 60 s (the shipped 150): a safety margin around the ~25 s
# scripted soup. If the choreography ever stalls, the round ends `LOST` at the
# buzzer instead of running a two-and-a-half-minute test.
#
# Schema: docs/LEVELS.md — validated by the server's content gate at boot and
# by `undercooked-levels validate` on a copy.
id: 01_first_kitchen
name: First Kitchen
order: 1
difficulty: 1
time_limit: 60
target_score: 20
blurb: >-
Your first shift: grab a tomato and an onion from the shelves, chop them,
cook the soup, plate it, and run it to the serve window before the clock
runs out. One dish, one of everything, nowhere to bump into each other.
grid:
- "############"
- "#T..C...S..#"
- "#..........#"
- "#.P......X.#"
- "#..........#"
- "#..K....O..#"
- "#..........#"
- "#..........#"
- "############"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"O": {kind: floor, station: spawn_shelf, item: onion}
"C": {kind: floor, station: chop_board}
"S": {kind: floor, station: stove}
"P": {kind: floor, station: prep}
"X": {kind: floor, station: serve}
"K": {kind: floor, station: sink}
spawns: [[2, 7], [9, 7]]
orders:
pool: [soup]
concurrent: 1
spawn_interval: [16, 20]
deadline: 60
hazards: []
escalation_notes: >-
Integration-suite copy of the tutorial baseline (PLAN §8 campaign plan):
one recipe, one order at a time, open floor, no hazards.
+80
View File
@@ -0,0 +1,80 @@
# levels/recipes.yaml — the campaign recipe book (PLAN §5/§8, A8).
#
# Every dish the game knows lives here; no Python enumerates recipes. The
# schema is `src/undercooked/sim/recipe.py` and the reference is
# docs/LEVELS.md — every model is strict (`extra="forbid"`), so a typo here
# fails the load loudly instead of silently defaulting.
#
# Step lists mirror PLAN §5's station pipelines: pick from the spawn shelf,
# chop (~0.9 s), cook on the stove, plate at the prep station, serve at the
# window. `seconds` feeds only the validator's greedy target-plausibility
# bound; the simulation (phase 04) re-enacts these steps per tick.
# Catalog of the item kinds the campaign's dishes consume (documentation of
# intent; spawn shelves are what actually put items on the floor).
ingredients: [tomato, onion, lettuce, dough, patty, rice, fish, plate]
recipes:
# tomato + onion -> chop -> pot on the stove -> plate
soup:
id: soup
name: Tomato Soup
base_score: 40
ingredients:
- {kind: tomato, form: raw}
- {kind: onion, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5.0}
- {kind: plate, station: prep, seconds: 0.4}
- {kind: serve, station: serve}
tags: [hot, soup]
# lettuce + tomato -> chop -> plate; the fast, no-fire dish
salad:
id: salad
name: Garden Salad
base_score: 30
ingredients:
- {kind: lettuce, form: raw}
- {kind: tomato, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: plate, station: prep, seconds: 0.4}
- {kind: serve, station: serve}
tags: [cold, quick]
# rice + onion straight onto the stove (PLAN §5: raw rice needs no board)
fried_rice:
id: fried_rice
name: Fried Rice
base_score: 50
ingredients:
- {kind: rice, form: raw}
- {kind: onion, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: cook, station: stove, seconds: 6.0}
- {kind: plate, station: prep, seconds: 0.4}
- {kind: serve, station: serve}
tags: [hot, grain]
# patty + dough + lettuce -> chop + pan on the stove -> plate. Defined now;
# later levels (phase 11) put it in an order pool.
burger:
id: burger
name: Chef's Burger
base_score: 70
ingredients:
- {kind: patty, form: raw}
- {kind: dough, form: raw}
- {kind: lettuce, form: raw}
steps:
- {kind: pick, station: spawn_shelf}
- {kind: chop, station: chop_board, seconds: 0.9}
- {kind: cook, station: stove, seconds: 5.0}
- {kind: plate, station: prep, seconds: 0.5}
- {kind: serve, station: serve}
tags: [hot, stacked]
+443
View File
@@ -0,0 +1,443 @@
"""Fixtures for the live-socket integration tests (phase 06).
`ws_server` is a **session-scoped factory**: one call spins up a real
`uvicorn.Server` on an ephemeral `127.0.0.1` port (never a routable
interface, the `tests/conftest.py` rule) with a `Settings` built from
explicit overrides, and the returned context manager tears the server down
when the test ends (a second safety close runs at session end). The app
built here is yielded too, so a test can reach
`app.state.supervisor` / `app.state.ws_registry` to assert on room state
(the seat's `connected` flag, the disconnect count, the socket census)
without a second client.
`live_server` is the same machine with one difference (phase 06 task 06):
`UNDERCOOKED_LEVELS_DIR` defaults to the **shipped** `levels/` campaign
(so a live server carries the real content the content gate validated),
and any `Settings` field is overridable (`tick_hz`, `snapshot_hz`, …)
the same way. A test that needs a playable, tunable round passes
`levels_dir=` explicitly (the task-03 `levels_playable` precedent, and
the task-06 `levels_round` campaign for the two-client round).
`ws_client` is a factory for the **scripted client** the round/abuse/
budget suites drive: `ws_client(url, code, token=None, name=None)` returns
a `WsClient` — a connected `websockets` socket with the `hello` already
sent and a background reader that keeps a merged snapshot view
(`client.view`: full frames reset, delta frames patch, the frame's `e`
events accumulated) and a frame queue the client's three verbs consume:
* `send_model(model)` — queue a JSON frame out (fire-and-forget; the
`input` choreography cadence never blocks on the socket),
* `expect(type, wait_s)` — the next frame of `type`, the ones before it
buffered and re-queued for a later verb,
* `collect(type, n, wait_s)` — `n` frames of `type`, the same buffering.
Settings are built the same way `tests/conftest.py` `make_settings` does
(the authoritative `config.ENV_KEYS` contract, ambient `UNDERCOOKED_*` /
`DEBUGPY*` stripped), with one deliberate difference: `UNDERCOOKED_LEVELS_DIR`
is *kept*, pointed at the campaign the factory names (the `ws_server`
default is the fixture-isolated campaign with a recipe book — `create_app`'s
content gate passes on it; the `live_server` default is the shipped
`levels/`), so a live server boots without the ambient environment leaking.
"""
from __future__ import annotations
import asyncio
import contextlib
import importlib
import json
import os
import threading
import time
from collections.abc import Callable, Iterator
from pathlib import Path
from typing import Any
import httpx
import pytest
import websockets
from websockets.exceptions import ConnectionClosed
LIVE_HOST = "127.0.0.1" # loopback only: tests never listen on a routable interface
FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels"
#: The shipped campaign (phase 06 task 06: `live_server` boots on it).
SHIPPED_LEVELS_DIR = Path(__file__).resolve().parents[2] / "levels"
config = importlib.import_module("undercooked.config")
app_module = importlib.import_module("undercooked.server.app")
uvicorn = importlib.import_module("uvicorn")
def _as_env_value(value: object) -> str:
"""Render a Python override value the way `load_settings` reads it."""
if isinstance(value, bool):
return "1" if value else "0"
if isinstance(value, (str, os.PathLike)):
return str(value)
if isinstance(value, (list, tuple)):
return ",".join(str(item) for item in value)
return str(value)
def _wait_for_health(url: str, thread: threading.Thread) -> None:
"""Poll `/healthz` until it answers 200, failing fast if the server died."""
timeout = float(os.environ.get("UNDERCOOKED_TEST_SOCKET_TIMEOUT", "30"))
deadline = time.monotonic() + timeout
probe = httpx.Client(timeout=1.0, trust_env=False)
try:
while time.monotonic() < deadline:
try:
if probe.get(f"{url}/healthz").status_code == 200:
return
except httpx.HTTPError:
pass
thread.join(timeout=0.05)
if not thread.is_alive():
raise RuntimeError(f"live server on {url} died during startup")
finally:
probe.close()
raise RuntimeError(f"live server on {url} never became healthy")
def _start_live_server(
overrides: dict[str, object], default_levels_dir: Path
) -> tuple[str, Any, Any, threading.Thread]:
"""One live uvicorn server on an ephemeral loopback port.
Returns `(url, application, server, thread)`; the caller wraps them in
a `LiveServer`. `overrides` are `Settings` field names (validated
against `config.ENV_KEYS`) — `levels_dir` falls back to
`default_levels_dir` when the caller did not name it.
"""
env = {
key: value
for key, value in os.environ.items()
if not key.startswith(("UNDERCOOKED_", "DEBUGPY"))
}
levels_dir = overrides.get("levels_dir", str(default_levels_dir))
env["UNDERCOOKED_LEVELS_DIR"] = str(levels_dir)
for name, value in overrides.items():
env[config.ENV_KEYS[name]] = _as_env_value(value)
settings = config.load_settings(env)
application = app_module.create_app(settings)
uv_config = uvicorn.Config(
application,
host=LIVE_HOST,
port=0,
log_level="warning",
lifespan="on",
)
sock = uv_config.bind_socket()
url = f"http://{LIVE_HOST}:{sock.getsockname()[1]}"
server = uvicorn.Server(uv_config)
thread = threading.Thread(
target=server.run, args=([sock],), name="undercooked-live-ws", daemon=True
)
thread.start()
_wait_for_health(url, thread)
return url, application, server, thread
class LiveServer:
"""One live uvicorn server + its app (a context manager)."""
def __init__(
self,
url: str,
app: Any,
server: Any,
thread: threading.Thread,
) -> None:
self.url = url
self.app = app
self._server = server
self._thread = thread
self._closed = False
def __enter__(self) -> tuple[str, Any]:
return self.url, self.app
def __exit__(self, *exc_info: object) -> None:
self.close()
def close(self) -> None:
if self._closed:
return
self._closed = True
self._server.should_exit = True
self._thread.join(timeout=15)
if self._thread.is_alive(): # pragma: no cover - only if uvicorn stops cooperating
pytest.fail("live server did not shut down within 15 s")
def _make_live_server_factory(default_levels_dir: Path) -> Callable[..., LiveServer]:
started: list[LiveServer] = []
def _make(**overrides: object) -> LiveServer:
if any(name not in config.ENV_KEYS for name in overrides):
unknown = sorted(name for name in overrides if name not in config.ENV_KEYS)
pytest.fail(f"unknown settings override(s): {unknown}")
url, application, server, thread = _start_live_server(
dict(overrides), default_levels_dir
)
live = LiveServer(url, application, server, thread)
started.append(live)
return live
# The session-end safety close (the context managers already closed
# each server the test used; a leaked one is closed here).
def _close_all() -> None:
for live in started:
live.close()
_make.close_all = _close_all # type: ignore[attr-defined]
return _make
@pytest.fixture(scope="session")
def ws_server() -> Iterator[Any]:
"""Factory: `with ws_server(max_players=2) as (url, app): …`
Each call starts a fresh `uvicorn.Server` on an ephemeral loopback
port (bound before the serving thread starts, so the port is known
exactly — no log scraping, no collisions between concurrent runs)
with a `Settings` resolved from `**overrides` (field name, the same
names `make_settings` takes) over the fixture-isolated content.
Shutdown is requested through `should_exit` so the lifespan stops
cleanly.
"""
factory = _make_live_server_factory(FIXTURE_LEVELS_DIR)
yield factory
factory.close_all() # type: ignore[attr-defined]
@pytest.fixture(scope="session")
def live_server() -> Iterator[Any]:
"""Factory: `with live_server(tick_hz=20, snapshot_hz=20) as (url, app): …`
The task-06 live server: identical to `ws_server` except that
`UNDERCOOKED_LEVELS_DIR` points at the **shipped** `levels/` campaign
by default (override with `levels_dir=…`), so the server boots on the
real, gate-validated content; `tick_hz` / `snapshot_hz` (and every
other `Settings` field) are overridable the same way.
"""
factory = _make_live_server_factory(SHIPPED_LEVELS_DIR)
yield factory
factory.close_all() # type: ignore[attr-defined]
def _uri(url: str, code: str) -> str:
return url.replace("http://", "ws://", 1) + f"/ws/{code}"
class View:
"""The merged snapshot state a scripted client reasons about.
Full frames (`full: true`, the `FULL_SNAPSHOT_EVERY_TICKS` cadence and
the `needs_full` resync) reset the entity maps; delta frames patch
them (a `{"gone": true}` tombstone removes). The frame's `e` events
accumulate in order (a boundary's events ride that one frame — the
encoder's contract), and the scalars (`t` / `s` / `tgt` / `tl`) are
the latest frame's."""
def __init__(self) -> None:
self.players: dict[str, dict[str, Any]] = {}
self.stations: dict[tuple[Any, Any, Any], dict[str, Any]] = {}
self.orders: dict[Any, dict[str, Any]] = {}
self.events: list[dict[str, Any]] = []
self.tick = 0
self.score = 0
self.target = 0
self.time_left = 0.0
def apply(self, frame: dict[str, Any]) -> None:
# The wire's `snap` message nests the encoder's frame under
# `raw` (`SnapMsg.raw` — the message's own `p` is the protocol
# version, the frame's own `p` is its players array).
raw = frame.get("raw")
if not isinstance(raw, dict):
raw = frame
if raw.get("full"):
self.players = {
entry["i"]: entry for entry in raw.get("p", []) if not entry.get("gone")
}
self.stations = {
(entry["k"], entry["x"], entry["y"]): entry
for entry in raw.get("st", [])
if not entry.get("gone")
}
self.orders = {
entry["id"]: entry for entry in raw.get("o", []) if not entry.get("gone")
}
else:
delta = raw.get("d", {})
for entry in delta.get("p", []):
if entry.get("gone"):
self.players.pop(entry["i"], None)
else:
self.players[entry["i"]] = entry
for entry in delta.get("st", []):
key = (entry["k"], entry["x"], entry["y"])
if entry.get("gone"):
self.stations.pop(key, None)
else:
self.stations[key] = entry
for entry in delta.get("o", []):
if entry.get("gone"):
self.orders.pop(entry["id"], None)
else:
self.orders[entry["id"]] = entry
self.events.extend(raw.get("e", []))
self.tick = raw.get("t", self.tick)
self.score = raw.get("s", self.score)
self.target = raw.get("tgt", self.target)
self.time_left = raw.get("tl", self.time_left)
def player(self, index: str) -> dict[str, Any] | None:
return self.players.get(index)
def station(self, kind: str) -> dict[str, Any] | None:
"""The first station of `kind` (the wire's identity is `k` +
coordinates; a level's stations of a kind sit on distinct cells)."""
for (station_kind, _, _), entry in self.stations.items():
if station_kind == kind:
return entry
return None
def station_at(self, kind: str, x: float, y: float) -> dict[str, Any] | None:
return self.stations.get((kind, x, y))
def event(self, kind: str) -> dict[str, Any] | None:
for entry in self.events:
if entry.get("k") == kind:
return entry
return None
class WsClient:
"""The scripted client (phase 06 task 06): a connected socket with the
`hello` already sent, a background reader (the merged `view` + the
frame queue), and the three verbs the suites name.
`send_model` is fire-and-forget by design: the choreography feeds
inputs on a cadence (~10 Hz) faster than the socket's round trip, and
the room's per-tick queue is what serialises the intent — a blocking
send would add latency the sim's queue discipline is meant to absorb.
`raw` is the malformed-frame half (the abuse suite's `not json`).
"""
def __init__(self, ws: Any) -> None:
self.ws = ws
self.view = View()
self._frames: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
self._reader: asyncio.Task[None] | None = None
self._closed = False
def start(self) -> None:
self._reader = asyncio.get_running_loop().create_task(self._read_loop())
async def _read_loop(self) -> None:
try:
while not self._closed:
raw = await self.ws.recv()
frame = json.loads(raw)
if frame.get("type") == "snap":
self.view.apply(frame)
self._frames.put_nowait(frame)
except (ConnectionClosed, asyncio.CancelledError):
pass
def send_model(self, model: dict[str, Any]) -> None:
"""Queue one JSON frame out (the task's `send_model`)."""
asyncio.get_running_loop().create_task(self.ws.send(json.dumps(model)))
async def send(self, model: dict[str, Any]) -> None:
await self.ws.send(json.dumps(model))
def raw(self, text: str) -> None:
"""One raw frame out (the malformed half: `not json`, …)."""
asyncio.get_running_loop().create_task(self.ws.send(text))
async def expect(self, type: str, wait_s: float = 10.0) -> dict[str, Any]:
"""The next frame of `type`; the frames before it are buffered and
re-queued (a later verb sees them, in order)."""
loop = asyncio.get_running_loop()
deadline = loop.time() + wait_s
held: list[dict[str, Any]] = []
while True:
remaining = deadline - loop.time()
if remaining <= 0:
msg = f"timed out waiting for a {type!r} frame"
raise TimeoutError(msg)
frame = await asyncio.wait_for(self._frames.get(), remaining)
if frame.get("type") == type:
for buffered in held:
self._frames.put_nowait(buffered)
return frame
held.append(frame)
async def collect(
self, type: str, n: int, wait_s: float = 30.0
) -> list[dict[str, Any]]:
"""`n` frames of `type` (in arrival order); the frames in between
are buffered and re-queued."""
loop = asyncio.get_running_loop()
deadline = loop.time() + wait_s
held: list[dict[str, Any]] = []
out: list[dict[str, Any]] = []
while len(out) < n:
remaining = deadline - loop.time()
if remaining <= 0:
msg = f"timed out after {len(out)}/{n} {type!r} frames"
raise TimeoutError(msg)
frame = await asyncio.wait_for(self._frames.get(), remaining)
if frame.get("type") == type:
out.append(frame)
else:
held.append(frame)
for buffered in held:
self._frames.put_nowait(buffered)
return out
async def close(self) -> None:
if self._closed:
return
self._closed = True
if self._reader is not None:
self._reader.cancel()
with contextlib.suppress(asyncio.CancelledError, Exception):
await self._reader
with contextlib.suppress(Exception):
await self.ws.close()
@pytest.fixture(scope="session")
def ws_client() -> Callable[..., Any]:
"""Factory: `client = await ws_client(url, code, token, name=…)`.
Connects to `/ws/{code}` on the named server, sends the `hello`
(the `token` joins or rejoins; absent, the server mints one and the
`welcome` echoes it) and returns the scripted `WsClient` (its reader
already running, its `view` merging snapshots).
"""
async def _connect(
url: str,
code: str,
token: str | None = None,
*,
name: str | None = None,
p: int = 1,
) -> WsClient:
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
hello: dict[str, Any] = {"type": "hello", "p": p}
if token is not None:
hello["token"] = token
if name is not None:
hello["name"] = name
await ws.send(json.dumps(hello))
client = WsClient(ws)
client.start()
return client
return _connect
+870
View File
@@ -0,0 +1,870 @@
"""Integration: the snapshot stream over live sockets + the deterministic
fan-out drive (phase 06, task 04).
Live half (a real uvicorn on an ephemeral loopback port): the first
`PLAYING` frame's keysets (the shape `docs/PROTOCOL.md` documents —
the captured frame in the docs carries exactly these), the size
budgets (4 players / 4 orders / 3 hazards ≤ 1.5 KB on the budget
kitchen; a 64x64 level ≤ 4 KB), the ≥ 40% delta shrink, the spectator
half-rate pacing, the `UNDERCOOKED_SIM_LATENCY_MS` downlink delay, and
the 100-tick downlink bound (≤ 60 KB/s).
Deterministic half (a `FakeClock`-driven room + the `SnapshotFanout`
directly, no wall clock): the player/spectator pass pacing, the full
cadence, the delta decode-equivalence (the applied chain lands on the
server's own re-encode), the `MAX_EVENTS_PER_FRAME` cap's
`events_dropped` count, and the `needs_full` resync forcing a full.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import shutil
import time
from collections.abc import Callable
from pathlib import Path
from typing import Any, cast
import httpx
import pytest
import websockets
from undercooked.config import Settings
from undercooked.server.clock import FakeClock
from undercooked.server.encoding import (
MAX_EVENTS_PER_FRAME,
encode_snapshot,
snapshot_size,
)
from undercooked.server.room import Room, RoomState
from undercooked.server.ws import ConnectionRegistry, SnapshotFanout
from undercooked.sim.content import ContentBundle
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import (
Event,
ItemForm,
ItemKind,
StationKind,
)
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
pytestmark = pytest.mark.integration
#: The playable campaign (the shipped level 1, validated by the content
#: gate at server boot) — a room built from it can start a round.
PLAYABLE_LEVELS_DIR = (
Path(__file__).resolve().parents[1] / "fixtures" / "levels_playable"
)
# The keysets the wire contract promises (docs/PROTOCOL.md "The `snap`
# frame" — the captured frame there carries exactly these; on the wire
# the frame rides under `raw`, beside the message's `type` and `p`).
FULL_TOP_KEYS: frozenset[str] = frozenset(
{"t", "s", "tgt", "tl", "full", "p", "st", "o", "hz", "e"}
)
PLAYER_KEYS: frozenset[str] = frozenset({"i", "x", "y", "f", "h", "s", "pi"})
STATION_KEYS: frozenset[str] = frozenset({"k", "x", "y", "g", "q", "p", "s"})
ORDER_KEYS: frozenset[str] = frozenset({"id", "r", "l", "pts"})
HAZARD_KEYS: frozenset[str] = frozenset({"t", "x", "y", "l"})
STATION_KINDS: frozenset[str] = frozenset(
{"spawn_shelf", "chop_board", "stove", "prep", "serve", "sink"}
)
PLAYER_STATES: frozenset[str] = frozenset(
{"idle", "moving", "stunned", "working"}
)
HAZARD_KINDS: frozenset[str] = frozenset(
{"fire", "spill", "moving_obstacle", "rush"}
)
# ------------------------------------------------------------------ helpers
def _uri(url: str, code: str) -> str:
return url.replace("http://", "ws://", 1) + f"/ws/{code}"
async def _connect(
url: str,
code: str,
token: str | None = None,
*,
name: str | None = None,
) -> Any:
"""A connected client with the `hello` already sent (a `token=None`
hello mints a fresh token — a spectator: no seat for it)."""
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
hello: dict[str, Any] = {"type": "hello", "p": 1}
if token is not None:
hello["token"] = token
if name is not None:
hello["name"] = name
await ws.send(json.dumps(hello))
return ws
async def _next_json(ws: Any, wait_s: float = 10.0) -> dict[str, Any]:
return json.loads(await asyncio.wait_for(ws.recv(), wait_s))
async def _wait_for(
predicate: Callable[[], bool], what: str, wait_s: float = 15.0
) -> None:
"""Poll across threads (the server's loop does the work; this thread
only observes it)."""
loop = asyncio.get_running_loop()
deadline = loop.time() + wait_s
while not predicate():
if loop.time() > deadline:
pytest.fail(f"timed out waiting for {what}")
await asyncio.sleep(0.01)
def _create_room(url: str, token: str | None = None) -> str:
"""`POST /api/rooms` over the live server; returns the code."""
body: dict[str, Any] = {}
if token is not None:
body["token"] = token
response = httpx.post(f"{url}/api/rooms", json=body, timeout=5.0)
assert response.status_code == 201, response.text
return response.json()["room_code"]
def _join(url: str, code: str, token: str) -> None:
response = httpx.post(
f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0
)
assert response.status_code == 200, response.text
async def _playing_room(
url: str, app: Any, host_token: str
) -> tuple[str, Room, Any]:
"""Create the room, connect the host, start (solo host), and wait
for `PLAYING` (the 3 s real-time countdown on the 20 Hz tick task)."""
code = _create_room(url, host_token)
host = await _connect(url, code, host_token, name="Host")
await _next_json(host) # welcome
await _next_json(host) # lobby
await host.send(json.dumps({"type": "start"})) # solo host
room = app.state.supervisor.get(code)
assert room is not None
await _wait_for(
lambda: room.state is RoomState.PLAYING and room.sim_state is not None,
f"room {code} to reach PLAYING",
wait_s=20.0,
)
return code, room, host
def _apply_delta(base: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]:
"""The client's merge (the decode half of the equivalence check):
upsert by identity key (preserving the base's order, new entities
appended — the encoder's own semantics), drop the `gone` tombstones,
take the scalars and `e` from the delta."""
# The scalars (`t`, `s`, `tgt`, `tl`) and `e` ride the delta; the
# entity arrays merge onto the base.
decoded = {
key: value
for key, value in delta.items()
if key not in ("d", "full")
}
identity: dict[str, Any] = {
"p": lambda e: (e["i"],),
"st": lambda e: (e["k"], e["x"], e["y"]),
"o": lambda e: (e["id"],),
"hz": lambda e: (e["t"], e["x"], e["y"]),
}
for key in ("p", "st", "o", "hz"):
base_entries: list[dict[str, Any]] = base.get(key, [])
delta_entries: list[dict[str, Any]] = delta["d"][key]
by_identity = {identity[key](entry): dict(entry) for entry in delta_entries}
merged: list[dict[str, Any]] = []
seen: set[tuple[Any, ...]] = set()
for entry in base_entries:
key_ = identity[key](entry)
if by_identity.get(key_, {}).get("gone") is True:
continue # tombstoned
merged.append(by_identity.get(key_, dict(entry)))
seen.add(key_)
for entry in delta_entries:
key_ = identity[key](entry)
if key_ not in seen and entry.get("gone") is not True:
merged.append(dict(entry))
decoded[key] = merged
decoded["full"] = True # a decoded chain is a full state
return decoded
class _FakeConn:
"""A duck for the fan-out's connection surface (the deterministic
drive): a role, the `needs_full` resync flag, an `enqueue` sink."""
def __init__(self, role: str) -> None:
self.role = role
self.needs_full = True
self.frames: list[dict[str, Any]] = []
self.state_frames: list[dict[str, Any]] = []
def enqueue(self, msg: Any) -> None:
raw = getattr(msg, "raw", None)
if raw is not None:
self.frames.append(raw)
else:
self.state_frames.append(json.loads(msg.model_dump_json()))
class _FakeRegistry:
"""A duck for `ConnectionRegistry.for_room` (the deterministic drive)."""
def __init__(self, conns: list[_FakeConn]) -> None:
self._conns = conns
def for_room(self, code: str) -> list[_FakeConn]:
return list(self._conns)
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:
"""A 7x5 kitchen with all six station kinds (the frame-size test
needs a content-rich full for the ≥ 40% delta shrink) and one
spawn point. `target` is above anything an idle cook scores, so a
`PLAYING` round on it always ends `LOST` on the buzzer."""
grid = [
"#######",
"#T.C.P#",
"#...V.#",
"#S..K.#",
"#######",
]
tiles: dict[str, dict[str, str]] = {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"V": {"kind": "floor", "station": "serve"},
"T": {"kind": "floor", "station": "stove"},
"C": {"kind": "floor", "station": "chop_board"},
"P": {"kind": "floor", "station": "prep"},
"K": {"kind": "floor", "station": "sink"},
}
data: dict[str, object] = {
"id": "TICK1",
"name": "Tick kitchen",
"order": 1,
"time_limit": 60.0,
"target_score": 1000,
"grid": grid,
"tiles": tiles,
"spawns": [[2, 2]],
"orders": {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [4, 6],
"deadline": 30,
},
}
return Level.build(LevelDef.model_validate(data))
def _make_room(
bundle: ContentBundle | None = None, **settings: Any
) -> tuple[Room, FakeClock]:
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 _enter_playing(room: Room) -> float:
"""Two seats (the solo-host start + a ready non-host), the 3 s
countdown in 1 s frames, `PLAYING`; returns the final `now`."""
room.add_seat("tok-a", "Ada", True)
seat = room.add_seat("tok-b", "Bob", False)
assert room.set_ready("tok-b", True)
assert seat is not None
room.start("tok-a")
room.tick_once(now=0.0)
for offset in (1.0, 2.0, 3.0):
room.tick_once(now=offset)
assert room.state is RoomState.PLAYING
return 3.0
# ------------------------------------------------- live: the keysets (L1)
async def test_first_playing_frame_is_full_with_the_contract_keysets(
ws_server: Any,
) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
_, _, host = await _playing_room(url, app, "tok-keys")
# The countdown frames drain off first (they predate PLAYING).
first: dict[str, Any] | None = None
while first is None or first["type"] == "countdown":
first = await _next_json(host)
assert first is not None
# The first `PLAYING` frame of a fresh connection is a full one
# (its `needs_full` default). The wire wraps the frame in
# `raw` (the message's own `p` is the protocol version).
assert first["type"] == "snap"
raw = first["raw"]
assert raw["full"] is True
# The keysets, exactly — the shape the docs' captured frame
# carries (and no more: the grid, the station ids and the spawn
# coordinates never leave the server).
assert set(raw) == FULL_TOP_KEYS
for player in raw["p"]:
assert set(player) == PLAYER_KEYS
assert player["f"] in (0, 1, 2, 3)
assert player["s"] in PLAYER_STATES
assert isinstance(player["pi"], int)
for station in raw["st"]:
assert set(station) == STATION_KEYS
assert station["k"] in STATION_KINDS
for order in raw["o"]:
assert set(order) == ORDER_KEYS
for hazard in raw["hz"]:
assert set(hazard) == HAZARD_KEYS
assert hazard["t"] in HAZARD_KINDS
for entry in raw["e"]:
assert "k" in entry
# Level 1's kitchen, exactly: seven stations, the one solo
# cook, no hazards (the tutorial's `hazards: []`).
assert len(raw["st"]) == 7
assert len(raw["p"]) == 1
assert raw["hz"] == []
assert raw["t"] >= 0
assert raw["s"] == 0
assert raw["tgt"] == 45
# ------------------------------------------------ live: the size budgets
BUDGET_LEVEL = """\
id: 00_budget
name: Budget Kitchen
order: 1
difficulty: 1
time_limit: 60
target_score: 100
blurb: >-
The frame-budget kitchen: four spawn points, four concurrent orders,
three fast hazards (the task's 4 players / 4 orders / 3 hazards case).
grid:
- "############"
- "#T..C...S..#"
- "#..........#"
- "#.P......X.#"
- "#..........#"
- "#..K....O..#"
- "#..........#"
- "#U......W..#"
- "############"
tiles:
"#": {kind: wall}
".": {kind: floor}
"T": {kind: floor, station: spawn_shelf, item: tomato}
"U": {kind: floor, station: spawn_shelf, item: tomato}
"W": {kind: floor, station: spawn_shelf, item: onion}
"O": {kind: floor, station: spawn_shelf, item: onion}
"C": {kind: floor, station: chop_board}
"S": {kind: floor, station: stove}
"P": {kind: floor, station: prep}
"X": {kind: floor, station: serve}
"K": {kind: floor, station: sink}
spawns: [[1, 2], [1, 4], [1, 6], [5, 2]]
orders:
pool: [soup]
concurrent: 4
spawn_interval: [1, 2]
deadline: 30
hazards:
- {type: fire, x: 8, y: 1, chance_per_s: 0.5, respawn_s: 2}
- {type: fire, x: 4, y: 1, chance_per_s: 0.5, respawn_s: 2}
- {type: spill, x: 5, y: 2, chance_per_s: 0.5, respawn_s: 2}
escalation_notes: >-
Test content: the budget case, not a campaign level.
"""
def _level_64_yaml() -> str:
"""The 64x64 budget level (a wall ring, a row of stations, floor)."""
top = "#" * 64
station_row = "#" + "T..C.O.S.." + "." * 52 + "#"
station_row_2 = "#" + "P..X..K..." + "." * 52 + "#"
middle = "#" + "." * 62 + "#"
grid = [top, station_row, station_row_2] + [middle] * 60 + [top]
assert all(len(row) == 64 for row in grid)
assert len(grid) == 64
return (
"id: 00_big\n"
"name: Big Kitchen\n"
"order: 1\n"
"difficulty: 1\n"
"time_limit: 60\n"
"target_score: 100\n"
"blurb: >-\n"
" The 64x64 budget: the frame must stay under 4 KB whatever the\n"
" grid (the grid never leaves the server — the frame is entities\n"
" only).\n"
"grid:\n"
+ "".join(f' - "{row}"\n' for row in grid)
+ "tiles:\n"
' "#": {kind: wall}\n'
' ".": {kind: floor}\n'
' "T": {kind: floor, station: spawn_shelf, item: tomato}\n'
' "O": {kind: floor, station: spawn_shelf, item: onion}\n'
' "C": {kind: floor, station: chop_board}\n'
' "S": {kind: floor, station: stove}\n'
' "P": {kind: floor, station: prep}\n'
' "X": {kind: floor, station: serve}\n'
' "K": {kind: floor, station: sink}\n'
"spawns: [[2, 3]]\n"
"orders:\n"
" pool: [soup]\n"
" concurrent: 1\n"
" spawn_interval: [16, 20]\n"
" deadline: 30\n"
"hazards: []\n"
"escalation_notes: >-\n"
" Test content: the 64x64 size case.\n"
)
def _write_levels_dir(tmp_path: Path, level_yaml: str) -> Path:
"""A throwaway content dir: the test level + the campaign's recipe
book (a `recipes.yaml` the content gate validates with it)."""
directory = tmp_path / "levels"
directory.mkdir()
(directory / "00_test.yaml").write_text(level_yaml, encoding="utf-8")
shutil.copy(PLAYABLE_LEVELS_DIR / "recipes.yaml", directory / "recipes.yaml")
return directory
async def test_the_4p_4o_3h_frame_fits_the_1_5_kb_budget(
ws_server: Any, tmp_path: Path
) -> None:
with ws_server(
levels_dir=str(_write_levels_dir(tmp_path, BUDGET_LEVEL))
) as (url, app):
code = _create_room(url, "tok-p1")
for token in ("tok-p2", "tok-p3", "tok-p4"):
_join(url, code, token)
clients = [
await _connect(url, code, "tok-p1", name="P1"),
await _connect(url, code, "tok-p2", name="P2"),
await _connect(url, code, "tok-p3", name="P3"),
await _connect(url, code, "tok-p4", name="P4"),
]
for client in clients:
await _next_json(client) # welcome
await _next_json(client) # lobby
await client.send(json.dumps({"type": "ready"}))
await clients[0].send(json.dumps({"type": "start"}))
room = app.state.supervisor.get(code)
assert room is not None
await _wait_for(
lambda: room.state is RoomState.PLAYING and room.sim_state is not None,
"the room to reach PLAYING",
wait_s=20.0,
)
# Wait for one frame carrying the budget case (4 cooks, 4
# orders) and measure *it* (orders spawn at 1-2 s intervals;
# the sim's hazard stage is identity until phase 10, so a live
# frame carries no `hz` yet — the hazard encoding is covered
# unit-side, and the 4p/4o frame already bounds the 1.5 KB
# budget: 3 hazards would add < 120 B).
frame: dict[str, Any] | None = None
deadline = time.monotonic() + 30.0
while frame is None and time.monotonic() < deadline:
data = await _next_json(clients[0])
if data["type"] != "snap":
continue
body = data["raw"]
if (
len(body.get("p", [])) == 4
and len(body.get("o", [])) >= 4
):
frame = body
assert frame is not None, "no frame carried the 4p/4o case in 30 s"
size = snapshot_size(frame)
assert size <= 1500, f"the budget frame is {size} bytes (budget 1.5 KB)"
# The keysets hold at the budget shape too (four distinct cooks).
assert set(frame) == FULL_TOP_KEYS
assert len({p["i"] for p in frame["p"]}) == 4
async def test_the_64x64_level_frame_fits_the_4_kb_budget(
ws_server: Any, tmp_path: Path
) -> None:
with ws_server(
levels_dir=str(_write_levels_dir(tmp_path, _level_64_yaml()))
) as (url, app):
_, _, host = await _playing_room(url, app, "tok-big")
frame: dict[str, Any] | None = None
while frame is None:
data = await _next_json(host)
if data["type"] == "snap":
frame = data["raw"]
assert frame is not None
assert frame["full"] is True # the fresh connection's first frame
size = snapshot_size(frame)
assert size <= 4096, f"the 64x64 frame is {size} bytes (budget 4 KB)"
# The grid is not in the frame: a 64x64 kitchen encodes to the
# same handful of stations as the small one.
assert len(frame["st"]) == 7
# ------------------------------------------------- live: the delta stream
async def test_the_delta_stream_shrinks_by_at_least_40_percent(
ws_server: Any,
) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
_, _, host = await _playing_room(url, app, "tok-delta")
# Collect `snap` frames until the window holds a full one and
# the frame right after it (the fresh connection's first is a
# full; the cadence full, every 20 passes, lands in the window
# too — either pair works).
window: list[dict[str, Any]] = []
deadline = time.monotonic() + 25.0
while time.monotonic() < deadline:
data = await _next_json(host)
if data["type"] != "snap":
continue
window.append(data["raw"])
for i, f in enumerate(window):
if f["full"] and i + 1 < len(window):
break
else:
continue
break
full = next(f for f in window if f["full"])
after = window[window.index(full) + 1]
assert after["full"] is False, "the frame after the full must be a delta"
full_size = snapshot_size(full)
delta_size = snapshot_size(after)
shrink = 1.0 - delta_size / full_size
assert shrink >= 0.40, (
f"the delta shrank by {shrink:.0%} (full {full_size} B, "
f"delta {delta_size} B; budget ≥ 40%)"
)
# The decode half (live): applying the delta to the full must
# land on exactly the base's entities (kept) plus the delta's
# (upserted), minus the tombstoned — and the delta's scalars.
decoded = _apply_delta(full, after)
assert decoded["t"] == after["t"]
assert decoded["s"] == after["s"]
assert decoded["full"] is True
identity = {
"p": lambda e: (e["i"],),
"st": lambda e: (e["k"], e["x"], e["y"]),
"o": lambda e: (e["id"],),
"hz": lambda e: (e["t"], e["x"], e["y"]),
}
for key in ("p", "st", "o", "hz"):
base_ids = {identity[key](e) for e in full[key]}
delta_ids = {
identity[key](e)
for e in after["d"][key]
if e.get("gone") is not True
}
gone_ids = {
identity[key](e)
for e in after["d"][key]
if e.get("gone") is True
}
got = {identity[key](e) for e in decoded[key]}
assert got == (base_ids - gone_ids) | delta_ids, (
f"the merge lost or invented {key} entities: {got} vs "
f"{(base_ids - gone_ids) | delta_ids}"
)
# ------------------------------------------------- live: the pacing
async def test_spectators_receive_half_the_rate(ws_server: Any) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
code, _, host = await _playing_room(url, app, "tok-rate")
# The spectator joins by a token-less hello (a minted token has
# no seat — the documented spectator path).
spectator = await _connect(url, code, None, name="Spectator")
await _next_json(spectator) # welcome
await _next_json(spectator) # lobby
await asyncio.sleep(0.3) # let the spectator's first full flush
# A 3.0 s window: the default snapshot rate is the 20 Hz tick
# (period 1 — a player pass every tick), the spectator pass at
# the half rate (every 2nd tick, 10 Hz).
player_bucket: dict[str, int] = {"n": 0}
spec_bucket: dict[str, int] = {"n": 0}
host_q = asyncio.create_task(_count(host, "snap", player_bucket))
spec_q = asyncio.create_task(_count(spectator, "snap", spec_bucket))
await asyncio.sleep(3.0)
host_q.cancel()
spec_q.cancel()
for task in (host_q, spec_q):
with contextlib.suppress(asyncio.CancelledError):
await task
player_count = player_bucket["n"]
spectator_count = spec_bucket["n"]
assert player_count >= 50, f"the player got {player_count} frames in 3 s (20 Hz)"
assert 22 <= spectator_count <= 38, (
f"the spectator got {spectator_count} frames in 3 s (10 Hz)"
)
assert player_count >= 2 * spectator_count - 10, (
f"the player's {player_count} is not ~2x the spectator's "
f"{spectator_count}"
)
async def _count(ws: Any, frame_type: str, bucket: dict[str, int]) -> None:
"""Count `frame_type` frames on a live socket until cancelled (the
pacing window's half)."""
bucket["n"] = 0
while True:
data = json.loads(await ws.recv())
if data["type"] == frame_type:
bucket["n"] += 1
# ------------------------------------------------- live: the latency
async def test_the_configured_sim_latency_bounds_the_downlink(
ws_server: Any,
) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR), sim_latency_ms=150) as (
url,
app,
):
_, _, host = await _playing_room(url, app, "tok-latency")
# The pump's per-batch delay: the next `snap` after an input is
# written only after the pump's 150 ms sleep — the input→frame
# round trip is lower-bounded by it.
await host.send(
json.dumps(
{
"type": "input",
"seq": 1,
"move": {"x": 1.0, "y": 0.0},
"act": False,
}
)
)
t0 = time.monotonic()
frame: dict[str, Any] | None = None
while frame is None:
data = await _next_json(host)
if data["type"] == "snap":
frame = data
elapsed = time.monotonic() - t0
assert elapsed >= 0.145, (
f"the input→frame round trip took {elapsed * 1000:.0f} ms; the "
f"configured 150 ms delay must lower-bound it"
)
# ------------------------------------------------- live: the rate bound
async def test_the_downlink_is_bounded_over_hundred_ticks(ws_server: Any) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
_, _, host = await _playing_room(url, app, "tok-bound")
# 100 ticks = 5.0 s of wall clock (the 20 Hz tick task): the
# total bytes the player receives must stay under 60 KB.
total = 0
frame_count = 0
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
try:
data = await asyncio.wait_for(host.recv(), timeout=0.5)
except TimeoutError:
continue
frame = json.loads(data)
if frame["type"] == "snap":
frame_count += 1
total += len(data.encode("utf-8"))
assert frame_count >= 30, (
f"only {frame_count} snap frames in 5 s (10 Hz expected)"
)
assert total <= 60_000, f"the 100-tick downlink was {total} bytes"
# ======================================= deterministic: the fan-out drive
def test_the_pass_pacing_full_cadence_and_decode_equivalence() -> None:
"""The defaults (the 20 Hz tick, the 20 Hz snapshot — period 1,
so the player pass is due every tick; the spectator pass at the
half rate, every 2 ticks). The first pass of a fresh audience is a
full (its `needs_full` default), the cadence fulls land at passes
1, 21, 41, … (a full every 20 passes), and applying the delta
chain must land on the server's own re-encode of the final state
(the decode-equivalence the client's merge owes)."""
room, _clock = _make_room(
bundle=ContentBundle(recipe_book=_make_book(), levels=(_make_level(),))
)
player = _FakeConn("player")
spectator = _FakeConn("spectator")
fanout = SnapshotFanout(
room,
cast(ConnectionRegistry, _FakeRegistry([player, spectator])),
)
room.set_broadcaster(fanout.pass_)
now = _enter_playing(room)
# 21 PLAYING ticks at the 20 Hz cadence: 21 player passes (period
# 1), 10 spectator passes (every 2nd tick).
for _ in range(21):
now += 0.05
room.tick_once(now=now)
assert len(player.frames) == 21, (
f"21 player passes expected, got {len(player.frames)}"
)
assert len(spectator.frames) == 10, (
f"10 spectator passes expected, got {len(spectator.frames)}"
)
# The fresh connection's first pass is a full; passes 2-20 are
# deltas; pass 21 is the cadence full (a full every 20 passes —
# one at 1, 21, 41, …).
assert player.frames[0]["full"] is True
for frame in player.frames[1:20]:
assert frame["full"] is False
assert player.frames[20]["full"] is True
assert spectator.frames[0]["full"] is True
for frame in spectator.frames[1:]:
assert frame["full"] is False
# The ≥ 40% shrink on the delta right after the fresh full (an
# idle kitchen: only the clock and the tick move).
full_size = snapshot_size(player.frames[0])
delta_size = snapshot_size(player.frames[1])
assert 1.0 - delta_size / full_size >= 0.40, (
f"the delta shrank by {1.0 - delta_size / full_size:.0%} "
f"(full {full_size} B, delta {delta_size} B)"
)
# The decode-equivalence: apply the chain (a full frame re-syncs
# the client state; a delta merges on top) through all 20 frames
# after the first and compare to the server's own re-encode of the
# final state (an idle kitchen is static apart from the clock).
state = room.sim_state
assert state is not None
reencoded = encode_snapshot(state)
decoded = player.frames[0]
for frame in player.frames[1:21]:
# A full frame re-syncs the client state; a delta merges on top.
decoded = frame if frame["full"] else _apply_delta(decoded, frame)
for key in ("p", "st", "hz", "o"):
assert decoded[key] == reencoded[key], f"the chain lost its {key}"
# The last pass encodes *before* the tick's own step (the room's
# pacing runs first), so the chain is exactly one tick behind the
# re-encode — and the idle kitchen's entities are identical in
# both.
assert decoded["t"] == reencoded["t"] - 1
assert decoded["s"] == reencoded["s"]
assert decoded["tgt"] == reencoded["tgt"]
def test_the_event_cap_counts_the_drop() -> None:
"""`MAX_EVENTS_PER_FRAME` (24) per frame: the 30th event is not
spread over the next frame — it counts in
`Room.stats()["events_dropped"]`, on the `ev` flush (a non-`PLAYING`
boundary) and on the `PLAYING` snapshot pass alike. The room's
buffer is not drained by the wire (the fan-out's cursor reads it
non-destructively)."""
room, _clock = _make_room(
bundle=ContentBundle(recipe_book=_make_book(), levels=(_make_level(),))
)
sink = _FakeConn("player")
fanout = SnapshotFanout(
room, cast(ConnectionRegistry, _FakeRegistry([sink]))
)
room.set_broadcaster(fanout.pass_)
# 30 emote events into the room's buffer (a non-`PLAYING` room:
# the `ev` flush path).
for index in range(30):
room._buffer_event(
_emote("tok-a", f"n{index}")
)
assert len(room.events_since_snapshot) == 30
fanout.pass_({"ev"}, ())
ev = sink.state_frames[-1]
assert ev["type"] == "ev"
assert len(ev["events"]) == MAX_EVENTS_PER_FRAME
assert room.stats()["events_dropped"] == 30 - MAX_EVENTS_PER_FRAME
# The buffer is intact (the cursor is non-destructive).
assert len(room.events_since_snapshot) == 30
# And the `PLAYING` path caps the same way (30 more events, two
# ticks — the player pass is due on the 2nd).
now = _enter_playing(room)
for index in range(30):
room._buffer_event(_emote("tok-b", f"m{index}"))
now += 0.05
room.tick_once(now=now) # the first player pass (period 1: due now)
snap = sink.frames[-1]
assert len(snap["e"]) == MAX_EVENTS_PER_FRAME
assert room.stats()["events_dropped"] == 2 * (30 - MAX_EVENTS_PER_FRAME)
assert len(room.events_since_snapshot) == 60
def _emote(player_id: str, emote_id: str) -> Event:
"""A lobby emote event (the `ev` flush's payload; the kind is the
server layer's plain string — `seats.EMOTE_EVENT_KIND`)."""
return Event(kind="emote", payload={"player_id": player_id, "emote": emote_id})
def test_a_forced_needs_full_restarts_the_chain_full() -> None:
"""The send queue's drop policy marks `needs_full` (a client that
has missed a frame): the audience's next pass is a full again, and
the stream's `prev` resets (the next delta diffs against it)."""
room, _clock = _make_room(
bundle=ContentBundle(recipe_book=_make_book(), levels=(_make_level(),))
)
player = _FakeConn("player")
fanout = SnapshotFanout(
room, cast(ConnectionRegistry, _FakeRegistry([player]))
)
room.set_broadcaster(fanout.pass_)
now = _enter_playing(room)
# Three ticks: a pass on each (period 1): full, delta, delta.
for _ in range(3):
now += 0.05
room.tick_once(now=now)
assert [f["full"] for f in player.frames] == [True, False, False]
# A dropped snapshot forces the next pass full.
player.needs_full = True
now += 0.05
room.tick_once(now=now)
assert player.frames[-1]["full"] is True
# The chain resets: the following pass is a delta against the new
# full (not against the stale `prev`).
now += 0.05
room.tick_once(now=now)
assert player.frames[-1]["full"] is False
+7 -3
View File
@@ -669,15 +669,19 @@ def test_queue_input_rejects_an_unknown_player_with_an_err_event() -> None:
"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` consumes and clears it.
# 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()
room.tick_once(now=3.0 + 0.05)
assert room._queued_inputs == []
+9 -4
View File
@@ -287,11 +287,16 @@ def test_one_room_fails_without_affecting_the_other(
bring_both_to_playing(room_a, room_b, clock)
inject_the_fault(room_a)
# The crash tick — isolated: it must not propagate. The wall
# time is not advanced: A takes this tick at the last wall time
# (dt 0), so B's cadence below stays exactly one 0.05 s frame
# per substep.
# The crash tick — isolated: it must not propagate. The task-03
# substep guard makes a dt-0 tick skip `step` entirely (a
# zero-substep tick would drop queued inputs without applying
# them), so the fault needs a substep-advancing tick: advance
# the wall clock by one period. B's `_last_at` is re-anchored
# to the same wall time so B's cadence below stays exactly one
# 0.05 s frame per substep.
clock.advance(PERIOD)
room_a.tick_once()
room_b._last_at = clock.now()
check_room_a_is_aborted(room_a, caplog, ada.token, bob.token)
check_room_b_keeps_ticking(room_b, clock)
+450
View File
@@ -0,0 +1,450 @@
"""The abuse suite (phase 06 task 06): the wire's guardrails, driven by
real sockets against a real `uvicorn.Server`.
The contract under test is PLAN §7 / `docs/PROTOCOL.md` "Inbound
guards": a malformed frame is an `err` + a counted shape violation (5
inside 10 s → close `4404`); a frame over `max_message_bytes` is
`too_big`, a frame over the token bucket `rate_limited` (3 consecutive
→ `4404`); the per-state whitelist is enforced before the semantics;
the host-only verbs are host-only no matter how fast they are
repeated; the emote throttle (2 per 1.0 s per connection) is a drop,
not an error. A flood of any of these must leave the **other**
connections — and the server itself — exactly as they were: the guard
is per-connection, the room and the process are not the blast radius.
Every test here bounds its own window: a live room's `snap` stream
never goes quiet while the round is `PLAYING`, so a drain waits for a
*budget* of frames or a *deadline*, never for silence that a healthy
server does not produce (the pre-task-06 drain design hung on a fixed
server and only ever passed on the pre-fix one that aborted).
Measured on this suite's machine (the `docs/PROTOCOL.md` budget table
carries the same numbers): 200 concurrent handshakes wall 0.18 s,
p95 164 ms, RSS delta 23.4 MB, `send_queue_max` 2.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import time
from typing import Any
import httpx
import pytest
import websockets
from websockets.exceptions import ConnectionClosed
from undercooked.server.ws import (
ABUSE_WINDOW_S,
MAX_SEND_QUEUE,
SHAPE_VIOLATIONS_TO_CLOSE,
)
pytestmark = pytest.mark.integration
#: The 4 KB boundary (PLAN §7's `max_message_bytes` default) in bytes.
MAX_MESSAGE_BYTES = 4096
#: The frame the cap refuses (64 KB — sixteen times the cap).
OVERSIZED_FRAME_BYTES = 64 * 1024
#: The `send_queue_max` bound for the burst test: the cap bounds
#: droppable traffic; non-droppable frames (the `pong`s a burst buys)
#: may push a queue past `MAX_SEND_QUEUE` until the pump drains it —
#: 4x the cap is the bound a 500-frame burst must stay inside
#: (measured peak: 2).
BURST_QUEUE_BOUND = 4 * MAX_SEND_QUEUE
#: The 200-connection memory bound (RSS delta, MB): the measured
#: footprint is 23.4 MB — the bound is 5x that, still 15x under a
#: per-connection "unbounded" regression would be visible at.
HANDSHAKE_MEMORY_BOUND_MB = 128.0
#: The p95 handshake bound (the task's: under 1 s). Measured 164 ms.
HANDSHAKE_P95_BOUND_S = 1.0
# ------------------------------------------------------------- the room
def _create_room(url: str, token: str) -> str:
"""One room, its creator at seat 0 as host."""
response = httpx.post(f"{url}/api/rooms", json={"token": token}, timeout=10.0)
assert response.status_code == 201, response.text
return response.json()["room_code"]
def _join(url: str, code: str, token: str) -> None:
"""Seat `token` in `code`."""
response = httpx.post(
f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=10.0
)
assert 200 <= response.status_code < 300, response.text
async def _connect(
url: str, code: str, token: str, name: str | None = None
) -> websockets.ClientConnection:
"""A connected socket: the upgrade, the `hello`, the `welcome`."""
ws = await websockets.connect(
url.replace("http://", "ws://", 1) + f"/ws/{code}", open_timeout=10.0
)
hello: dict[str, Any] = {"type": "hello", "p": 1, "token": token}
if name is not None:
hello["name"] = name
await ws.send(json.dumps(hello))
welcome = json.loads(await ws.recv())
assert welcome["type"] == "welcome", welcome
return ws
async def _read_until_close(ws: Any) -> list[dict[str, Any]]:
"""Everything `ws` has left to say, until the close frame."""
frames: list[dict[str, Any]] = []
while True:
try:
frames.append(json.loads(await asyncio.wait_for(ws.recv(), 5.0)))
except (
TimeoutError,
ConnectionClosed,
):
break
return frames
async def _alive(ws: Any, tag: str) -> None:
"""A keep-alive round trip: `ping` → `pong` (the other connection
is still being served)."""
await ws.send(json.dumps({"type": "ping", "t": 1}))
deadline = asyncio.get_running_loop().time() + 5.0
while True:
frame = json.loads(
await asyncio.wait_for(
ws.recv(), max(0.1, deadline - asyncio.get_running_loop().time())
)
)
if frame["type"] == "pong":
return
assert (
asyncio.get_running_loop().time() < deadline
), f"{tag} lost its pong within 5 s: {frame}"
# ------------------------------------------------- 1: the shape storm
async def test_two_thousand_malformed_frames_cannot_take_the_server_down(
ws_server: Any,
) -> None:
"""2000 malformed frames on one socket: the socket closes at the
violation limit (`4404`), the *other* connection keeps being served,
and the server is healthy afterwards."""
with ws_server() as (url, app):
code = _create_room(url, "tok-host")
_join(url, code, "tok-attacker")
host = await _connect(url, code, "tok-host", "Host")
attacker = await _connect(url, code, "tok-attacker", "Attacker")
room = app.state.supervisor.get(code)
assert room is not None
# The storm: 2000 shape violations (valid JSON, no `type` —
# `parse_client_message` answers `bad_field` for each). The
# close falls on the 5th inside the 10 s window; the rest of
# the flood meets a closed socket (suppressed — the guard did
# its job).
for _ in range(2000):
with contextlib.suppress(Exception):
await attacker.send(json.dumps({"nope": 1}))
# The attacker's socket is gone, by the abuse close.
await _read_until_close(attacker)
closed = attacker.close_code
assert closed is not None, "the attacker's socket was not closed"
assert closed == 4404, f"expected the 4404 abuse close, got {closed}"
assert (
SHAPE_VIOLATIONS_TO_CLOSE == 5
) # the bound the close fell on (the doc's "disconnect after 5")
assert ABUSE_WINDOW_S == 10.0
# The other connection is unaffected: a round trip on it, and
# the room exactly as it was (LOBBY, two seats — the attacker's
# seat is kept for the grace, not ejected by the flood).
await _alive(host, "the host")
assert room.state.value == "lobby"
assert len(room.seats) == 2
probe = await asyncio.to_thread(
httpx.get, f"{url}/healthz", timeout=5.0
)
assert probe.status_code == 200
# ------------------------------------------------- 2: the rate burst
async def test_a_500_message_burst_hits_the_rate_limit_and_the_queue_bound(
ws_server: Any,
) -> None:
"""500 pings on one socket: the bucket (40/40) empties, the
overage is `rate_limited` (3 consecutive → `4404`), the send
queue's high-water mark stays within its bound, and the server
survives."""
with ws_server() as (url, app):
code = _create_room(url, "tok-host")
host = await _connect(url, code, "tok-host", "Host")
room = app.state.supervisor.get(code)
assert room is not None
# The burst: 500 pings, as fast as the socket lets us (the
# first 40 take the bucket's burst; the overage is refused).
for index in range(500):
with contextlib.suppress(Exception):
await host.send(json.dumps({"type": "ping", "t": index}))
frames = await _read_until_close(host)
codes = [
frame.get("code")
for frame in frames
if frame["type"] == "err"
]
assert "rate_limited" in codes, (
"no rate_limited err frame arrived in the burst"
)
closed = host.close_code
assert closed is not None, "the burst socket was not closed"
assert closed == 4404, f"expected the 4404 abuse close, got {closed}"
# The queue's high-water mark: the `pong`s the burst buys are
# non-droppable, so a queue may lawfully run past the cap —
# the bound is what must hold (measured peak: 2).
stats = app.state.ws_registry.stats()
assert stats["send_queue_max"] <= BURST_QUEUE_BOUND, stats
# And the server is healthy.
probe = await asyncio.to_thread(
httpx.get, f"{url}/healthz", timeout=5.0
)
assert probe.status_code == 200
assert room.state.value == "lobby"
# ------------------------------------------------- 3: the size cap
async def test_the_size_cap_refuses_64_kb_and_admits_the_4kb_boundary(
ws_server: Any,
) -> None:
"""A 64 KB frame is `too_big` (refused before parsing, a rate
violation); a frame of exactly `max_message_bytes` (4096 B — the
cap's boundary) is admitted: the `ready` in it lands (the lobby
re-broadcast) without a single `too_big`."""
with ws_server() as (url, app):
code = _create_room(url, "tok-host")
host = await _connect(url, code, "tok-host", "Host")
room = app.state.supervisor.get(code)
assert room is not None
seat = room.seat_for_token("tok-host")
assert seat is not None and seat.ready is False
# The 64 KB *text* frame (the wire's contract): any bytes do
# (the cap is checked before the parse) — a `ready` padded
# past the cap with JSON whitespace.
oversized = b'{"type": "ready"}' + b" " * (
OVERSIZED_FRAME_BYTES - len(b'{"type": "ready"}')
)
assert len(oversized) == OVERSIZED_FRAME_BYTES
await host.send(oversized.decode("utf-8"))
# The 64 KB *binary* frame: the same bytes, but the client
# library encodes `bytes` as a binary frame — the regression
# the `KeyError`'s fix covers (the cap answers, not the
# stack's `receive_text()`).
await host.send(oversized)
# The 4 KB boundary: a `ready` padded with JSON whitespace to
# exactly `max_message_bytes` (not over — the cap is `>`).
payload = b'{"type": "ready"}'
boundary = payload + b" " * (MAX_MESSAGE_BYTES - len(payload))
assert len(boundary) == MAX_MESSAGE_BYTES
await host.send(boundary.decode("utf-8"))
# Read what the server said: two `too_big`s (the two 64 KB
# frames), the `ready`'s lobby re-broadcast, nothing else.
saw_too_big = 0
saw_lobby = False
deadline = asyncio.get_running_loop().time() + 5.0
while asyncio.get_running_loop().time() < deadline:
try:
frame = json.loads(await asyncio.wait_for(host.recv(), 0.5))
except TimeoutError:
break
if frame["type"] == "err":
if frame["code"] == "too_big":
saw_too_big += 1
else:
pytest.fail(f"an unexpected err frame: {frame}")
elif frame["type"] == "lobby":
saw_lobby = True
assert saw_too_big == 2, (
f"each 64 KB frame (text and binary) must be refused "
f"exactly once (too_big); saw {saw_too_big}"
)
assert saw_lobby, "the 4 KB boundary frame did not land (no ready-up)"
assert seat.ready is True, "the boundary frame's ready-up did not apply"
# ------------------------------------------------- 4: the lobby flood
async def test_a_lobby_flood_cannot_bypass_the_host_check_or_the_emote_throttle(
ws_server: Any,
) -> None:
"""A non-host floods the host-only verbs (`start` x 50,
`set_level` x 50) and the emote (x 20): the `not_host` refusals
are per-frame errors, not shape violations (the connection is not
closed by them), no transition happens, the level is untouched,
and the emote throttle (2 per 1.0 s) drops the overage silently
(no `err`, fewer `ev`s than the flood). The flood is paced to
the token bucket (40/40 — waves of ≤ 40 frames a second apart):
the `not_host` refusals must be tested *through* the rate guard,
not by tripping it first."""
with ws_server() as (url, app):
code = _create_room(url, "tok-host")
_join(url, code, "tok-b")
host = await _connect(url, code, "tok-host", "Host")
b = await _connect(url, code, "tok-b", "B")
room = app.state.supervisor.get(code)
assert room is not None
original_level = room.level
# The flood, paced to the token bucket (40/40): waves of ≤ 40
# frames, a second apart (the refill outpaces the wave — the
# `not_host` refusals are what the flood is meant to test).
async def _wave(frames: list[dict[str, Any]]) -> None:
for frame in frames:
with contextlib.suppress(Exception):
await b.send(json.dumps(frame))
await asyncio.sleep(1.1)
await _wave([{"type": "start"} for _ in range(40)])
await _wave(
[{"type": "start"} for _ in range(10)]
+ [{"type": "set_level", "level": "not-a-level"} for _ in range(30)]
)
await _wave(
[{"type": "set_level", "level": "not-a-level"} for _ in range(20)]
+ [{"type": "emote", "id": "wave"} for _ in range(10)]
)
await _wave(
[{"type": "emote", "id": "wave"} for _ in range(10)]
)
# B's view: `not_host` for every start/set_level (the semantic
# refusals — 100 of them), no close by the flood.
not_host = 0
emote_events_b = 0
deadline = asyncio.get_running_loop().time() + 5.0
while asyncio.get_running_loop().time() < deadline:
try:
frame = json.loads(await asyncio.wait_for(b.recv(), 0.5))
except TimeoutError:
break
if frame["type"] == "err" and frame["code"] == "not_host":
not_host += 1
elif frame["type"] == "ev":
for event in frame["events"]:
if event.get("k") == "emote":
emote_events_b += 1
assert not_host >= 100, (
f"every start/set_level needs its not_host refusal "
f"(got {not_host})"
)
assert (
b.close_code is None
), "the not_host refusals are semantic errors, not a 4404"
# The room is exactly as it was: no transition, no level.
assert room.state.value == "lobby", (
"the start flood must not have left the lobby"
)
assert room.level is original_level, (
"the set_level flood must not have changed the level"
)
# The emote overage is a silent drop: fewer events than the
# flood (2 per 1.0 s window — the whole burst is one window on
# a healthy loop; a slow one may roll into a second).
assert emote_events_b < 20, (
f"the emote throttle must drop the overage (got "
f"{emote_events_b} events from a 20-emote burst)"
)
# And the host is unaffected.
await _alive(host, "the host")
# ------------------------------------------------- 5: 200 handshakes
async def test_200_concurrent_handshakes_stay_within_the_memory_bound(
ws_server: Any,
) -> None:
"""200 concurrent handshakes (50 rooms x 4 seats, every seat
taken by a real socket): all 200 `welcome`s land, the p95
handshake is under 1 s, the socket census sees them, and the
process's RSS growth stays within the bound (measured: 23.4 MB
→ the bound is 5x that)."""
with ws_server() as (url, app):
code_of: dict[str, str] = {}
for room_index in range(50):
creator = f"tok-{room_index}-0"
code = _create_room(url, creator)
for seat_index in (1, 2, 3):
_join(url, code, f"tok-{room_index}-{seat_index}")
for seat_index in (0, 1, 2, 3):
code_of[f"tok-{room_index}-{seat_index}"] = code
assert len(code_of) == 200
baseline_mb = app.state.ws_registry.memory_mb()
async def handshake(token: str) -> tuple[str, float]:
started = time.monotonic()
ws = await websockets.connect(
url.replace("http://", "ws://", 1) + f"/ws/{code_of[token]}",
open_timeout=10.0,
)
await ws.send(
json.dumps({"type": "hello", "p": 1, "token": token})
)
welcome = json.loads(await ws.recv())
assert welcome["type"] == "welcome", welcome
return token, time.monotonic() - started
results = await asyncio.gather(
*(handshake(token) for token in code_of)
)
assert len(results) == 200
durations = sorted(duration for _, duration in results)
p95 = durations[int(0.95 * len(durations)) - 1]
assert p95 < HANDSHAKE_P95_BOUND_S, (
f"the p95 handshake is {p95 * 1000:.0f} ms "
f"(bound {HANDSHAKE_P95_BOUND_S * 1000:.0f} ms)"
)
# The census sees all 200 sockets (one per token — no
# eviction: every token is distinct).
stats = app.state.ws_registry.stats()
assert stats["sockets"] == 200, stats
# And the process's growth is bounded (the 200 connections'
# footprint: sockets, queues, the room/seat bookkeeping).
delta_mb = app.state.ws_registry.memory_mb() - baseline_mb
assert delta_mb <= HANDSHAKE_MEMORY_BOUND_MB, (
f"200 handshakes grew the process by {delta_mb:.1f} MB "
f"(bound {HANDSHAKE_MEMORY_BOUND_MB:.0f} MB)"
)
# The server is healthy under the load.
probe = await asyncio.to_thread(
httpx.get, f"{url}/healthz", timeout=5.0
)
assert probe.status_code == 200
+318
View File
@@ -0,0 +1,318 @@
"""The budget suite (phase 06 task 06, `slow`): PLAN §10's
performance numbers, measured on a **live** 4-player room — the mean
tick cost over 400 real 20 Hz ticks (≤ 2 ms, the p99 ≤ 8 ms), the
`snap` frame size (≤ 1.5 KB on the 4-player wire shape), the
downlink rate (≤ 60 KB/s over a 5 s window on one client) and the
input→snapshot echo (≤ 120 ms — the send to the first `snap` that
arrives after it: at 20 Hz the server answers within one tick —
wall-tick jitter re-encodes the same sim tick on some ticks, so the
*distinct*-tick definition is the 2-tick one, and the budget is 2.4
ticks).
The tick cost is measured by wrapping `room._tick` (the bound method
the room's own tick task drives — no `FakeClock`, no replay: the
wall-clock 20 Hz loop, the substep dance and all). The sim is kept
working by three driving sockets (a `input` frame each ~10 s⁻¹, a
random unit direction and an occasional press — the same choreography
the two-client round test uses, in miniature); the fourth cook stays
still and is the measurement socket (its `input` seq counter is not
interleaved with the drivers' — a stale-seq refusal would poison
both).
`docs/PROTOCOL.md` "Testing the protocol" carries the measured
values this suite bounds (the bound is the budget; the measurement
is what a regression moves).
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import math
import random
import time
from pathlib import Path
from typing import Any
import httpx
import pytest
import websockets
from websockets.exceptions import ConnectionClosed
pytestmark = [pytest.mark.integration, pytest.mark.slow]
PLAYABLE_LEVELS_DIR = (
Path(__file__).resolve().parents[1] / "fixtures" / "levels_playable"
)
#: The tick budget (PLAN §10): the mean over 400 real ticks, and the
#: p99 (a single slow tick may not hide in the mean).
TICKS_TO_MEASURE = 400
MEAN_TICK_BUDGET_S = 0.002
P99_TICK_BUDGET_S = 0.008
#: The `snap` size budget (the 4p wire shape, `test_encoding.py`'s
#: 1.5 KB).
SNAPSHOT_BUDGET_BYTES = 1500
#: The downlink budget (bytes per second, a 5 s window on one client).
DOWNLINK_WINDOW_S = 5.0
DOWNLINK_BUDGET_BYTES_PER_S = 60 * 1024
#: The input→snapshot echo budget (send → the first `snap` that
#: arrives after the send; at 20 Hz the server answers within one
#: tick — 50 ms — and the budget is 2.4 ticks).
ECHO_BUDGET_S = 0.120
# ------------------------------------------------------------- the room
def _create_room(url: str, token: str) -> str:
response = httpx.post(
f"{url}/api/rooms", json={"token": token}, timeout=10.0
)
assert response.status_code == 201, response.text
return response.json()["room_code"]
def _join(url: str, code: str, token: str) -> None:
response = httpx.post(
f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=10.0
)
assert 200 <= response.status_code < 300, response.text
async def _connect(url: str, code: str, token: str, name: str) -> Any:
ws = await websockets.connect(
url.replace("http://", "ws://", 1) + f"/ws/{code}", open_timeout=10.0
)
await ws.send(
json.dumps({"type": "hello", "p": 1, "token": token, "name": name})
)
welcome = json.loads(await ws.recv())
assert welcome["type"] == "welcome", welcome
return ws
async def _wait_for_async(predicate: Any, timeout_s: float) -> None:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if predicate():
return
await asyncio.sleep(0.05)
msg = f"timed out after {timeout_s:.0f} s"
raise TimeoutError(msg)
def _input_frame(rng: random.Random, seq: int) -> dict[str, Any]:
"""One `input` frame: a random unit movement intent (the sim
clamps any magnitude) and an occasional press."""
angle = rng.uniform(0.0, 2.0 * math.pi)
return {
"type": "input",
"seq": seq,
"move": {
"x": round(math.cos(angle), 6),
"y": round(math.sin(angle), 6),
},
"act": rng.random() < 0.1,
}
# --------------------------------------------------- the three phases
async def _measure_tick_cost(room: Any) -> tuple[float, float, int]:
"""The mean / p99 / n of the room's real tick, over
`TICKS_TO_MEASURE` ticks (the `room._tick` wrap — the room's own
tick task drives it; `tick_once`'s crash isolation stays outside
the wrap)."""
durations: list[float] = []
original_tick = room._tick
def _timed_tick(dt: float, when: float) -> None:
started = time.perf_counter()
original_tick(dt, when)
durations.append(time.perf_counter() - started)
room._tick = _timed_tick # type: ignore[method-assign]
try:
await _wait_for_async(
lambda: len(durations) >= TICKS_TO_MEASURE, timeout_s=60.0
)
finally:
room._tick = original_tick # type: ignore[method-assign]
mean_tick = sum(durations) / len(durations)
p99 = sorted(durations)[int(0.99 * len(durations)) - 1]
return mean_tick, p99, len(durations)
async def _measure_downlink(ws: Any) -> tuple[float, int, int]:
"""One client's downlink over `DOWNLINK_WINDOW_S`: the bytes per
second, the largest `snap` frame and the snap count. The kernel
TCP backlog the tick phase left on this (unread) socket is
drained first (2 s — the window's bound, and loopback delivers
the backlog far faster): without it the window would measure the
catch-up burst, not the steady state a continuously-reading
client sees."""
drain_deadline = time.monotonic() + 2.0
while time.monotonic() < drain_deadline:
with contextlib.suppress(
TimeoutError, asyncio.TimeoutError, ConnectionClosed
):
await asyncio.wait_for(ws.recv(decode=False), 0.5)
total_bytes = 0
snapshot_sizes: list[int] = []
deadline = time.monotonic() + DOWNLINK_WINDOW_S
while time.monotonic() < deadline:
try:
raw = await asyncio.wait_for(ws.recv(decode=False), 1.0)
except TimeoutError:
continue
except ConnectionClosed:
break
total_bytes += len(raw)
if json.loads(raw)["type"] == "snap":
snapshot_sizes.append(len(raw))
downlink_bytes_per_s = total_bytes / DOWNLINK_WINDOW_S
max_snapshot = max(snapshot_sizes) if snapshot_sizes else 0
return downlink_bytes_per_s, max_snapshot, len(snapshot_sizes)
async def _measure_echo(ws: Any, rng: random.Random) -> list[float]:
"""Three input→snapshot echoes (~1 s apart): the send to the first
`snap` that arrives after it. The echo budget is the bound — a
sample that misses it fails, and a snap already in the socket's
buffer (the tick between the send and the read) reads 0 ms."""
elapsed_s: list[float] = []
for sample in range(3):
frame = _input_frame(rng, 100_000 + sample)
sent_at = time.monotonic()
with contextlib.suppress(Exception):
await ws.send(json.dumps(frame))
saw_echo = False
sample_elapsed: float | None = None
deadline = sent_at + ECHO_BUDGET_S
while time.monotonic() < deadline:
try:
raw = await asyncio.wait_for(ws.recv(decode=False), 1.0)
except TimeoutError:
break # the echo budget is the bound; it expires
except ConnectionClosed:
break
if json.loads(raw)["type"] == "snap":
elapsed = time.monotonic() - sent_at
assert elapsed <= ECHO_BUDGET_S, (
f"the input→snapshot echo is "
f"{elapsed * 1000:.0f} ms "
f"(budget {ECHO_BUDGET_S * 1000:.0f} ms)"
)
saw_echo = True
sample_elapsed = elapsed
break
assert saw_echo and sample_elapsed is not None, (
f"sample {sample}: no snap frame arrived after the input "
f"within the {ECHO_BUDGET_S * 1000:.0f} ms echo budget"
)
elapsed_s.append(sample_elapsed)
await asyncio.sleep(1.0)
return elapsed_s
# ------------------------------------------------------------- the test
async def test_the_4_player_room_holds_the_tick_snapshot_downlink_and_echo_budgets(
ws_server: Any,
) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
code = _create_room(url, "tok-0")
for seat in (1, 2, 3):
_join(url, code, f"tok-{seat}")
clients = [
await _connect(url, code, f"tok-{seat}", f"Cook{seat}")
for seat in range(4)
]
room = app.state.supervisor.get(code)
assert room is not None
# Ready-up (every seat — the start guard), then the start.
for ws in clients:
await ws.send(json.dumps({"type": "ready"}))
await clients[0].send(json.dumps({"type": "start"}))
await _wait_for_async(
lambda: room.state.value == "playing"
and room.sim_state is not None,
timeout_s=30.0,
)
# The drivers: three cooks wander on a ~10 s⁻¹ cadence (a
# random unit direction, an occasional press). The sim is the
# loaded machine the budget applies to. The fourth (the
# measurement socket) stays still — its `input` seq counter
# is not interleaved with the drivers' (a stale-seq refusal
# would poison both). The seeded generator is the test's own
# determinism (a fixed choreography, never a credential —
# `random` is the sim's only RNG, and this is not the sim).
rng = random.Random(20240606) # noqa: S311
async def _drive(ws: Any) -> None:
seq = 1
while True:
frame = _input_frame(rng, seq)
seq += 1
with contextlib.suppress(Exception):
await ws.send(json.dumps(frame))
await asyncio.sleep(0.1)
drivers = [
asyncio.create_task(_drive(ws)) for ws in clients[:3]
]
# The measurement socket's keep-alive (the documented client
# behaviour — a silent socket for `HEARTBEAT_DEAD_S` (12 s) is
# a dead client, and the test's window outlives the silence):
# a `ping` every 2 s keeps it alive for the full ~30 s.
async def _keepalive(ws: Any) -> None:
n = 0
while True:
await asyncio.sleep(2.0)
n += 1
with contextlib.suppress(Exception):
await ws.send(json.dumps({"type": "ping", "t": n}))
keepalive = asyncio.create_task(_keepalive(clients[3]))
try:
# ---- the tick cost (400 real 20 Hz ticks, ~20 s of wall)
mean_tick, p99, n_ticks = await _measure_tick_cost(room)
assert mean_tick <= MEAN_TICK_BUDGET_S, (
f"the mean tick cost is {mean_tick * 1000:.3f} ms over "
f"{n_ticks} ticks (budget "
f"{MEAN_TICK_BUDGET_S * 1000:.1f} ms)"
)
assert p99 <= P99_TICK_BUDGET_S, (
f"the p99 tick cost is {p99 * 1000:.3f} ms "
f"(budget {P99_TICK_BUDGET_S * 1000:.1f} ms)"
)
# ---- the downlink + the snapshot size (one client, 5 s)
bytes_per_s, max_snapshot, n_snaps = await _measure_downlink(
clients[3]
)
assert n_snaps > 0, "no snap frames in the downlink window"
assert bytes_per_s <= DOWNLINK_BUDGET_BYTES_PER_S, (
f"the downlink is {bytes_per_s / 1024:.1f} KB/s "
f"(budget {DOWNLINK_BUDGET_BYTES_PER_S / 1024:.0f} KB/s)"
)
assert max_snapshot <= SNAPSHOT_BUDGET_BYTES, (
f"a snap frame is {max_snapshot} bytes "
f"(budget {SNAPSHOT_BUDGET_BYTES})"
)
# ---- the input→snapshot echo (3 samples, ~1 s apart)
await _measure_echo(clients[3], rng)
finally:
for task in drivers:
task.cancel()
keepalive.cancel()
with contextlib.suppress(asyncio.CancelledError):
await asyncio.gather(*drivers, keepalive)
+686
View File
@@ -0,0 +1,686 @@
"""Phase 06 task 05 (US-09): heartbeats, drops, the seat grace and the
rejoin — end to end.
Two parts, one contract (docs/PROTOCOL.md "Heartbeats, drops, and seat
grace"):
* **the room level** — a `FakeClock`-driven `Room` (no test sleeps):
`on_disconnect` keeps the seat and ghosts the sim's cook (one `GHOST_ON`),
the park at `PARK_AFTER_S` (3 s) releases the carried item exactly once
(a station set-down or `lost_items`), `on_reconnect` restores the seat
(one `GHOST_OFF`, the carry kept), a reconnect of a live seat emits no
`GHOST_OFF`, the `SeatAbandoned` at `guest_idle_timeout_s` empties the
seat with a *forced* release (even before the 3 s park deadline), and
a closed room runs the `on_close` hook once.
* **the socket level** — a live uvicorn server (real time, the 20 Hz tick
task) and `websockets` clients: an abrupt drop keeps the seat in the
grace and parks the carry, a rejoin inside the grace restores the seat
(`welcome.rejoined = true`, one `GHOST_OFF`, the snapshot stream resumes
with a full frame within one tick's budget), a rejoin after the grace
spectates (or gets `err room_gone` + close `4401` when the room has
closed in the meantime), a 12 s silent client is closed `1000` and the
room sees the standard drop, and the test-only seam
(`POST /api/test/kill_socket/{code}/{token}`, `UNDERCOOKED_TEST_MODE=1`
only) simulates a hard drop — `404` when the seam is off.
"""
from __future__ import annotations
import asyncio
import contextlib
import copy
import json
import pathlib
from collections.abc import Callable, Mapping
from dataclasses import replace as dc_replace
from typing import Any
import httpx
import pytest
import websockets
from undercooked.config import Settings
from undercooked.server.clock import FakeClock
from undercooked.server.room import Room, RoomState
from undercooked.sim.content import ContentBundle
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import (
EventKind,
Item,
ItemForm,
ItemKind,
PlayerState,
StationKind,
)
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
pytestmark = pytest.mark.integration
PLAYABLE_LEVELS_DIR = pathlib.Path(__file__).resolve().parents[1] / "fixtures" / "levels_playable"
# --------------------------------------------------------------------- helpers
def _uri(url: str, code: str) -> str:
return url.replace("http://", "ws://", 1) + f"/ws/{code}"
async def _connect(
url: str,
code: str,
token: str | None = None,
*,
name: str | None = None,
) -> Any:
"""A connected client with the `hello` already sent."""
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
if token is not None or name is not None:
hello: dict[str, Any] = {"type": "hello", "p": 1}
if token is not None:
hello["token"] = token
if name is not None:
hello["name"] = name
await ws.send(json.dumps(hello))
return ws
async def _next_json(ws: Any, wait_s: float = 15.0) -> dict[str, Any]:
return json.loads(await asyncio.wait_for(ws.recv(), wait_s))
async def _wait_for(
predicate: Callable[[], bool], what: str, wait_s: float = 20.0
) -> None:
"""Poll across threads (the server's loop does the work; this thread
only observes it)."""
loop = asyncio.get_running_loop()
deadline = loop.time() + wait_s
while not predicate():
if loop.time() > deadline:
pytest.fail(f"timed out waiting for {what}")
await asyncio.sleep(0.02)
def _create_room(url: str, token: str | None = None) -> str:
body: dict[str, Any] = {}
if token is not None:
body["token"] = token
response = httpx.post(f"{url}/api/rooms", json=body, timeout=5.0)
assert response.status_code == 201, response.text
return response.json()["room_code"]
def _join(url: str, code: str, token: str) -> None:
response = httpx.post(
f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0
)
assert response.status_code == 200, response.text
def _kill_socket(url: str, code: str, token: str) -> httpx.Response:
"""The test-only seam (`UNDERCOOKED_TEST_MODE=1`): a hard drop."""
return httpx.post(f"{url}/api/test/kill_socket/{code}/{token}", timeout=5.0)
def _ghost_count(
frames: list[dict[str, Any]], kind: str
) -> int:
"""How many `kind` events a frame stream carries (the `snap` frame
nests the PLAN §4 shape under `raw`, events in `raw.e` under `k`; the
standalone `ev` frame under `kind`)."""
count = 0
for frame in frames:
if frame["type"] == "snap":
count += sum(
1
for event in frame.get("raw", {}).get("e", ())
if event.get("k") == kind
)
elif frame["type"] == "ev":
count += sum(
1 for event in frame.get("events", ()) if event.get("kind") == kind
)
return count
def _sim_player(room: Room, token: str) -> Any:
sim = room.sim_state
assert sim is not None, "the round is not built"
player = next((p for p in sim.players if p.id == token), None)
assert player is not None, f"no sim player for {token!r}"
return player
def _counters(room: Room) -> Mapping[str, int]:
sim = room.sim_state
assert sim is not None, "the round is not built"
return sim.counters
def _seat_connected(room: Room, token: str) -> bool:
seat = room.seat_for_token(token)
return seat is not None and seat.connected is True
def _seat_disconnected(room: Room, token: str) -> bool:
seat = room.seat_for_token(token)
return seat is not None and seat.connected is False
def _give_carry(room: Room, token: str) -> None:
"""A test-side hand item: the park's release needs something to
release (a live round's cooking is too flaky to script)."""
sim = room.sim_state
assert sim is not None
sim = dc_replace(
sim,
players=tuple(
dc_replace(
p, carrying=Item(kind=ItemKind.TOMATO, form=ItemForm.RAW)
)
if p.id == token
else p
for p in sim.players
),
)
room.sim_state = sim
async def _playing_room(
url: str, app: Any, host_token: str, guest_token: str
) -> tuple[str, Room, Any, Any]:
"""Two seated players in `PLAYING` (the 3 s real-time countdown on
the 20 Hz tick task)."""
code = _create_room(url, host_token)
_join(url, code, guest_token)
host = await _connect(url, code, host_token, name="Host")
await _next_json(host) # welcome
await _next_json(host) # lobby
guest = await _connect(url, code, guest_token, name="Guest")
await _next_json(guest) # welcome
await _next_json(guest) # lobby
await guest.send(json.dumps({"type": "ready"}))
await host.send(json.dumps({"type": "start"}))
room = app.state.supervisor.get(code)
assert room is not None
await _wait_for(
lambda: room.state is RoomState.PLAYING and room.sim_state is not None,
f"room {code} to reach PLAYING",
wait_s=30.0,
)
return code, room, host, guest
async def _close_clean(ws: Any) -> None:
with contextlib.suppress(Exception):
await ws.close()
def _keepalive(ws: Any) -> asyncio.Task[None]:
"""The documented keep-alive (≥ 5 Hz budget; 0.5 Hz is plenty for a
test client): a silent socket for `HEARTBEAT_DEAD_S` (12 s) is a
dead client, so a test client that sits for a while must ping."""
state = {"n": 0}
async def _loop() -> None:
while True:
await asyncio.sleep(2.0)
state["n"] += 1
with contextlib.suppress(Exception):
await ws.send(json.dumps({"type": "ping", "t": state["n"]}))
return asyncio.create_task(_loop())
# ------------------------------------------------- the room level (FakeClock)
def make_book() -> RecipeBook:
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) -> Level:
"""A 7x4 kitchen; `time_limit` 60 s — a `PLAYING` round that will not
end while the grace/park windows run."""
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"Ghost kitchen {level_id}",
"order": 1,
"time_limit": 60.0,
"target_score": 1000,
"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() -> ContentBundle:
return ContentBundle(recipe_book=make_book(), levels=(make_level("ghost"),))
def make_room(
**settings: Any,
) -> tuple[Room, FakeClock]:
clock = FakeClock()
bundle = make_bundle()
room = Room(
code="TEST1",
settings=Settings(**settings),
clock=clock,
content=bundle,
level_id=bundle.levels[0].id,
)
return room, clock
def _playing(room: Room) -> float:
"""A two-seat `PLAYING` room; returns the `now` the round starts at."""
room.add_seat("tok-a", "Ada", True)
room.add_seat("tok-b", "Bob", False)
room.set_ready("tok-b", True)
room.request_start()
room.tick_once(now=0.0) # the dt-0 prime (the countdown's first frame)
for offset in (1.0, 2.0, 3.0):
room.tick_once(now=offset)
assert room.state is RoomState.PLAYING
return 3.0
def test_on_disconnect_keeps_the_seat_and_ghosts_the_cook() -> None:
room, _ = make_room()
now = _playing(room)
room.on_disconnect("tok-b", now=now)
seat = room.seat_for_token("tok-b")
assert seat is not None # the seat is KEPT for the rejoin path
assert seat.connected is False
player = _sim_player(room, "tok-b")
assert player.connected is False
assert player.state is PlayerState.DISCONNECTED
events = [e for e in room.take_events() if e.kind is EventKind.GHOST_ON]
assert len(events) == 1 # one `GHOST_ON` per drop
# A second drop mark is a no-op: no second `GHOST_ON`.
assert room.on_disconnect("tok-b", now=now) is True
assert room.take_events() == ()
def test_park_releases_the_carried_item_after_three_seconds() -> None:
room, _ = make_room()
now = _playing(room)
_give_carry(room, "tok-b")
room.on_disconnect("tok-b", now=now)
events = [e for e in room.take_events() if e.kind is EventKind.GHOST_ON]
assert len(events) == 1 # one `GHOST_ON` per drop
# One second before the deadline: no release yet.
room.tick_once(now=now + 2.9)
assert _sim_player(room, "tok-b").carrying is not None
assert room.take_events() == ()
# The 3 s deadline (the room's tick, off `seat.last_seen`): the item
# is released into `lost_items` (the tile has no station under the
# spawn) with one `GHOST_PARK`.
room.tick_once(now=now + 3.0)
assert _sim_player(room, "tok-b").carrying is None
assert _counters(room)["lost_items"] == 1
kinds = [e.kind for e in room.take_events()]
assert EventKind.GHOST_PARK in kinds
# A later tick cannot double-release.
room.tick_once(now=now + 6.0)
assert _counters(room)["lost_items"] == 1
assert room.take_events() == ()
def test_on_reconnect_restores_the_seat_and_keeps_the_carry() -> None:
room, _ = make_room()
now = _playing(room)
_give_carry(room, "tok-b")
room.on_disconnect("tok-b", now=now)
room.on_reconnect("tok-b", now=now + 1.0)
seat = room.seat_for_token("tok-b")
assert seat is not None and seat.connected is True
player = _sim_player(room, "tok-b")
assert player.connected is True
assert player.state is PlayerState.IDLE
assert player.carrying is not None # the rejoin keeps the carry
events = [e for e in room.take_events() if e.kind is EventKind.GHOST_OFF]
assert len(events) == 1 # one `GHOST_OFF` (the drop's `GHOST_ON` drained)
def test_reconnect_without_a_disconnect_emits_no_ghost_off() -> None:
room, _ = make_room()
_playing(room)
assert room.on_reconnect("tok-b") is True # the seat was never a ghost
assert room.take_events() == () # no `GHOST_OFF`
def test_seat_abandoned_after_the_grace_releases_and_empties() -> None:
room, _ = make_room(guest_idle_timeout_s=2.0)
now = _playing(room)
_give_carry(room, "tok-b")
room.on_disconnect("tok-b", now=now)
# Inside the grace: the seat stays (the rejoin window is open).
room.tick_once(now=now + 1.5)
assert room.seat_for_token("tok-b") is not None
# Past the grace: the seat is emptied, the carry released at once,
# one `GHOST_OFF`, the sim's player stays (parked).
room.tick_once(now=now + 2.1)
assert room.seat_for_token("tok-b") is None
assert _sim_player(room, "tok-b").carrying is None
assert _sim_player(room, "tok-b").connected is False
assert _counters(room)["lost_items"] == 1
kinds = [e.kind for e in room.take_events()]
assert EventKind.GHOST_OFF in kinds
# The room may close once the last seat is gone too: the close
# rules apply (the 30 s seat grace / the supervisor's GC).
room.on_disconnect("tok-a", now=now + 2.1)
room.tick_once(now=now + 4.2)
assert room.seat_for_token("tok-a") is None
assert room.is_empty
def test_abandon_before_the_park_deadline_forces_the_release() -> None:
"""A 1 s grace runs out before the 3 s park deadline: the release
must not wait for the park — the seat is going away."""
room, _ = make_room(guest_idle_timeout_s=1.0)
now = _playing(room)
_give_carry(room, "tok-b")
room.on_disconnect("tok-b", now=now)
room.tick_once(now=now + 1.1) # the grace expires, 1.9 s before the park
assert room.seat_for_token("tok-b") is None
assert _sim_player(room, "tok-b").carrying is None
assert _counters(room)["lost_items"] == 1
def test_a_closed_room_runs_the_on_close_hook_once() -> None:
calls: list[str] = []
async def _hook(room: Room) -> None:
calls.append(room.code)
clock = FakeClock()
bundle = make_bundle()
room = Room(
code="TEST1",
settings=Settings(),
clock=clock,
content=bundle,
level_id=bundle.levels[0].id,
on_close=_hook,
)
room.add_seat("tok-a", "Ada", True)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(room.close())
assert calls == ["TEST1"] # once, not twice
loop.run_until_complete(room.close()) # idempotent: no second call
assert calls == ["TEST1"]
finally:
loop.close()
# ------------------------------------------------- the socket level (live)
async def test_an_abrupt_drop_keeps_the_seat_and_parks_the_carry(
ws_server: Any,
) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
_code, room, host, guest = await _playing_room(url, app, "tok-a", "tok-b")
_keep = _keepalive(host)
_give_carry(room, "tok-b")
# The drop: the client closes abruptly (no clean close).
with contextlib.suppress(Exception):
await guest.close()
await _wait_for(
lambda: _seat_disconnected(room, "tok-b"),
"the seat to be marked disconnected",
wait_s=10.0,
)
seat = room.seat_for_token("tok-b")
assert seat is not None # the seat is kept for the grace
assert seat.connected is False
player = _sim_player(room, "tok-b")
assert player.connected is False
assert player.state is PlayerState.DISCONNECTED
# The park at 3 s (the room's tick, real time): the carry lands
# in `lost_items`; the wire saw one `GHOST_ON` and one
# `GHOST_PARK` (collected on the other player's stream).
frames: list[dict[str, Any]] = []
await _wait_for(
lambda: room.sim_state is not None
and room.sim_state.counters.get("lost_items") == 1,
"the park release",
wait_s=15.0,
)
# Drain the host's stream and count the ghost events. Bounded
# by design: a live room's `snap` stream never goes quiet
# while the round is `PLAYING` (this level's 150 s time limit
# outlives the test), and both ghost events land within one
# tick of the drop and of the park — a 10 s window after the
# park release is ten times the margin; the `ConnectionClosed`
# break still catches the round ending early.
deadline = asyncio.get_running_loop().time() + 10.0
while asyncio.get_running_loop().time() < deadline:
try:
frames.append(
json.loads(await asyncio.wait_for(host.recv(), 1.0))
)
except TimeoutError:
continue # a quiet second is fine; the window bounds
except websockets.exceptions.ConnectionClosed:
break
assert _ghost_count(frames, "ghost_on") == 1
assert _ghost_count(frames, "ghost_park") == 1
_keep.cancel()
with contextlib.suppress(asyncio.CancelledError):
await _keep
async def test_rejoin_in_grace_restores_the_seat(ws_server: Any) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
code, room, host, guest = await _playing_room(url, app, "tok-a", "tok-b")
_keep = _keepalive(host)
await _close_clean(guest)
await _wait_for(
lambda: _seat_disconnected(room, "tok-b"),
"the seat to be marked disconnected",
wait_s=10.0,
)
# The rejoin inside the grace (a fresh socket, the same token).
back = await _connect(url, code, "tok-b", name="Guest")
welcome = await _next_json(back)
assert welcome["type"] == "welcome"
assert welcome["rejoined"] is True # the rejoin flag (task 05)
await _wait_for(
lambda: _seat_connected(room, "tok-b"),
"the seat to be restored",
wait_s=10.0,
)
player = _sim_player(room, "tok-b")
assert player.connected is True
# The snapshot stream resumes with a full frame within one
# tick's budget (the rejoining socket's `needs_full` resync).
snap = None
while snap is None:
frame = json.loads(await asyncio.wait_for(back.recv(), 5.0))
if frame["type"] == "snap":
snap = frame
assert "d" not in snap.get("raw", {}) # a full frame, not a delta
# The wire saw one `GHOST_OFF` (and the drop's one `GHOST_ON`).
frames: list[dict[str, Any]] = []
for _ in range(30):
try:
frames.append(json.loads(await asyncio.wait_for(host.recv(), 1.0)))
except TimeoutError:
break
except websockets.exceptions.ConnectionClosed:
break
assert _ghost_count(frames, "ghost_on") == 1
assert _ghost_count(frames, "ghost_off") >= 1
_keep.cancel()
with contextlib.suppress(asyncio.CancelledError):
await _keep
async def test_rejoin_after_the_grace_spectates(ws_server: Any) -> None:
with ws_server(
levels_dir=str(PLAYABLE_LEVELS_DIR), guest_idle_timeout_s=2.0
) as (url, app):
code, room, _host, guest = await _playing_room(url, app, "tok-a", "tok-b")
await _close_clean(guest)
await _wait_for(
lambda: room.seat_for_token("tok-b") is None,
"the seat to be abandoned (the grace ran out)",
wait_s=15.0,
)
# The rejoin after the grace: the seat is gone — a late join in
# `PLAYING` spectates (not a rejoin).
back = await _connect(url, code, "tok-b", name="Guest")
welcome = await _next_json(back)
assert welcome["type"] == "welcome"
assert welcome["rejoined"] is False
assert room.seat_for_token("tok-b") is None
assert room.spectator_for_token("tok-b") is not None
async def test_a_rejoin_into_a_gone_room_says_room_gone(ws_server: Any) -> None:
with ws_server(
levels_dir=str(PLAYABLE_LEVELS_DIR),
guest_idle_timeout_s=2.0,
lobby_idle_timeout_s=3.0,
) as (url, app):
# A solo host room in `LOBBY`: the drop empties it, and the
# supervisor's GC closes it (the `LOBBY` idle rule, the 5 s
# pass).
code = _create_room(url, "tok-a")
solo = await _connect(url, code, "tok-a", name="Solo")
await _next_json(solo) # welcome
await _next_json(solo) # lobby
room = app.state.supervisor.get(code)
assert room is not None
await _close_clean(solo)
await _wait_for(
lambda: room.seat_for_token("tok-a") is None,
"the seat to be abandoned",
wait_s=15.0,
)
await _wait_for(
lambda: room.state is RoomState.CLOSED,
"the room to close (the GC's idle rule)",
wait_s=30.0,
)
# A stale-token rejoin: `err room_gone` + close `4401`, before
# any seat is taken — the client's "the room died" signal. The
# close may race the open handshake (the accept went out before
# the refusal): either the `err` frame arrives, or the `4401`
# close does — both carry the same signal.
closed: websockets.exceptions.ConnectionClosed | None = None
late: Any = None
try:
late = await websockets.connect(_uri(url, code), open_timeout=10.0)
await late.send(json.dumps({"type": "hello", "p": 1, "token": "tok-a"}))
for _ in range(5): # the refusal close follows the `err`
await asyncio.wait_for(late.recv(), 10.0)
except websockets.exceptions.ConnectionClosed as exc:
closed = exc
assert closed is not None and closed.rcvd is not None
assert closed.rcvd.code == 4401
async def test_a_dead_client_is_closed_by_the_heartbeat_watchdog(ws_server: Any) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
code = _create_room(url, "tok-a")
silent = await _connect(url, code, "tok-a", name="Silent")
await _next_json(silent) # welcome
await _next_json(silent) # lobby
room = app.state.supervisor.get(code)
assert room is not None
# 12 s of silence (no `ping`): the watchdog closes `1000` and
# the room sees the standard drop — indistinguishable from a
# hard drop (one mark, one ghost).
with pytest.raises(websockets.exceptions.ConnectionClosed) as exc:
while True:
await asyncio.wait_for(silent.recv(), 20.0)
assert exc.value.rcvd is not None
assert exc.value.rcvd.code == 1000
await _wait_for(
lambda: _seat_disconnected(room, "tok-a"),
"the seat to be marked disconnected",
wait_s=10.0,
)
async def test_the_test_seam_kills_a_socket(ws_server: Any) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR), test_mode=True) as (
url,
app,
):
code, room, _host, guest = await _playing_room(url, app, "tok-a", "tok-b")
# The seam force-closes the socket server-side: a hard drop for
# the client, the standard `on_disconnect` for the room.
response = _kill_socket(url, code, "tok-b")
assert response.status_code == 200, response.text
# The client sees the hard drop (past any buffered frames).
closed = False
for _ in range(50):
try:
await asyncio.wait_for(guest.recv(), 10.0)
except websockets.exceptions.ConnectionClosed:
closed = True
break
assert closed, "the killed socket did not close"
await _wait_for(
lambda: _seat_disconnected(room, "tok-b"),
"the seat to be marked disconnected",
wait_s=10.0,
)
# An unknown token: `404 no_connection`.
response = _kill_socket(url, code, "tok-nobody")
assert response.status_code == 404
assert response.json()["code"] == "no_connection"
async def test_the_test_seam_is_gated_by_test_mode(ws_server: Any) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, _app):
# `test_mode` unset: the seam is not registered at all.
code = _create_room(url, "tok-a")
solo = await _connect(url, code, "tok-a", name="Solo")
await _next_json(solo) # welcome
await _next_json(solo) # lobby
response = _kill_socket(url, code, "tok-a")
assert response.status_code == 404
# The socket is still alive (the seam never ran).
await _close_clean(solo)
+477
View File
@@ -0,0 +1,477 @@
"""Integration: the WebSocket endpoint, handshake, and connection guardrails
(phase 06 task 02; US-01/US-02) — every path driven by **real sockets**
against a live `uvicorn.Server` on an ephemeral `127.0.0.1` port (the
`ws_server` factory in `tests/integration/conftest.py`), with the app's
own supervisor reached for state assertions (the seat's `connected` flag,
the `mark_disconnected` call count, the socket census).
* **the happy handshake** — `POST /api/rooms` (a known token) →
`/ws/{code}` → `hello` → `welcome` (the joiner's seat index / host flag
correct) + the current `lobby` (the host's seat, `can_start`); a
`ping` inside the handshake window gets a `pong` before the `hello`,
* **the refusal closes** — unknown code → `err room_gone` + close `4401`;
a full room (a *new* token) → `err room_full` + close `4402`; no
`hello` within the (shortened) handshake window → close `4400`; a `p`
outside the supported range → `err protocol_mismatch` + close `4400` on
the first bad frame (before any seat is taken);
* **the origin check** — an explicit `allowed_origins` list that the
request's `Origin` misses is refused at the HTTP level (the client
sees a 403, never a close frame); the same-host rule (the empty
setting) admits the matching `Origin`;
* **one socket per token** — a second `hello` for a token evicts the
first (`err evicted` + close `4403`), the first stops receiving, and
the *evicted* socket's teardown does not mark the seat disconnected
(the new socket owns it — the crown stays put);
* **the size/rate guardrails** — an oversized frame is `too_big` and
three consecutive refusals close `4404`; a message flood is
`rate_limited` and three consecutive refusals close `4404`; five
shape violations in the handshake window close `4404`; a
`wrong_state` / `unknown_type` frame is refused with the named code
and the connection stays open;
* **the disconnect** — an abrupt transport close (the client's socket
vanishes without a close handshake) reaches the room as exactly one
`mark_disconnected` (the seat is kept for the rejoin path), releases
the registry slot, and leaves no error in the server log.
Note on the "abrupt close" mechanics: the installed `websockets` (17.2)
client has no `close(transport=True)` keyword, so the test drives the
abrupt path through `ws.transport.close()` — the same primitive. A bare
FIN is enough: the server's transport sees the EOF, uvicorn closes the
connection, and the endpoint's `finally` reports the disconnect.
"""
from __future__ import annotations
import asyncio
import json
import logging
from collections.abc import Callable
from typing import Any
import httpx
import pytest
import websockets
from websockets.exceptions import ConnectionClosed, InvalidHandshake
pytestmark = pytest.mark.integration
HELLO_TIMEOUT_S = 0.5 # the shortened window, set via settings (task 02)
# --------------------------------------------------------------------- helpers
def _uri(url: str, code: str) -> str:
return url.replace("http://", "ws://", 1) + f"/ws/{code}"
async def _connect(
url: str,
code: str,
token: str | None = None,
*,
p: int = 1,
name: str | None = None,
origin: str | None = None,
) -> Any:
"""A connected client with a `hello` already sent (the handshake is
the point under test, so the hello goes out immediately)."""
kwargs: dict[str, Any] = {"open_timeout": 10.0}
if origin is not None:
kwargs["origin"] = origin
ws = await websockets.connect(_uri(url, code), **kwargs)
if token is not None or name is not None or p != 1:
hello: dict[str, Any] = {"type": "hello", "p": p}
if token is not None:
hello["token"] = token
if name is not None:
hello["name"] = name
await ws.send(json.dumps(hello))
return ws
async def _next_json(ws: Any) -> dict[str, Any]:
return json.loads(await ws.recv())
async def _close_code(ws: Any) -> int:
with pytest.raises(ConnectionClosed) as excinfo:
await ws.recv()
return int(excinfo.value.code)
async def _drain_until_close(
ws: Any, max_frames: int, wait_s: float = 5.0
) -> tuple[list[dict[str, Any]], int | None]:
"""Read frames until the connection closes.
A WebSocket client may hold *buffered* frames — the `too_big` /
`rate_limited` errors the server queued before the abuse close
arrive ahead of the close frame — so the close must be expected
after draining them, not on the very next read. Returns the frames
(in arrival order) and the close code (`None` if the window ran out
first, which the caller asserts against).
"""
frames: list[dict[str, Any]] = []
close_code: int | None = None
for _ in range(max_frames):
try:
raw = await asyncio.wait_for(ws.recv(), wait_s)
except ConnectionClosed as exc:
close_code = int(exc.code)
break
frames.append(json.loads(raw))
return frames, close_code
async def _poll(predicate: Callable[[], bool], what: str, wait_s: float = 5.0) -> None:
"""Poll across threads (the server's loop does the work; this thread
only observes it)."""
loop = asyncio.get_running_loop()
deadline = loop.time() + wait_s
while not predicate():
if loop.time() > deadline:
pytest.fail(f"timed out waiting for {what}")
await asyncio.sleep(0.01)
def _create_room(url: str, token: str | None = None) -> tuple[str, str | None]:
"""`POST /api/rooms` over the live server; returns (code, you.token)."""
body: dict[str, Any] = {}
if token is not None:
body["token"] = token
response = httpx.post(f"{url}/api/rooms", json=body, timeout=5.0)
assert response.status_code == 201, response.text
data = response.json()
return data["room_code"], (data["you"] or {}).get("token")
def _join(url: str, code: str, token: str) -> None:
response = httpx.post(
f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0
)
assert response.status_code == 200, response.text
# ---------------------------------------------------------------- the happy path
async def test_happy_handshake_welcome_and_lobby(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, you_echo = _create_room(url, "tok-creator")
assert you_echo == "tok-creator"
async with await _connect(url, code, "tok-joiner", name="Joiner") as ws:
welcome = await _next_json(ws)
assert welcome["type"] == "welcome"
assert welcome["p"] == 1
assert welcome["room"] == code
assert welcome["state"] == "lobby"
assert welcome["you"] == {
"token": "tok-joiner",
"seat": 1,
"host": False,
}
seats = welcome["seats"]
assert len(seats) == 2
assert seats[0]["index"] == 0
assert seats[0]["host"] is True
assert seats[1]["index"] == 1
assert seats[1]["name"] == "Joiner"
assert seats[1]["host"] is False
lobby = await _next_json(ws)
assert lobby["type"] == "lobby"
assert lobby["code"] == code
assert lobby["state"] == "lobby"
assert lobby["host_seat"] == 0
# The fixture campaign is level-free, so `can_start` is False
# (the start preconditions — level selected, ready players — are
# covered by the phase-05 lifecycle suite over real levels).
assert lobby["can_start"] is False
# The ready-up: a toggle that re-broadcasts the lobby.
await ws.send(json.dumps({"type": "ready"}))
lobby2 = await _next_json(ws)
assert lobby2["can_start"] is False # still: no level selected
assert lobby2["seats"][1]["ready"] is True
assert len(lobby2["seats"]) == 2
async def test_ping_before_hello_gets_pong(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
await ws.send(json.dumps({"type": "ping", "t": 7}))
pong = await _next_json(ws)
assert pong["type"] == "pong"
assert pong["t"] == 7
await ws.send(json.dumps({"type": "hello", "p": 1}))
welcome = await _next_json(ws)
assert welcome["type"] == "welcome"
await ws.close()
# ---------------------------------------------------------------- refusal closes
async def test_unknown_code_closes_4401(ws_server: Any) -> None:
with ws_server() as (url, _app):
ws = await websockets.connect(_uri(url, "ZZZZZ"), open_timeout=10.0)
err = await _next_json(ws)
assert err["type"] == "err"
assert err["code"] == "room_gone"
assert await _close_code(ws) == 4401
async def test_full_room_new_token_closes_4402(ws_server: Any) -> None:
with ws_server(max_players=2) as (url, _app):
code, _ = _create_room(url, "tok-a")
_join(url, code, "tok-b") # the second (and last) seat
async with await _connect(url, code, "tok-c") as ws:
err = await _next_json(ws)
assert err["type"] == "err"
assert err["code"] == "room_full"
assert await _close_code(ws) == 4402
async def test_no_hello_within_timeout_closes_4400(ws_server: Any) -> None:
with ws_server(hello_timeout_s=HELLO_TIMEOUT_S) as (url, _app):
code, _ = _create_room(url)
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
# No hello at all: the shortened window closes the socket.
assert await _close_code(ws) == 4400
async def test_wrong_protocol_version_closes_4400(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
async with await _connect(url, code, "tok-x", p=99) as ws:
err = await _next_json(ws)
assert err["type"] == "err"
assert err["code"] == "protocol_mismatch"
assert await _close_code(ws) == 4400
# No seat was taken on the refused version.
room = _app.state.supervisor.get(code)
assert len(room.seats) == 1
# ---------------------------------------------------------------- the origin check
async def test_disallowed_origin_refused_at_http_level(ws_server: Any) -> None:
with ws_server(allowed_origins=("https://elsewhere.example",)) as (url, _app):
code, _ = _create_room(url)
# The client's Origin (http://127.0.0.1:<port>) is not in the
# explicit list: the upgrade is refused before the accept — the
# client sees a 403 handshake failure, never a close frame.
with pytest.raises(InvalidHandshake):
await websockets.connect(_uri(url, code), open_timeout=10.0)
async def test_same_host_origin_allowed(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
origin = "http://" + url.removeprefix("http://")
async with await _connect(url, code, "tok-orig", origin=origin) as ws:
welcome = await _next_json(ws)
assert welcome["type"] == "welcome"
# ------------------------------------------------- one socket per token (eviction)
async def test_second_socket_same_token_evicts_first(ws_server: Any) -> None:
with ws_server() as (url, app):
code, _ = _create_room(url)
first = await _connect(url, code, "tok-shared")
welcome = await _next_json(first)
assert welcome["type"] == "welcome"
await _next_json(first) # the lobby
second = await _connect(url, code, "tok-shared")
welcome2 = await _next_json(second)
assert welcome2["type"] == "welcome"
assert welcome2["you"]["seat"] == 1 # the creator holds seat 0
# The evicted socket: the named refusal, then the close.
err = await _next_json(first)
assert err["type"] == "err"
assert err["code"] == "evicted"
assert await _close_code(first) == 4403
# The evicted socket's teardown must not mark the seat
# disconnected (the new socket owns it now), and the crown stays
# with the creator (an eviction is not a host leave — no
# `transfer_host`). Checked before `second` closes: closing it
# is a genuine disconnect for the token.
room = app.state.supervisor.get(code)
seat = room.seat_for_token("tok-shared")
assert seat is not None
assert seat.connected is True
assert room.seats[0].host is True
await second.close()
async def test_evicted_socket_stops_receiving(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
first = await _connect(url, code, "tok-shared")
await _next_json(first) # welcome
await _next_json(first) # lobby
second = await _connect(url, code, "tok-shared")
await _next_json(second) # welcome
await _next_json(second) # lobby
await _next_json(first) # the evicted err
await _close_code(first) # 4403 (asserted in the other test; the
# point here is what happens *after* the close)
# The evicted socket is out of the broadcast: a ready-up from the
# second socket re-broadcasts the lobby to the second only.
await second.send(json.dumps({"type": "ready"}))
lobby = await _next_json(second)
assert lobby["type"] == "lobby"
assert any(seat["ready"] for seat in lobby["seats"])
await second.close()
# ----------------------------------------------------------- size / rate guardrails
async def test_oversized_frame_refused_then_4404(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
async with await _connect(url, code, "tok-big") as ws:
await _next_json(ws) # welcome
await _next_json(ws) # lobby
oversized = json.dumps(
{"type": "ping", "t": 1, "pad": "x" * 5000} # > max_message_bytes
)
for _ in range(3): # three consecutive refusals -> the abuse close
await ws.send(oversized)
frames, close_code = await _drain_until_close(ws, 8)
assert frames, "the oversized frames must be refused"
assert all(
frame["type"] == "err" and frame["code"] == "too_big"
for frame in frames
), f"unexpected frames before the close: {frames}"
assert close_code == 4404
async def test_flood_rate_limited_then_4404(ws_server: Any) -> None:
with ws_server(max_inputs_per_s=5) as (url, _app):
code, _ = _create_room(url)
async with await _connect(url, code, "tok-flood") as ws:
await _next_json(ws) # welcome
await _next_json(ws) # lobby
for index in range(8): # burst 5: the 6th..8th exhaust the bucket
await ws.send(json.dumps({"type": "ping", "t": index}))
frames, close_code = await _drain_until_close(ws, 16)
assert any(
frame.get("type") == "err" and frame["code"] == "rate_limited"
for frame in frames
), f"the flood never tripped the token bucket: {frames}"
assert close_code == 4404
async def test_shape_violations_in_handshake_window_close_4404(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
for _ in range(5): # five shape violations inside the window
await ws.send("{not json")
assert await _close_code(ws) == 4404
async def test_wrong_state_refused_and_connection_stays(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
async with await _connect(url, code, "tok-state") as ws:
await _next_json(ws) # welcome
await _next_json(ws) # lobby
await ws.send(
json.dumps(
{
"type": "input", # legal in PLAYING, not in LOBBY
"seq": 1,
"move": {"x": 1.0, "y": 0.0},
"act": False,
"face": None,
}
)
)
err = await _next_json(ws)
assert err["type"] == "err"
assert err["code"] == "wrong_state"
# One shape violation: the connection survives.
await ws.send(json.dumps({"type": "ping", "t": 3}))
pong = await _next_json(ws)
assert pong["type"] == "pong"
async def test_unknown_type_refused_and_connection_stays(ws_server: Any) -> None:
with ws_server() as (url, _app):
code, _ = _create_room(url)
async with await _connect(url, code, "tok-bogus") as ws:
await _next_json(ws) # welcome
await _next_json(ws) # lobby
await ws.send(json.dumps({"type": "teleport", "x": 1}))
err = await _next_json(ws)
assert err["type"] == "err"
assert err["code"] == "unknown_type"
await ws.send(json.dumps({"type": "ping", "t": 4}))
pong = await _next_json(ws)
assert pong["type"] == "pong"
# ------------------------------------------------------------------- the disconnect
async def test_abrupt_close_notifies_room_once_and_releases(
ws_server: Any,
) -> None:
with ws_server() as (url, app):
code, _ = _create_room(url, "tok-abrupt")
supervisor = app.state.supervisor
calls: list[tuple[str, str]] = []
original = supervisor.mark_disconnected
def counting(raw_code: str, token: str) -> None:
calls.append((raw_code, token))
original(raw_code, token)
supervisor.mark_disconnected = counting
records: list[logging.LogRecord] = []
class _Collector(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
records.append(record)
collector = _Collector(level=logging.ERROR)
logging.getLogger().addHandler(collector)
try:
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
await ws.send(json.dumps({"type": "hello", "p": 1, "token": "tok-abrupt"}))
assert json.loads(await ws.recv())["type"] == "welcome"
room = supervisor.get(code)
assert room.seat_for_token("tok-abrupt").connected is True
ws.transport.close() # abrupt: the transport vanishes, no close handshake
# Both halves of the teardown (the `mark_disconnected` and the
# registry release) must be visible before the assertions — they
# run in the server's loop, this one in the test's.
await _poll(
lambda: room.seat_for_token("tok-abrupt").connected is False
and app.state.ws_registry.socket_count == 0,
"the seat to be marked disconnected and the socket released",
)
finally:
supervisor.mark_disconnected = original
logging.getLogger().removeHandler(collector)
# Exactly one on_disconnect — and for the right token.
assert calls == [(code, "tok-abrupt")]
# No exception in the server log: the drop is a clean no-op.
errors = [record for record in records if record.levelno >= logging.ERROR]
exceptions = [
record for record in errors if record.exc_info is not None
]
assert not exceptions, (
"the server log carries an exception for the abrupt close:\n"
+ "\n".join(repr(record.exc_info) for record in exceptions)
)
+517
View File
@@ -0,0 +1,517 @@
"""Integration: input ingestion over **real sockets** (phase 06, task 03;
US-04/US-06) — the `seq` discipline, the hostile-clamp invariants, the
seat rules and the per-tick queue, driven against a live `uvicorn.Server`
(the `ws_server` factory in `tests/integration/conftest.py`) with a
*playable* level (the shipped, already-validated `01_first_kitchen` in
`tests/fixtures/levels_playable/`, so a room can actually reach `PLAYING`
— the fixture campaign in `tests/fixtures/levels/` has no levels and a
room built from it cannot start a round).
* **the `seq` discipline, end to end** — monotonic seqs are accepted
(`inputs_received` / `last_seq` advance); an equal seq is an idempotent
resend, silently dropped; a backwards seq is `err bad_field` *with the
frame's `seq`* + the room's `resync` event (`stale_seqs` counted,
`last_seq` never lowered); a jump of ≤ 3 ahead is tolerated silently;
a larger jump is accepted and counted in `input_gaps`;
* **the hostile clamp** — a `move` of `{x: 1000, y: 1000}` is a
`bad_field` at the wire (the `Vec2Msg`'s early, explicit half), and a
cook that *is* moving (fed a legitimate intent) never exceeds
`MAX_SPEED x 1.01` — per-tick distance `vel x 0.05` within
`MAX_SPEED x 0.05 x 1.01`; a `pos` field (the teleport attempt) is a
`bad_field` by `extra="forbid"`; a non-bool `act` is rejected upstream;
* **the seat rules** — a spectator's input is `err not_seated`; a
`DISCONNECTED` seat's input is silently ignored (no err, no stat);
* **the per-tick queue** — a 20-frame burst on a one-seat room never
leaves more than `4 x 1` frames queued (the capacity), every frame is
accounted (`inputs_received + inputs_dropped == 20` — the drops
themselves are proven deterministic in
`tests/unit/test_ws_input_path.py`), and the sim keeps stepping
through it.
The sim-side half of the hostile-speed proof — `(1000, 1000)` driven
through `to_player_input` + `Room.queue_input` + `tick_once` for 20
ticks — lives in `tests/unit/test_ws_input_path.py`, where it can be
measured tick-exact; here the live socket proves the wire layer's half.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
from collections.abc import Callable
from pathlib import Path
from typing import Any
import httpx
import pytest
import websockets
from undercooked.server.room import Room, RoomState
from undercooked.sim.movement import MAX_SPEED
pytestmark = pytest.mark.integration
#: The playable campaign (the shipped level 1, validated by the content
#: gate at server boot) — a room built from it can start a round.
PLAYABLE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels_playable"
# --------------------------------------------------------------------- helpers
def _uri(url: str, code: str) -> str:
return url.replace("http://", "ws://", 1) + f"/ws/{code}"
async def _connect(
url: str,
code: str,
token: str | None = None,
*,
name: str | None = None,
) -> Any:
"""A connected client with the `hello` already sent."""
ws = await websockets.connect(_uri(url, code), open_timeout=10.0)
if token is not None or name is not None:
hello: dict[str, Any] = {"type": "hello", "p": 1}
if token is not None:
hello["token"] = token
if name is not None:
hello["name"] = name
await ws.send(json.dumps(hello))
return ws
async def _next_json(ws: Any, wait_s: float = 10.0) -> dict[str, Any]:
return json.loads(await asyncio.wait_for(ws.recv(), wait_s))
async def _next_non_countdown(ws: Any, wait_s: float = 10.0) -> dict[str, Any]:
"""The next frame that is not part of the continuous `PLAYING`
stream (task 04: the 3 s countdown's `n=3,2,1` frames queue before
the first `PLAYING` frame, and the `snap` stream then flows
continuously — a `PLAYING` probe skips both)."""
while True:
frame = await _next_json(ws, wait_s)
if frame["type"] not in ("countdown", "snap"):
return frame
async def _wait_for(
predicate: Callable[[], bool], what: str, wait_s: float = 15.0
) -> None:
"""Poll across threads (the server's loop does the work; this thread
only observes it)."""
loop = asyncio.get_running_loop()
deadline = loop.time() + wait_s
while not predicate():
if loop.time() > deadline:
pytest.fail(f"timed out waiting for {what}")
await asyncio.sleep(0.01)
def _create_room(url: str, token: str | None = None) -> str:
"""`POST /api/rooms` over the live server; returns the code."""
body: dict[str, Any] = {}
if token is not None:
body["token"] = token
response = httpx.post(f"{url}/api/rooms", json=body, timeout=5.0)
assert response.status_code == 201, response.text
return response.json()["room_code"]
def _join(url: str, code: str, token: str) -> None:
response = httpx.post(
f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0
)
assert response.status_code == 200, response.text
def _conn_for_token(app: Any, code: str, token: str) -> Any:
for conn in app.state.ws_registry.for_room(code):
if conn.token == token:
return conn
msg = f"no live connection for token {token!r} in room {code}"
raise AssertionError(msg)
async def _playing_room(
url: str, app: Any, host_token: str
) -> tuple[str, Room, Any]:
"""Create the room, connect the host, start (solo host), and wait for
`PLAYING` (the 3 s real-time countdown on the 20 Hz tick task)."""
code = _create_room(url, host_token)
host = await _connect(url, code, host_token, name="Host")
await _next_json(host) # welcome
await _next_json(host) # lobby
await host.send(json.dumps({"type": "start"})) # solo host
room = app.state.supervisor.get(code)
assert room is not None
await _wait_for(
lambda: room.state is RoomState.PLAYING and room.sim_state is not None,
f"room {code} to reach PLAYING",
wait_s=20.0,
)
return code, room, host
async def _send_input(ws: Any, seq: int, **extra: Any) -> None:
frame: dict[str, Any] = {
"type": "input",
"seq": seq,
"move": {"x": 1.0, "y": 0.0},
"act": False,
"face": None,
}
frame.update(extra)
await ws.send(json.dumps(frame))
# ------------------------------------------------------------ the seq discipline
async def test_seq_discipline_end_to_end(ws_server: Any) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
code, room, host = await _playing_room(url, app, "tok-seq")
stats = room.stats
# Monotonic seqs are accepted.
for seq in (1, 2, 3):
await _send_input(host, seq)
await _wait_for(
lambda: stats()["inputs_received"] == 3,
"the three accepted inputs to be counted",
)
conn = _conn_for_token(app, code, "tok-seq")
assert conn.last_seq == 3
# An equal seq is an idempotent resend: silently dropped.
await _send_input(host, 3)
await asyncio.sleep(0.3)
assert stats()["inputs_received"] == 3
assert conn.last_seq == 3
# A backwards seq: `err bad_field` with the frame's `seq` + the
# room's `resync` event; `last_seq` is never lowered.
await _send_input(host, 1)
err = await _next_non_countdown(host)
assert err["type"] == "err"
assert err["code"] == "bad_field"
assert err["seq"] == 1
await _wait_for(
lambda: stats()["stale_seqs"] == 1,
"the stale-seq refusal to be counted",
)
assert conn.last_seq == 3
await _wait_for(
lambda: any(event.kind == "resync" for event in room.events_since_snapshot),
"the resync event to be buffered",
)
resync = next(
event
for event in room.events_since_snapshot
if event.kind == "resync"
)
assert resync.payload == {"player_id": "tok-seq", "seq": 1}
# A jump of ≤ 3 ahead is tolerated silently.
await _send_input(host, 6)
await _wait_for(
lambda: conn.last_seq == 6 and stats()["inputs_received"] == 4,
"the tolerated gap to be accepted",
)
assert stats()["input_gaps"] == 0
# A larger jump is accepted and counted.
await _send_input(host, 60)
await _wait_for(
lambda: conn.last_seq == 60 and stats()["inputs_received"] == 5,
"the large gap to be accepted",
)
assert stats()["input_gaps"] == 1
# ------------------------------------------------------------ the hostile clamp
async def test_a_hostile_move_is_rejected_and_the_cook_stays_within_max_speed(
ws_server: Any,
) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
_, room, host = await _playing_room(url, app, "tok-hostile")
# The wire's early, explicit half: magnitude > 1 is a `bad_field`,
# whatever the `seq` is (the parse fails before the seq check).
await _send_input(host, 999, move={"x": 1000.0, "y": 1000.0}, act=True)
err = await _next_non_countdown(host)
assert err["type"] == "err"
assert err["code"] == "bad_field"
# The sim-side bound: feed a legitimate intent on a ~10 Hz feeder
# (the cook accelerates to the cap and stays there) while the
# hostile frame's effect — none — is what the bound must hold.
base = 1
# Feed slightly faster than the 50 ms tick cadence: a frame in
# every tick window (the room's substep guard carries a queued
# frame to the next substep-advancing tick, so at this cadence
# the cook has an intent on every tick and accelerates to the
# cap — a slower feed would let it brake between frames).
async def feed() -> None:
nonlocal base
for offset in range(24):
await _send_input(host, base + offset)
base += 1
await asyncio.sleep(0.04)
feeder = asyncio.create_task(feed())
await asyncio.sleep(0.7) # acceleration window (30 tiles/s^2)
samples: list[float] = []
for _ in range(5):
assert room.sim_state is not None
(player,) = room.sim_state.players
samples.append(player.vel.length)
await asyncio.sleep(0.1)
feeder.cancel()
with contextlib.suppress(asyncio.CancelledError):
await feeder
bound = MAX_SPEED * 1.01
assert samples, "no velocity samples were taken"
assert max(samples) <= bound, (
f"the cook reached {max(samples):.3f} tiles/s; the bound is {bound:.3f}"
)
# The bound was non-trivial: the cook was genuinely moving (within
# the cap) when it was measured — a stationary cook would prove
# nothing about a hostile `move`.
assert max(samples) >= 0.5 * MAX_SPEED, (
f"the cook only reached {max(samples):.3f} tiles/s — the bound "
f"was not exercised (expected near {MAX_SPEED:.3f})"
)
# Per-tick distance = vel x STEP_DT: the sweep can only apply
# *less* motion than the velocity would, so the distance bound
# follows from the velocity bound.
assert max(samples) * 0.05 <= MAX_SPEED * 0.05 * 1.01
async def test_a_position_field_is_rejected_by_the_schema(ws_server: Any) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
code, room, host = await _playing_room(url, app, "tok-pos")
# The teleport attempt: a `pos` field in an `input` (there is no
# position field in the protocol; `extra="forbid"` rejects one).
await host.send(
json.dumps(
{
"type": "input",
"seq": 1,
"move": {"x": 1.0, "y": 0.0},
"act": False,
"face": None,
"pos": {"x": 3.5, "y": 1.5},
}
)
)
err = await _next_non_countdown(host)
assert err["type"] == "err"
assert err["code"] == "bad_field"
assert "pos" in err["msg"] # the extra field is named
# Nothing was accepted (and `last_seq` never moved on a refusal).
assert room.stats()["inputs_received"] == 0
assert _conn_for_token(app, code, "tok-pos").last_seq == 0
async def test_act_non_bool_is_rejected_upstream(ws_server: Any) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
_, room, host = await _playing_room(url, app, "tok-act")
await host.send(
json.dumps(
{
"type": "input",
"seq": 1,
"move": {"x": 1.0, "y": 0.0},
"act": 1, # a JSON int, not a bool: the strict model refuses
"face": None,
}
)
)
err = await _next_non_countdown(host)
assert err["type"] == "err"
assert err["code"] == "bad_field"
assert room.stats()["inputs_received"] == 0
async def test_face_is_derived_from_move_in_the_sim(ws_server: Any) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
_, room, host = await _playing_room(url, app, "tok-face")
# `face: null` + `move (0, -1)`: the facing is derived (up).
await _send_input(host, 1, move={"x": 0.0, "y": -1.0})
await _wait_for(
lambda: room.sim_state is not None
and room.sim_state.players[0].facing == "up",
"the derived facing to reach the sim",
wait_s=10.0,
)
# And the other way down, for symmetry.
await _send_input(host, 2, move={"x": 0.0, "y": 1.0})
await _wait_for(
lambda: room.sim_state is not None
and room.sim_state.players[0].facing == "down",
"the derived facing to flip",
wait_s=10.0,
)
# --------------------------------------------------------------- the seat rules
async def test_a_spectators_input_is_refused_not_seated(ws_server: Any) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
code, _, _ = await _playing_room(url, app, "tok-spect")
room = app.state.supervisor.get(code)
assert room is not None
# A late join (the room is past `LOBBY`): `tok-watch` becomes a
# spectator — its input is the documented `not_seated`.
watcher = await _connect(url, code, "tok-watch", name="Watcher")
await _next_json(watcher) # welcome
await _next_json(watcher) # lobby
await _send_input(watcher, 1)
err = await _next_json(watcher)
assert err["type"] == "err"
assert err["code"] == "not_seated"
assert room.stats()["inputs_received"] == 0
async def test_a_disconnected_seats_input_is_silently_ignored(ws_server: Any) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
# Two players in before the start: the host starts, the ready-up
# is the non-host's.
code = _create_room(url, "tok-p1")
_join(url, code, "tok-p2")
p1 = await _connect(url, code, "tok-p1", name="P1")
await _next_json(p1) # welcome
await _next_json(p1) # lobby
p2 = await _connect(url, code, "tok-p2", name="P2")
await _next_json(p2) # welcome
await _next_json(p2) # lobby
# The documented keep-alive (phase 06 task 05): both clients
# ping periodically — under load the `PLAYING` wait below may
# outlast the 12 s heartbeat dead window, and a silent socket
# is a dead client (the server's close `1000` is correct
# behaviour, not a bug).
async def _keepalive() -> None:
n = 0
while True:
await asyncio.sleep(2.0)
n += 1
for socket in (p1, p2):
with contextlib.suppress(Exception):
await socket.send(json.dumps({"type": "ping", "t": n}))
keepalive = asyncio.create_task(_keepalive())
await p2.send(json.dumps({"type": "ready"}))
await p1.send(json.dumps({"type": "start"}))
room = app.state.supervisor.get(code)
assert room is not None
await _wait_for(
lambda: room.state is RoomState.PLAYING and room.sim_state is not None,
f"room {code} to reach PLAYING",
wait_s=20.0,
)
# The seat's connection "drops" (the supervisor marks it — the
# socket stays open: this is the mark the endpoint's `finally`
# makes on a genuine drop, driven directly here).
app.state.supervisor.mark_disconnected(code, "tok-p2")
assert room.seat_for_token("tok-p2").connected is False
# The (still-open) socket's input is silently ignored: no err, no
# stat, `last_seq` untouched. (The `ready` re-broadcast is still
# in the socket's queue — drain it first: it predates the drop.)
# Bounded by design: a live room's `snap` stream never goes
# quiet while the round is `PLAYING` (this level's 150 s time
# limit outlives the test), so the pre-existing `ready`
# re-broadcast, the three `countdown` frames and the first
# snaps all land within a few seconds of the start — an 8 s
# window covers them with twice the margin, and it is the
# window the ignored input's (absent) `err` would have to
# arrive in.
await _send_input(p2, 1)
deadline = asyncio.get_running_loop().time() + 8.0
while asyncio.get_running_loop().time() < deadline:
try:
pending = json.loads(
await asyncio.wait_for(p2.recv(), 0.5)
)
except TimeoutError:
continue # a quiet half second is fine; the window bounds
assert pending["type"] in (
"lobby",
"countdown",
"snap",
"pong",
), (
f"only the pre-existing `lobby` re-broadcast, the "
f"countdown frames, the keep-alive `pong`s and the "
f"`snap` stream may still be in flight; got {pending}"
)
await asyncio.sleep(0.5)
assert room.stats()["inputs_received"] == 0
assert room.stats()["stale_seqs"] == 0
assert _conn_for_token(app, code, "tok-p2").last_seq == 0
# And the silence held: no *refusal* frame arrived after the
# drain. The live socket keeps receiving `snap` frames (the
# dropped seat's cook is parked, not ejected — the stream
# continues for any live connection in the player audience);
# what must not come is the `err`.
saw_refusal: dict[str, Any] | None = None
deadline = asyncio.get_running_loop().time() + 3.0
while asyncio.get_running_loop().time() < deadline:
try:
frame = json.loads(await asyncio.wait_for(p2.recv(), 1.0))
except TimeoutError:
continue # the point: the refusal was *silent*
if frame["type"] == "err":
saw_refusal = frame
break
assert saw_refusal is None, (
f"an unexpected err frame reached the ignored seat: {saw_refusal}"
)
keepalive.cancel()
with contextlib.suppress(asyncio.CancelledError):
await keepalive
# ----------------------------------------------------------- the per-tick queue
async def test_a_burst_never_exceeds_the_capacity_and_the_sim_keeps_stepping(
ws_server: Any,
) -> None:
with ws_server(levels_dir=str(PLAYABLE_LEVELS_DIR)) as (url, app):
_, room, host = await _playing_room(url, app, "tok-burst")
# One seat: the queue's capacity is `4 x 1`. A 20-frame burst (all
# within the token bucket's burst of 40) — with a single seat every
# queued entry is the sender's own, so an overflow evicts the
# sender's oldest (never a rejection) and *every* frame lands:
# `inputs_received` reaches 20 no matter how the 20 Hz tick task
# interleaves with the burst (the drop *mechanism* is proven
# deterministic in tests/unit/test_ws_input_path.py).
start_tick = room.sim_state.tick if room.sim_state is not None else 0
for seq in range(1, 21):
await _send_input(host, seq)
for _ in range(6):
assert len(room._queued_inputs) <= 4, (
f"the per-tick queue grew past its capacity "
f"(4 x 1 seat): {len(room._queued_inputs)} frames"
)
await asyncio.sleep(0.03)
await _wait_for(
lambda: room.stats()["inputs_received"] == 20,
"every burst frame to be queued (one seat: evict, never reject)",
)
# The sim kept stepping through the whole burst.
await _wait_for(
lambda: room.sim_state is not None
and room.sim_state.tick >= start_tick + 10,
"the sim to keep stepping (10+ ticks)",
wait_s=15.0,
)
@@ -0,0 +1,589 @@
"""Integration: a complete round over **real sockets**, two clients
(phase 06 task 06; US-02/US-03/US-05) — the `live_server` / `ws_client`
fixtures from `tests/integration/conftest.py` drive a playable campaign
(`tests/fixtures/levels_round/`: the shipped level 1 verbatim in layout
and content, `target_score` 20 and `time_limit` 60 s so the scripted
first serve wins the round — one soup scores 44-66 by
`sim/scoring.py`, the shipped 150 s / 45 target would make the win
depend on a *second* serve's timing; the wire contract under test is
the same).
The round, end to end, on the wire:
* **the lobby** — A creates the room (HTTP, a known token), B joins
(HTTP), both connect and handshake (`welcome` + `lobby`); A (the
host) sends `set_level`, both send `ready`, then A sends `start`;
* **the countdown and the stream** — both receive `countdown`
`n = 3, 2, 1`, then 30 `snap` frames with non-decreasing `t` and ≥ 20
distinct ticks (a substep-less wall tick — the 20 Hz cadence jitters
48-51 ms — re-encodes the same sim tick, so "increasing" means that,
not *strictly* increasing, and the 30-frame window carries the
re-encode tolerance);
* **the inputs** — A drives the scripted soup (walk → shelf pick →
board hold → stove hold → prep hold → serve press; one rising-edge
press per verb, holds until the view's station reads `ready`), B
drives a walking loop; both clients' merged snapshot views carry the
state;
* **the `SERVED` event and the score** — both observe the same
`served` event (the payload's `oi` / `pts` / `q` / `stk`) and the
same final `score` in the `round` frame;
* **the `round` frame** — the round ends `WON` on the serve's
substep; both receive `round` and the two payloads are JSON-equal
(byte-identical under the canonical dump: the only
viewer-specific field, `unlocked`, is `None` for both — a win on
the campaign's single level unlocks nothing);
* **the lobby return** — after `round`, A's `to_lobby` returns both to
`LOBBY` with the seats preserved (two seats, the ready flags reset,
the crown on A).
The choreography is feedback-driven off the merged view (positions are
one snapshot stale — ≤ 100 ms at the 20 Hz stream — so every station
visit settles: wait for the view to confirm arrival, hold the action
while the station works, release, one fresh press for the pick). The
staging points are the cells *directly* adjacent to each station, on
the axis the approach comes in on (the reach zone's discrete half:
the front cell, the two diagonal-front cells, the tile under the cook
— a station whose cell is not in that set is not a candidate at all).
"""
from __future__ import annotations
import asyncio
import contextlib
import itertools
import json
import math
from pathlib import Path
from typing import Any
import httpx
import pytest
pytestmark = pytest.mark.integration
#: The playable campaign (the shipped level 1, layout-verbatim,
#: `target_score` 20 / `time_limit` 60 s — the module docstring's
#: tuning): a room built from it can start a round and the first
#: serve wins it.
ROUND_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels_round"
# The level 1 geometry (grid cell (col, row); the wire's position is in
# tile units, a cell (x, y) centre is (x + 0.5, y + 0.5).
SHELF_STAGE = (2.5, 1.5) # cell (2, 1), facing LEFT at the shelf (1, 1)
BOARD_STAGE = (3.5, 1.5) # cell (3, 1), facing RIGHT at the board (4, 1)
STOVE_STAGE = (7.5, 1.5) # cell (7, 1), facing RIGHT at the stove (8, 1)
PREP_STAGE = (3.5, 3.5) # cell (3, 3), facing LEFT at the prep (2, 3)
SERVE_STAGE = (9.5, 3.5) # the window's own cell (walkable, `PASS_THROUGH`)
# ~16.7 Hz feed: a frame in (almost) every wall-tick window — the
# per-tick queue then carries an intent to every substep-advancing tick,
# so the cook's velocity holds near the cap instead of oscillating
# (a slower feed lets it brake between frames; the carried items rot
# with the time, and the serve's `quality` is what the win turns on).
INPUT_INTERVAL_S = 0.06
# ---------------------------------------------------------------- helpers
def _input(seq: int, move: tuple[float, float] | None, act: bool) -> dict[str, Any]:
x, y = move if move is not None else (0.0, 0.0)
return {
"type": "input",
"seq": seq,
"move": {"x": x, "y": y},
"act": act,
"face": None,
}
class _Sequencer:
"""The client's monotonic `seq` counter (one per connection — the
seq discipline is per connection)."""
def __init__(self) -> None:
self.next = 0
def __call__(self) -> int:
self.next += 1
return self.next
async def _walk_to(
client: Any,
pid: str,
seq: _Sequencer,
target: tuple[float, float],
band: float,
*,
wait_s: float = 40.0,
settle: bool = False,
) -> None:
"""Walk the view position to within `band` of `target`.
With `settle` (the final waypoint) the arrival is then confirmed
*twice* — the second check after a 150 ms settle (a full snapshot
cycle, the view is ≤ 100 ms stale at 20 Hz) — with a zero `move`
so the derived facing keeps the last approach vector (aimed at the
target, which the staging points sit on the axis of) and the
deceleration of the last approach vector runs out in ~0.12 s.
"""
loop = asyncio.get_running_loop()
deadline = loop.time() + wait_s
while True:
player = client.view.player(pid)
assert player is not None, "the scripted cook vanished from the view"
dx = target[0] - player["x"]
dy = target[1] - player["y"]
distance = math.hypot(dx, dy)
if distance <= band:
if settle:
await client.send(_input(seq(), None, False))
await asyncio.sleep(0.15) # the settle: the view catches up
player = client.view.player(pid)
assert player is not None
distance = math.hypot(
target[0] - player["x"], target[1] - player["y"]
)
if distance <= band + 0.2: # the deceleration tail
return
continue # the settle was not enough: walk again
return # pass the waypoint immediately (the caller walks on —
# a stopped cook does not leave the band on its own, and a
# sleeping loop here would dead-lock the choreography)
assert loop.time() < deadline, f"timed out walking to {target}"
length = distance if distance > 1e-6 else 1.0
await client.send(_input(seq(), (dx / length, dy / length), False))
await asyncio.sleep(INPUT_INTERVAL_S)
async def _walk(
client: Any,
pid: str,
seq: _Sequencer,
waypoints: list[tuple[float, float]],
band: float = 0.4,
*,
wait_s: float = 40.0,
) -> None:
"""Walk the waypoint list; the last one is the settled stop.
The waypoints detour around the *blocking* fixtures (a body of
`2 x RADIUS` = 0.7 tiles cannot pass a station's cell — the sweep
stops it before it intrudes, and a body touching a blocking cell's
edge cannot move into it: the scripted path has to route around,
the same way a human cook would). The corridors keep ≥ 0.15 tile
between the body edge and every blocking cell.
"""
for target in waypoints[:-1]:
await _walk_to(client, pid, seq, target, 0.35, wait_s=wait_s)
await _walk_to(
client, pid, seq, waypoints[-1], band, wait_s=wait_s, settle=True
)
async def _press(client: Any, seq: _Sequencer) -> None:
"""One action press: a rising edge (an `act: true` frame after an
`act: false` one — the `action_gate`'s one-press-one-event), then
the release."""
await client.send(_input(seq(), None, True))
await asyncio.sleep(0.09)
await client.send(_input(seq(), None, False))
async def _hold_until(
client: Any, seq: _Sequencer, predicate: Any, wait_s: float = 20.0
) -> None:
"""Hold the action down (the first frame is the edge — the caller
just walked with `act: false`) until `predicate()`; the station's
progress advances while the worker is in reach and holding
(`sim/stations.py` stage 4)."""
loop = asyncio.get_running_loop()
deadline = loop.time() + wait_s
while not predicate():
assert loop.time() < deadline, "timed out holding the action"
await client.send(_input(seq(), None, True))
await asyncio.sleep(INPUT_INTERVAL_S)
async def _wait_for(predicate: Any, what: str, wait_s: float = 20.0) -> None:
loop = asyncio.get_running_loop()
deadline = loop.time() + wait_s
while not predicate():
if loop.time() > deadline:
pytest.fail(f"timed out waiting for {what}")
await asyncio.sleep(0.05)
def _station_ready(client: Any, kind: str) -> bool:
"""The view's station of `kind` reads `ready` (the work is done)."""
station = client.view.station(kind)
return station is not None and station.get("s") == "ready"
def _carrying(client: Any, pid: str, short: str) -> bool:
"""The view's player carries the item with the short form `short`
(e.g. `tomato_chopped`)."""
player = client.view.player(pid)
return player is not None and player.get("h") == short
def _create_room(url: str, token: str) -> str:
response = httpx.post(f"{url}/api/rooms", json={"token": token}, timeout=5.0)
assert response.status_code == 201, response.text
return response.json()["room_code"]
def _join(url: str, code: str, token: str) -> None:
response = httpx.post(
f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0
)
assert response.status_code == 200, response.text
async def _start_playing(
url: str, app: Any, ws_client: Any
) -> tuple[str, Any, Any, Any]:
"""Two clients, the room, and the lobby, to `PLAYING` (the module
docstring's lobby half); returns `(code, room, a, b)`."""
supervisor = app.state.supervisor
code = _create_room(url, "tok-a")
_join(url, code, "tok-b")
a = await ws_client(url, code, "tok-a", name="A")
b = await ws_client(url, code, "tok-b", name="B")
for client in (a, b):
welcome = await client.expect("welcome")
assert welcome["state"] == "lobby"
assert welcome["rejoined"] is False
lobby = await client.expect("lobby")
assert lobby["state"] == "lobby"
a.send_model({"type": "set_level", "level": "01_first_kitchen"})
await _wait_for(
lambda: supervisor.get(code) is not None
and supervisor.get(code).level is not None,
"the room to hold the level pick",
)
a.send_model({"type": "ready"})
b.send_model({"type": "ready"})
# The ready-ups arrive on *different* connections — the host's `start`
# must not race ahead of B's (a premature start is the named
# `no_ready_players` refusal). Wait for the room to see both.
room = supervisor.get(code)
assert room is not None
await _wait_for(
lambda: room.can_start,
"the room to see both ready-ups (can_start)",
wait_s=10.0,
)
a.send_model({"type": "start"})
await _wait_for(
lambda: room.state.value == "playing" and room.sim_state is not None,
"the room to reach PLAYING (the 3 s real-time countdown)",
wait_s=20.0,
)
# Drain the lobby re-broadcasts (the `set_level` pick and the two
# ready-ups) so a later `expect("lobby")` cannot match a stale one —
# they arrived before the countdown frames, so the queue is in order.
for client in (a, b):
await client.collect("lobby", 3, wait_s=10.0)
return code, room, a, b
async def _drive_soup(client: Any, pid: str, seq: _Sequencer) -> None:
"""The scripted soup: shelf → board → stove → prep → serve, one
fresh press per verb, holds until the view's station reads
`ready`."""
# 1. The tomato, off the spawn shelf (the room seeded 8 of them).
# The detour: straight up column 2 runs into the prep's cell
# (blocking); the x = 3.5 corridor is clear of it (body edge
# 3.15 > 3.0) and of the board (3.85 < 4.0).
await _walk(
client,
pid,
seq,
[(3.5, 7.5), (3.5, 1.5), SHELF_STAGE],
0.4,
)
await _press(client, seq)
await _wait_for(
lambda: _carrying(client, pid, "tomato_raw"),
f"{pid} to pick the tomato",
wait_s=10.0,
)
# 2. Chop it (0.9 s of work).
await _walk(client, pid, seq, [BOARD_STAGE], 0.4)
await _hold_until(client, seq, lambda: _station_ready(client, "chop_board"))
await client.send(_input(seq(), None, False)) # the release
await asyncio.sleep(0.09)
await _press(client, seq)
await _wait_for(
lambda: _carrying(client, pid, "tomato_chopped"),
f"{pid} to pick the chopped tomato",
wait_s=10.0,
)
# 3. Cook it (6 s of work, the pot's turn). The board's cell blocks
# the row-1 corridor: the row-2 corridor is clear (body edge
# 5.15 > 5.0 clears the board; 7.85 < 8.0 clears the stove).
# The last leg comes in *west-to-east* along row 1: the reach
# zone's discrete half (the front cell, the two diagonal-front
# cells, the tile under the cook) puts the stove's cell (8, 1)
# in the candidate set only while the cook faces RIGHT.
await _walk(
client,
pid,
seq,
[(3.5, 2.5), (5.5, 2.5), (5.5, 1.5), STOVE_STAGE],
0.4,
)
await _hold_until(
client, seq, lambda: _station_ready(client, "stove"), wait_s=25.0
)
await client.send(_input(seq(), None, False))
await asyncio.sleep(0.09)
await _press(client, seq)
await _wait_for(
lambda: _carrying(client, pid, "tomato_cooked"),
f"{pid} to pick the cooked tomato",
wait_s=10.0,
)
# 4. Plate it (0.6 s of work, a plate out of the clean stock).
# The prep's cell blocks its own west side: the staging point is
# its *east* neighbour (body edge 3.15 > 3.0), reached along row 3.
await _walk(
client,
pid,
seq,
[(7.5, 3.5), PREP_STAGE],
0.4,
)
await _hold_until(client, seq, lambda: _station_ready(client, "prep"))
await client.send(_input(seq(), None, False))
await asyncio.sleep(0.09)
await _press(client, seq)
await _wait_for(
lambda: _carrying(client, pid, "tomato_plated"),
f"{pid} to pick the plated dish",
wait_s=10.0,
)
# 5. Serve it (the window is walkable: stand on it, one press).
await _walk(client, pid, seq, [SERVE_STAGE], 0.4)
await _press(client, seq)
async def _wander(client: Any, pid: str, seq: _Sequencer) -> None:
"""B's input: a walking loop in rows 6-7 (A's route is the top
rows — the two cooks never share a tile; the loop's body edges
stay clear of the row-5 fixtures: at y = 6.5 the body edge is
6.15 > 6.0)."""
waypoints: list[tuple[float, float]] = [
(10.5, 7.5), # east along the bottom row
(10.5, 6.5), # north
(5.5, 6.5), # west
(5.5, 7.5), # south
]
for target in waypoints:
await _walk_to(client, pid, seq, target, 0.3, wait_s=20.0)
await client.send(_input(seq(), None, False))
async def _keepalive(clients: tuple[Any, ...]) -> None:
"""The documented keep-alive (the 12 s heartbeat dead window): a
`ping` from every client every 2 s — a silent socket is a dead
client, and the round outlasts the window."""
n = 0
while True:
await asyncio.sleep(2.0)
n += 1
for client in clients:
with contextlib.suppress(Exception):
client.send_model({"type": "ping", "t": n})
# ---------------------------------------------------------------- the round
def _assert_snap_ticks(name: str, frames: list[dict[str, Any]]) -> None:
"""One client's `snap` stream: non-decreasing `t` (a substep-less
wall tick re-encodes the same sim tick — "increasing" means
non-decreasing here) plus ≥ 20 *distinct* ticks (the stream
stalled if it stalled at one tick)."""
ticks = [frame["raw"]["t"] for frame in frames]
assert all(
later >= earlier for earlier, later in itertools.pairwise(ticks)
), f"client {name}'s snap ticks went backwards: {ticks}"
assert len(set(ticks)) >= 20, (
f"client {name} saw {len(set(ticks))} distinct ticks in "
f"{len(ticks)} frames (the stream stalled)"
)
def _assert_lobby_return(name: str, lobby: dict[str, Any]) -> None:
"""One client's `to_lobby` re-broadcast: the state is `lobby`, the
seats are preserved, the ready flags reset, the crown stays on A.
"""
assert lobby["state"] == "lobby", f"client {name} is not back in the lobby"
assert len(lobby["seats"]) == 2, (
f"client {name}: the seats are not preserved"
)
assert all(seat["ready"] is False for seat in lobby["seats"]), (
f"client {name}: the ready flags must reset on the lobby return"
)
assert lobby["host_seat"] == 0, (
f"client {name}: the crown must stay on A"
)
async def test_two_clients_play_a_complete_round(
live_server: Any, ws_client: Any
) -> None:
with live_server(levels_dir=str(ROUND_LEVELS_DIR)) as (url, app):
code, room, a, b = await _start_playing(url, app, ws_client)
seq_a, seq_b = _Sequencer(), _Sequencer()
# --- the countdown 3 -> 1, then the snap stream
countdown_a = await a.collect("countdown", 3, wait_s=20.0)
countdown_b = await b.collect("countdown", 3, wait_s=20.0)
assert [frame["n"] for frame in countdown_a] == [3, 2, 1]
assert [frame["n"] for frame in countdown_b] == [3, 2, 1]
# ≥ 20 `snap` frames with increasing `t`: a substep-less wall tick
# (a 20 Hz cadence jitters 48-51 ms) re-encodes the *same* sim
# tick, so "increasing" is non-decreasing plus ≥ 20 *distinct*
# ticks — 30 frames carry the tolerance for the re-encodes.
snaps_a = await a.collect("snap", 30, wait_s=30.0)
snaps_b = await b.collect("snap", 30, wait_s=30.0)
for name, frames in (("a", snaps_a), ("b", snaps_b)):
_assert_snap_ticks(name, frames)
# --- the inputs: A the scripted soup, B the walking loop, both
# kept alive past the heartbeat dead window
keepalive = asyncio.create_task(_keepalive((a, b)))
soup = asyncio.create_task(_drive_soup(a, "tok-a", seq_a))
try:
await _wander(b, "tok-b", seq_b) # B's inputs, while the soup runs
await soup
# --- the `SERVED` event, both clients, the same payload
room = app.state.supervisor.get(code)
assert room is not None and room.sim_state is not None
await _wait_for(
lambda: a.view.event("served") is not None,
"A to see the `served` event",
wait_s=30.0,
)
await _wait_for(
lambda: b.view.event("served") is not None,
"B to see the same `served` event",
wait_s=30.0,
)
served_a = a.view.event("served")
served_b = b.view.event("served")
assert served_a is not None and served_b is not None
for field in ("oi", "pts", "q", "stk"):
assert served_a[field] == served_b[field], (
f"the `served` events disagree on {field}: "
f"{served_a} vs {served_b}"
)
assert served_a["pts"] >= room.sim_state.target_score, (
f"the first serve ({served_a['pts']} pts) must cross the "
f"target ({room.sim_state.target_score}) — the round is "
"designed to win on this substep"
)
# --- the round ends; both see the same score and payload
round_a = await a.expect("round", wait_s=30.0)
round_b = await b.expect("round", wait_s=30.0)
assert round_a["result"] == "won"
assert round_a["score"] >= round_a["target"] >= 20
assert round_b["result"] == "won"
# The same final score (the `round` frame carries it).
assert round_a["score"] == round_b["score"]
# JSON equality (the "byte-identical" under the canonical dump:
# the only viewer-specific field is `unlocked`, `None` for
# both — the win is on the campaign's single level).
assert round_a["unlocked"] is None
assert round_b["unlocked"] is None
assert round_a == round_b
assert json.dumps(round_a, sort_keys=True) == json.dumps(
round_b, sort_keys=True
)
# The per-player contribution is there (two seats — A the
# soup's cook, B the wanderer).
assert len(round_a["players"]) == 2
assert {p["id"] for p in round_a["players"]} == {"tok-a", "tok-b"}
assert round_a["counters"]["served"] == 1
# --- the lobby return: `to_lobby`, seats preserved
a.send_model({"type": "to_lobby"})
# The server-side truth first: the transition happens in the
# dispatch, the re-broadcast right after — the frame may trail
# the state by a read pass.
await _wait_for(
lambda: room.state.value == "lobby",
"the room to take the to_lobby transition",
wait_s=15.0,
)
lobby_a = await a.expect("lobby", wait_s=15.0)
lobby_b = await b.expect("lobby", wait_s=15.0)
for name, lobby in (("a", lobby_a), ("b", lobby_b)):
_assert_lobby_return(name, lobby)
assert room.state.value == "lobby"
assert {seat.token for seat in room.seats} == {"tok-a", "tok-b"}
finally:
keepalive.cancel()
with contextlib.suppress(asyncio.CancelledError):
await keepalive
for client in (a, b):
await client.close()
async def test_a_third_connection_mid_round_is_a_spectator(
live_server: Any, ws_client: Any
) -> None:
with live_server(levels_dir=str(ROUND_LEVELS_DIR)) as (url, app):
code, room, a, b = await _start_playing(url, app, ws_client)
seq_a, seq_b = _Sequencer(), _Sequencer()
await a.collect("countdown", 3, wait_s=20.0)
await b.collect("countdown", 3, wait_s=20.0)
# --- C arrives mid-round (PLAYING — past the LOBBY join rule)
c = await ws_client(url, code, "tok-c", name="C")
welcome = await c.expect("welcome")
assert welcome["state"] == "playing"
assert welcome["you"]["host"] is False
assert welcome["you"]["seat"] is not None # the spectator index
assert len(welcome["seats"]) == 2 # the seats, unchanged
room = app.state.supervisor.get(code)
assert room is not None
assert room.spectator_for_token("tok-c") is not None
# C receives snapshots (the spectator pass: half the player rate)
snap = await c.expect("snap", wait_s=20.0)
assert snap["raw"]["t"] >= 0
# C's input is the documented refusal (a spectator holds no seat)
c.send_model(
{
"type": "input",
"seq": 1,
"move": {"x": 1.0, "y": 0.0},
"act": False,
"face": None,
}
)
err = await c.expect("err", wait_s=10.0)
assert err["code"] == "not_seated"
assert room.stats()["inputs_received"] == 0 # nothing reached the sim
# A and B keep driving inputs while C watches (the task's "both
# drive" — and the keep-alive, so the watchdog stays quiet).
for _ in range(10):
a.send_model(_input(seq_a(), (0.0, -1.0), False))
b.send_model(_input(seq_b(), (0.0, 1.0), False))
await asyncio.sleep(0.1)
await c.close()
await a.close()
await b.close()
+1
View File
@@ -210,6 +210,7 @@ def test_the_docs_on_disk_are_the_shipped_documents() -> None:
"docs/DESIGN.md",
"docs/DEPLOYMENT.md",
"docs/LEVELS.md",
"docs/PROTOCOL.md",
"docs/ROOMS.md",
"docs/SIMULATION.md",
"docs/TESTING.md",
+434
View File
@@ -0,0 +1,434 @@
"""Unit: the snapshot encoder keeps the sim's internals off the wire
(phase 06, task 04) — the recursive no-leak guard, the 2-decimal
quantization, the explicit event-payload map, the delta tombstones, and
`snapshot_size`'s UTF-8 accounting.
The no-leak rule (task 04): a frame contains no `token`, no
`display_name`, no `seed`, no station internal `id`, no `rng_state` —
checked recursively, on *values* as well as *keys* (a station's
internal id, `stove_1`, is a value, not a key). The sim's internal
identity is projected out by design: a station's wire identity is its
kind + coordinates (`k` + `x`/`y`), and an event's payload goes through
the explicit `EVENT_PAYLOAD_SHORT` map — a key that is not listed there
never leaves the server.
"""
from __future__ import annotations
import json
from collections.abc import Iterator
from dataclasses import replace
from typing import Any
import pytest
from undercooked.server.encoding import (
EVENT_PAYLOAD_SHORT,
encode_events,
encode_snapshot,
event_wire_dict,
snapshot_size,
)
from undercooked.sim.grid import Grid
from undercooked.sim.model import (
Event,
EventKind,
Facing,
Hazard,
HazardKind,
Item,
ItemForm,
ItemKind,
KitchenState,
Order,
Player,
PlayerState,
Station,
StationKind,
StationState,
TileKind,
Vec2,
)
pytestmark = pytest.mark.unit
#: The forbidden *key names* — the sim's internal fields, never on the
#: wire (task 04's no-leak list; `station` is the internal-id carrier
#: the explicit payload map exists to drop).
FORBIDDEN_KEYS: frozenset[str] = frozenset(
{"token", "display_name", "seed", "rng_state", "station"}
)
#: The station/hazard internal ids (values — they must not appear
#: anywhere in a frame).
STOVE_ID = "stove_1"
CHOP_ID = "chop_board_1"
HARD_ID = "hz-fire-1"
A_DISPLAY_NAME = "A very unique cook name"
A_SEED = 0x0BADF00D
A_RNG_STATE = 0xCAFEBABE
A_PLAYER_ID = "tok-abcdef0123456789" # the token value (allowed as `i`)
def make_state() -> KitchenState:
"""A hand-built state with every internal field set to a unique
value: two stations (internal ids, contents, progress), one player
(token id, display name, unquantized position, a carried item), one
order, one hazard, a big `seed` and `rng_state`."""
return KitchenState(
level_id="01_first_kitchen",
seed=A_SEED,
grid=Grid(width=8, height=8, tiles=(TileKind.FLOOR,) * 64),
tick=4821,
stations=(
Station(
id=STOVE_ID,
kind=StationKind.STOVE,
x=3.23456,
y=2.0,
contents=(Item(ItemKind.ONION, ItemForm.RAW, quality=0.98765),),
state=StationState.WORKING,
progress=0.4242,
),
Station(
id=CHOP_ID,
kind=StationKind.CHOP_BOARD,
x=5.0,
y=7.0,
state=StationState.DIRTY,
dirty=True,
),
),
players=(
Player(
id=A_PLAYER_ID,
pos=Vec2(1.23456, -2.34567),
vel=Vec2(0.5, -0.5),
facing=Facing.RIGHT,
carrying=Item(ItemKind.TOMATO, ItemForm.CHOPPED, quality=0.98765),
state=PlayerState.MOVING,
seat=1,
display_name=A_DISPLAY_NAME,
),
),
orders=(
Order(id=7, recipe_id="soup", time_left=30.0, deadline=1200, value=40),
),
order_deadline=1200,
hazards=(
Hazard(
id=HARD_ID,
kind=HazardKind.FIRE,
pos=Vec2(7.654321, 5.2),
life=12.346,
),
),
score=120,
target_score=140,
time_left=83.654,
rng_state=A_RNG_STATE,
)
def walk(node: Any) -> Iterator[tuple[Any, Any]]:
"""Every `(key, value)` pair in the JSON tree (keys only on objects)."""
if isinstance(node, dict):
for key, value in node.items():
yield key, value
yield from walk(value)
elif isinstance(node, list):
for item in node:
yield from walk(item)
def assert_no_leak(frame: dict[str, Any]) -> None:
"""The recursive no-leak check: no forbidden key, and no internal
value (a station/hazard id, the display name, the seed, the
rng_state) anywhere in the tree."""
values: list[Any] = []
for key, value in walk(frame):
assert key not in FORBIDDEN_KEYS, f"forbidden key on the wire: {key!r}"
values.append(value)
for forbidden in (STOVE_ID, CHOP_ID, HARD_ID, A_DISPLAY_NAME):
assert forbidden not in values, f"internal value on the wire: {forbidden!r}"
assert A_SEED not in values, "the round seed leaked onto the wire"
assert A_RNG_STATE not in values, "the rng_state leaked onto the wire"
# ------------------------------------------------------------- the no-leak guard
def test_no_forbidden_key_or_internal_value_reaches_the_wire() -> None:
assert_no_leak(encode_snapshot(make_state()))
def test_the_delta_frame_is_no_leakier_than_the_full() -> None:
state = make_state()
full = encode_snapshot(state)
moved = state.with_players(
(replace(state.players[0], pos=Vec2(4.321, 2.765), state=PlayerState.IDLE),)
)
assert_no_leak(encode_snapshot(moved, full))
def test_the_event_entries_carry_only_the_mapped_keys() -> None:
"""An event whose payload carries the internal keys (`station`,
`tick`, an unknown one) is projected through the explicit map — the
unlisted keys (and the internal id they carry) never reach the
wire."""
events = [
Event(
kind=EventKind.RESYNC,
payload={"player_id": A_PLAYER_ID, "seq": 41},
),
Event(
kind=EventKind.DENIED,
x=3.23456,
y=2.0,
payload={
"player_id": A_PLAYER_ID,
"reason": "contested",
"station": STOVE_ID, # the internal id: must not survive
"tick": 4821, # the frame's `t` is the tick: dropped
"mystery": "no-such-mapping",
},
),
]
encoded = encode_events(events)
assert len(encoded) == 2
allowed = {"k", "x", "y", *EVENT_PAYLOAD_SHORT.values()}
for entry in encoded:
for key, _ in walk(entry):
assert key in allowed, f"an unlisted event key reached the wire: {key!r}"
(denied,) = [entry for entry in encoded if entry["k"] == "denied"]
assert denied == {"k": "denied", "x": 3.23, "y": 2.0, "i": A_PLAYER_ID, "r": "contested"}
assert STOVE_ID not in [value for _, value in walk(encoded)]
def test_the_event_wire_dict_drops_unlisted_keys_the_same_way() -> None:
event = Event(
kind=EventKind.BURNT,
x=3.23456,
y=2.0,
payload={
"station": STOVE_ID,
"item": "onion",
"form": "burnt",
"tick": 4821,
"mystery": 1,
},
)
wire = event_wire_dict(event)
assert wire["kind"] == "burnt"
assert wire["x"] == 3.23
assert wire["payload"] == {"it": "onion", "fm": "burnt"}
# ----------------------------------------------------------- quantization
def test_quantization_is_to_two_decimals() -> None:
frame = encode_snapshot(make_state())
(player,) = frame["p"]
(station,) = (
entry for entry in frame["st"] if entry["k"] == "chop_board"
)
(stove,) = (entry for entry in frame["st"] if entry["k"] == "stove")
(order,) = frame["o"]
(hazard,) = frame["hz"]
# The player's position (Vec2.quantized(2)).
assert (player["x"], player["y"]) == (1.23, -2.35)
# The station's position, progress and the content's quality.
assert (stove["x"], stove["y"]) == (3.23, 2.0)
assert stove["p"] == 0.42
assert stove["q"] == 0.99 # round(0.98765, 2)
# The order's deadline share (30 s of a 60 s window) and the clock.
assert order["l"] == 0.5
assert frame["tl"] == 83.65 # round(83.654, 2)
# The hazard's position and life (seconds, 2 decimals).
assert (hazard["x"], hazard["y"]) == (7.65, 5.2)
assert hazard["l"] == 12.35
# The carried item's short form (the `h` field) and the station's
# first content (`g`).
assert player["h"] == "tomato_chopped"
assert stove["g"] == "onion_raw"
assert station["g"] is None
assert station["q"] == 1.0 # an empty station's quality
def test_facing_is_an_index_of_zero_to_three() -> None:
frame = encode_snapshot(make_state())
(player,) = frame["p"]
assert player["f"] == 1 # Facing.RIGHT
for entry in frame["p"]:
assert entry["f"] in (0, 1, 2, 3)
def test_snapshot_size_is_the_utf8_length_of_the_frame_json() -> None:
frame = encode_snapshot(make_state())
assert snapshot_size(frame) == len(json.dumps(frame).encode("utf-8"))
# ---------------------------------------------------------------- the delta
def test_removed_orders_and_hazards_are_tombstoned() -> None:
state = make_state()
full = encode_snapshot(state)
without = state.with_orders(()).with_hazards(())
delta = encode_snapshot(without, full)
assert delta["full"] is False
(order_tombstone,) = [
entry for entry in delta["d"]["o"] if entry.get("gone") is True
]
assert order_tombstone == {"id": 7, "gone": True}
(hazard_tombstone,) = [
entry for entry in delta["d"]["hz"] if entry.get("gone") is True
]
assert hazard_tombstone == {"t": "fire", "x": 7.65, "y": 5.2, "gone": True}
def test_removed_players_and_stations_are_tombstoned_too() -> None:
"""Every collection tombstones a vanished entity: a player (a cook
that left the round) and a station (a fixture that is gone) carry
their identity keys under `gone` so the client can drop them."""
state = make_state()
full = encode_snapshot(state)
without = state.with_players(()).with_stations(())
delta = encode_snapshot(without, full)
(player_tombstone,) = [
entry for entry in delta["d"]["p"] if entry.get("gone") is True
]
assert player_tombstone == {"i": A_PLAYER_ID, "gone": True}
station_tombstones = [
entry for entry in delta["d"]["st"] if entry.get("gone") is True
]
assert len(station_tombstones) == 2
assert {
(entry["k"], entry["x"], entry["y"])
for entry in station_tombstones
} == {("stove", 3.23, 2.0), ("chop_board", 5.0, 7.0)}
def test_a_malformed_prev_degrades_to_an_empty_prev() -> None:
"""A `prev` whose entity collection is not a list (a client bug in
the stream, not a sim state) degrades to an empty previous: every
current entity is in, and no tombstone is invented."""
state = make_state()
delta = encode_snapshot(state, prev={"p": {}, "st": "nope"})
expected_player = {
"i": A_PLAYER_ID,
"x": 1.23,
"y": -2.35,
"f": 1,
"h": "tomato_chopped",
"s": "moving",
"pi": 1,
}
assert delta["d"]["p"] == [expected_player]
assert len(delta["d"]["st"]) == 2
assert not any(entry.get("gone") for entry in delta["d"]["st"])
def test_a_non_finite_or_null_payload_value_never_reaches_the_wire() -> None:
"""`_short_value`'s guard: a `None` (and a `NaN`/`inf`) payload
value is dropped — a null or a `NaN` in a JSON frame is noise, not
information; a finite float is quantized to 2 decimals."""
event = Event(
kind=EventKind.SERVED,
payload={
"order_id": 7,
"points": float("nan"),
"quality": None,
"streak": float("inf"),
"delta": 12.346,
},
)
(entry,) = encode_events([event])
assert entry == {"k": "served", "x": 0.0, "y": 0.0, "oi": 7, "d": 12.35}
(wire,) = [event_wire_dict(event)]
assert wire["payload"] == {"oi": 7, "d": 12.35}
def test_an_unchanged_entity_is_out_of_the_delta() -> None:
"""The diff is against the *previous* frame (not the last full): an
entity unchanged since `prev` is out, and one that moved *back* to
its original values is in (the client holds the previous values —
the move back is a change it must hear about). Decoding the chain
(full → delta1) must land on `moved`'s full frame."""
state = make_state()
full = encode_snapshot(state)
moved = state.with_players(
(replace(state.players[0], pos=Vec2(4.321, 2.765)),)
)
delta1 = encode_snapshot(moved, full)
assert delta1["d"]["p"][0]["x"] == 4.32 # the move is in
# Unchanged since the previous frame: out.
delta2 = encode_snapshot(moved, delta1)
assert delta2["d"]["p"] == []
# Back at the original values: in (a change since the previous frame).
delta3 = encode_snapshot(state, delta2)
(player,) = delta3["d"]["p"]
assert player["x"] == 1.23 and player["y"] == -2.35
# And the chain decodes: applying delta1 to the full lands on
# `moved`'s full frame.
assert _apply_delta(full, delta1) == encode_snapshot(moved)
def _apply_delta(base: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]:
"""The client's merge: upsert by identity key (preserving the base's
order, new entities appended — the encoder's own semantics), drop
the `gone` tombstones, take the scalars and `e` from the delta."""
# The scalars (`t`, `s`, `tgt`, `tl`) and `e` ride the delta; the
# entity arrays merge onto the base.
decoded = {
key: value
for key, value in delta.items()
if key not in ("d", "full")
}
identity: dict[str, Any] = {
"p": lambda e: (e["i"],),
"st": lambda e: (e["k"], e["x"], e["y"]),
"o": lambda e: (e["id"],),
"hz": lambda e: (e["t"], e["x"], e["y"]),
}
for key in ("p", "st", "o", "hz"):
base_entries: list[dict[str, Any]] = base.get(key, [])
delta_entries: list[dict[str, Any]] = delta["d"][key]
by_identity = {identity[key](entry): dict(entry) for entry in delta_entries}
merged: list[dict[str, Any]] = []
seen: set[tuple[Any, ...]] = set()
for entry in base_entries:
key_ = identity[key](entry)
if by_identity.get(key_, {}).get("gone") is True:
continue # tombstoned
merged.append(by_identity.get(key_, dict(entry)))
seen.add(key_)
for entry in delta_entries:
key_ = identity[key](entry)
if key_ not in seen and entry.get("gone") is not True:
merged.append(dict(entry))
decoded[key] = merged
decoded["full"] = True # a decoded chain is a full state
return decoded
def test_the_delta_against_a_delta_prev_reads_the_prev_from_d() -> None:
"""A delta frame's entities live under `d` — the next delta must
diff against *that* (a prev that is itself a delta)."""
state = make_state()
full = encode_snapshot(state)
moved = state.with_players(
(replace(state.players[0], pos=Vec2(4.321, 2.765)),)
)
delta1 = encode_snapshot(moved, full)
moved_again = state.with_players(
(replace(state.players[0], pos=Vec2(4.5, 2.765)),)
)
delta2 = encode_snapshot(moved_again, delta1)
(player,) = delta2["d"]["p"]
assert player["x"] == 4.5 # the change since delta1, not since the full
# `y` is unchanged since delta1, but the whole entry is in (it is a
# whole-entry diff): the entry carries both values.
assert player["y"] == 2.77
+303
View File
@@ -0,0 +1,303 @@
"""`sim/ghost.py` — the ghost's state and hands (phase 06 task 05, US-09).
The wall-clock grace is the room's (measured off `seat.last_seen`); this file
pins the sim's half, pure and deterministic:
* **`mark_ghost`** — the sim-side half of a drop: `connected=False`, the cook
in `DISCONNECTED`; idempotent (a second call changes nothing);
* **`restore_player`** — the sim-side half of the rejoin inside the grace:
`connected=True`, the cook in `IDLE`; a still-carried item is kept;
idempotent;
* **`release_ghost_carry`** — the park's release: the carried item goes onto
the station on the cook's tile (a marked set-down, one `DROPPED`) or into
`counters["lost_items"]` (one `GHOST_PARK`) on a bare tile; an item is never
deleted silently; idempotent (a double call cannot double-release); a
not-yet-marked (connected) cook is switched first — the forced release on
a `SeatAbandoned` when the grace ran out before the park deadline;
* **the ghost parks in the movement stage** — a disconnected cook's (stale)
input does not move it, and a stun that runs out on a disconnected cook
revives it in `DISCONNECTED`, not `IDLE`;
* **the events encode** — `ghost_on`/`ghost_off`/`ghost_park` ride the `k`
names with the short payload keys (`i`/`it`/`fm`), and the unlisted keys
never leave the server.
"""
from __future__ import annotations
import pytest
from undercooked.server.encoding import encode_events
from undercooked.sim.carry import first_loose_entry
from undercooked.sim.ghost import (
PARK_AFTER_S,
mark_ghost,
release_ghost_carry,
restore_player,
station_at,
)
from undercooked.sim.grid import Grid
from undercooked.sim.model import (
Event,
EventKind,
Facing,
Item,
ItemForm,
ItemKind,
KitchenState,
Player,
PlayerState,
Station,
StationKind,
TileKind,
Vec2,
)
from undercooked.sim.movement import apply_stun, resolve_moves
from undercooked.sim.step import PlayerInput
pytestmark = pytest.mark.unit
WIDTH, HEIGHT = 7, 5
#: The cook's cell — the station test's fixture sits on it.
CELL_X, CELL_Y = 3, 2
def make_state(
stations: tuple[Station, ...] = (),
players: tuple[Player, ...] = (),
) -> KitchenState:
return KitchenState(
level_id="03_ghost",
seed=7,
grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)),
stations=stations,
players=players,
target_score=40,
time_left=90.0,
)
def cook(
player_id: str = "p0",
*,
pos: Vec2 | None = None,
carrying: Item | None = None,
connected: bool = True,
state: PlayerState = PlayerState.IDLE,
stun: float = 0.0,
seat: int = 0,
) -> Player:
return Player(
id=player_id,
pos=pos if pos is not None else Vec2(CELL_X + 0.5, CELL_Y + 0.5),
facing=Facing.DOWN,
carrying=carrying,
connected=connected,
state=state,
stun=stun,
seat=seat,
)
def tomato(form: ItemForm = ItemForm.RAW) -> Item:
return Item(kind=ItemKind.TOMATO, form=form)
def fixture(station_id: str, kind: StationKind = StationKind.PREP) -> Station:
return Station(id=station_id, kind=kind, x=float(CELL_X), y=float(CELL_Y))
# --- the constant ---------------------------------------------------------------
def test_the_park_deadline_is_three_seconds() -> None:
assert PARK_AFTER_S == 3.0
# --- mark / restore -------------------------------------------------------------
def test_mark_ghost_switches_the_cook_and_is_idempotent() -> None:
state = make_state(players=(cook(),))
once = mark_ghost(state, state.players[0])
assert once.players[0].connected is False
assert once.players[0].state is PlayerState.DISCONNECTED
# Idempotent: a second call changes nothing (the event is the room's,
# so the sim must not double-apply the switch either).
twice = mark_ghost(once, once.players[0])
assert twice is once
assert twice.players[0].connected is False
def test_mark_ghost_keeps_the_carry() -> None:
state = make_state(players=(cook(carrying=tomato()),))
marked = mark_ghost(state, state.players[0])
assert marked.players[0].carrying is not None # the item waits for the park
assert marked.players[0].state is PlayerState.DISCONNECTED
def test_restore_player_brings_the_cook_back_and_keeps_the_carry() -> None:
ghost = mark_ghost(make_state(players=(cook(carrying=tomato()),)), cook(carrying=tomato()))
player = ghost.players[0]
restored = restore_player(ghost, player)
assert restored.players[0].connected is True
assert restored.players[0].state is PlayerState.IDLE
assert restored.players[0].carrying is not None # the rejoin keeps the carry
# Idempotent: a second restore changes nothing.
again = restore_player(restored, restored.players[0])
assert again is restored
# --- the release (the park / the forced release) --------------------------------
def test_station_at_finds_the_station_on_the_tile_and_none_on_bare_floor() -> None:
shelf = fixture("prep_0", StationKind.PREP)
state = make_state(stations=(shelf,), players=(cook(),))
assert station_at(state, state.players[0].pos) is shelf
bare = make_state(players=(cook(pos=Vec2(1.5, 1.5)),))
assert station_at(bare, Vec2(1.5, 1.5)) is None
def test_release_parks_onto_the_station_with_a_marked_set_down() -> None:
shelf = fixture("prep_0", StationKind.PREP)
state = make_state(stations=(shelf,), players=(cook(carrying=tomato()),))
state = mark_ghost(state, state.players[0])
released, events = release_ghost_carry(state, state.players[0])
assert released.players[0].carrying is None
station = released.stations[0]
assert station.contents == (tomato(),)
# A marked set-down: grabbable at once (the hand-off window applies — a
# released item can be passed).
entry = first_loose_entry(released, station)
assert entry is not None and entry[0] == tomato()
assert [e.kind for e in events] == [EventKind.DROPPED]
def test_release_parks_into_lost_items_on_a_bare_tile() -> None:
state = make_state(players=(cook(pos=Vec2(1.5, 1.5), carrying=tomato(ItemForm.CHOPPED)),))
state = mark_ghost(state, state.players[0])
released, events = release_ghost_carry(state, state.players[0])
assert released.players[0].carrying is None
assert released.counters["lost_items"] == 1
assert len(events) == 1
event = events[0]
assert event.kind is EventKind.GHOST_PARK
assert event.payload == {
"player_id": "p0",
"item": "tomato",
"form": "chopped",
}
def test_release_is_idempotent_a_double_call_cannot_double_release() -> None:
shelf = fixture("prep_0", StationKind.PREP)
state = make_state(stations=(shelf,), players=(cook(carrying=tomato()),))
state = mark_ghost(state, state.players[0])
released, first = release_ghost_carry(state, state.players[0])
assert len(first) == 1
# Second call: the hand is empty, nothing to release — no events, no
# counter bump, the set-down untouched.
again, second = release_ghost_carry(released, released.players[0])
assert second == ()
assert released.counters.get("lost_items", 0) == 0
assert again.stations[0].contents == (tomato(),)
# Same for the counter path: the tombstone is counted exactly once.
bare = make_state(players=(cook(pos=Vec2(1.5, 1.5), carrying=tomato()),))
bare = mark_ghost(bare, bare.players[0])
parked, _ = release_ghost_carry(bare, bare.players[0])
again_bare, second_bare = release_ghost_carry(parked, parked.players[0])
assert second_bare == ()
assert again_bare.counters["lost_items"] == 1
def test_release_of_a_connected_cook_switches_and_releases() -> None:
"""The forced release on a `SeatAbandoned`: the seat is emptied before
the 3 s park deadline, and the item must release at once (the sim
switches the ghost first, then releases)."""
shelf = fixture("prep_0", StationKind.PREP)
state = make_state(stations=(shelf,), players=(cook(carrying=tomato()),))
released, events = release_ghost_carry(state, state.players[0])
assert released.players[0].connected is False
assert released.players[0].state is PlayerState.DISCONNECTED
assert released.players[0].carrying is None
assert released.stations[0].contents == (tomato(),)
assert [e.kind for e in events] == [EventKind.DROPPED]
def test_release_of_an_empty_hand_is_a_silent_noop() -> None:
state = make_state(players=(cook(pos=Vec2(1.5, 1.5)),))
state = mark_ghost(state, state.players[0])
released, events = release_ghost_carry(state, state.players[0])
assert events == ()
assert "lost_items" not in released.counters
# --- the ghost parks in the movement stage ---------------------------------------
def test_a_stun_that_runs_out_on_a_disconnected_cook_revives_it_disconnected() -> None:
players, _ = apply_stun((cook(connected=False, state=PlayerState.STUNNED, stun=0.05),), 0.05)
assert players[0].state is PlayerState.DISCONNECTED
assert players[0].connected is False
# A stun still running keeps the cook pinned (and stunned).
running, _ = apply_stun((cook(connected=False, state=PlayerState.STUNNED, stun=0.3),), 0.05)
assert running[0].state is PlayerState.STUNNED
assert running[0].stun == pytest.approx(0.25)
# A stun-zero STUNNED cook revives to `DISCONNECTED` as well, not `IDLE`.
zero, _ = apply_stun((cook(connected=False, state=PlayerState.STUNNED, stun=0.0),), 0.05)
assert zero[0].state is PlayerState.DISCONNECTED
# A connected cook still revives to `IDLE`.
alive, _ = apply_stun((cook(state=PlayerState.STUNNED, stun=0.05),), 0.05)
assert alive[0].state is PlayerState.IDLE
def test_a_disconnected_cook_parks_stale_input_does_not_move_it() -> None:
state = make_state(players=(cook(connected=False, state=PlayerState.DISCONNECTED),))
before = state.players[0].pos
after, events = resolve_moves(
state, 0.05, {"p0": PlayerInput(player_id="p0", move=Vec2(1.0, 0.0))}
)
assert after[0].pos == before # the ghost does not walk
assert after[0].vel.length == 0.0
assert after[0].state is PlayerState.DISCONNECTED
assert events == ()
# A connected cook on the same frame still moves.
state2 = make_state(players=(cook(state=PlayerState.IDLE),))
after2, _ = resolve_moves(
state2, 0.05, {"p0": PlayerInput(player_id="p0", move=Vec2(1.0, 0.0))}
)
assert after2[0].pos.x > state2.players[0].pos.x
assert after2[0].state is PlayerState.MOVING
# --- the events encode -----------------------------------------------------------
def test_the_ghost_events_encode_with_their_k_names_and_short_payloads() -> None:
encoded = encode_events(
(
Event(
kind=EventKind.GHOST_ON,
x=3.5,
y=2.5,
payload={"player_id": "p0"},
),
Event(kind=EventKind.GHOST_OFF, x=3.5, y=2.5, payload={"player_id": "p0"}),
Event(
kind=EventKind.GHOST_PARK,
x=1.5,
y=1.5,
payload={"player_id": "p0", "item": "tomato", "form": "chopped"},
),
)
)
assert [entry["k"] for entry in encoded] == ["ghost_on", "ghost_off", "ghost_park"]
assert encoded[0] == {"k": "ghost_on", "x": 3.5, "y": 2.5, "i": "p0"}
assert encoded[2] == {
"k": "ghost_park",
"x": 1.5,
"y": 1.5,
"i": "p0",
"it": "tomato",
"fm": "chopped",
}
+247
View File
@@ -0,0 +1,247 @@
"""Unit: `docs/PROTOCOL.md` and `protocol.py` cannot drift (phase 06, task 01).
The phase's completion criteria demand that "every field in code appears in
the doc" — and, in the other direction, that the doc never names a field
that does not exist. This test is the guard: for **every** message model
in both directions (the nine client→server, the eight server→client, and
the six wire objects they nest) it extracts the field names from the doc's
``### ModelName`` table and compares them with the model's ``model_fields``
— set-equal, in both directions. It also keeps the doc's error-code and
close-code tables equal to the `ErrorCode` / `CloseCode` constants.
Doc convention (what this test relies on): every ``###`` heading in
``docs/PROTOCOL.md`` is exactly one model section (no other ``###``
headings — use ``##`` for prose sections), and each section holds one
markdown table whose first column is the field name. So: an added field
that is not documented fails here, a documented field that is not in the
model fails here, a renamed model fails here, and a new error code without
a doc row fails here.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
from pydantic import BaseModel
from undercooked.server.protocol import (
CloseCode,
CountdownMsg,
EmoteMsg,
ErrMsg,
ErrorCode,
EventWire,
EvMsg,
HelloMsg,
InputMsg,
LobbyMsg,
NextLevelMsg,
PingMsg,
PlayerScoreWire,
PongMsg,
ReadyMsg,
RoundMsg,
SeatWire,
SetLevelMsg,
SnapMsg,
SpectatorWire,
StartMsg,
ToLobbyMsg,
Vec2Msg,
WelcomeMsg,
YouWire,
)
pytestmark = pytest.mark.unit
DOCS = Path(__file__).resolve().parents[2] / "docs" / "PROTOCOL.md"
#: Every message model in both directions, plus the wire objects they nest.
C_TO_S_MODELS: dict[str, type[BaseModel]] = {
"HelloMsg": HelloMsg,
"ReadyMsg": ReadyMsg,
"SetLevelMsg": SetLevelMsg,
"StartMsg": StartMsg,
"InputMsg": InputMsg,
"EmoteMsg": EmoteMsg,
"ToLobbyMsg": ToLobbyMsg,
"NextLevelMsg": NextLevelMsg,
"PingMsg": PingMsg,
}
S_TO_C_MODELS: dict[str, type[BaseModel]] = {
"WelcomeMsg": WelcomeMsg,
"LobbyMsg": LobbyMsg,
"CountdownMsg": CountdownMsg,
"SnapMsg": SnapMsg,
"RoundMsg": RoundMsg,
"EvMsg": EvMsg,
"ErrMsg": ErrMsg,
"PongMsg": PongMsg,
}
WIRE_MODELS: dict[str, type[BaseModel]] = {
"Vec2Msg": Vec2Msg,
"YouWire": YouWire,
"SeatWire": SeatWire,
"SpectatorWire": SpectatorWire,
"EventWire": EventWire,
"PlayerScoreWire": PlayerScoreWire,
}
ALL_MODELS: dict[str, type[BaseModel]] = {
**C_TO_S_MODELS,
**S_TO_C_MODELS,
**WIRE_MODELS,
}
SECTION_HEADING = re.compile(r"^###\s+(\S+)\s*$")
def _clean(cell: str) -> str:
return cell.strip().strip("`").strip()
def extract_model_sections(text: str) -> dict[str, list[str]]:
"""Field names (first table column) per ``###`` section.
A section runs from its ``###`` heading to the next heading of any
level; inside it, only table rows count, and the header row (first cell
``Field``) and the separator row (dashes/colons) are skipped.
"""
sections: dict[str, list[str]] = {}
current: str | None = None
for line in text.splitlines():
heading = SECTION_HEADING.match(line)
if heading is not None:
name = heading.group(1)
sections.setdefault(name, [])
current = name
continue
if line.lstrip().startswith("#"):
current = None # any other heading ends the section
continue
if current is None:
continue
stripped = line.strip()
if not stripped.startswith("|"):
continue
cells = [_clean(cell) for cell in stripped.strip("|").split("|")]
if not cells or cells[0] in {"Field", ""}:
continue
if set(cells[0]) <= {"-", ":"}:
continue
sections[current].append(cells[0])
return sections
def extract_section_first_cells(text: str, heading: str) -> list[str]:
"""First table column of one ``##`` section (the code tables)."""
lines = text.splitlines()
try:
start = next(i for i, line in enumerate(lines) if line.strip() == heading)
except StopIteration:
pytest.fail(f"{heading!r} section not found in docs/PROTOCOL.md")
cells: list[str] = []
for line in lines[start + 1 :]:
if line.lstrip().startswith("#"):
break
stripped = line.strip()
if not stripped.startswith("|"):
continue
first = _clean(stripped.strip("|").split("|")[0])
if first in {"Code", ""} or set(first) <= {"-", ":"}:
continue
cells.append(first)
return cells
def test_the_doc_exists() -> None:
assert DOCS.is_file(), "docs/PROTOCOL.md is the phase deliverable — it must exist"
def test_documented_sections_are_exactly_the_model_registry() -> None:
sections = extract_model_sections(DOCS.read_text(encoding="utf-8"))
missing = set(ALL_MODELS) - set(sections)
extra = set(sections) - set(ALL_MODELS)
assert not missing, f"models with no ### section in the doc: {sorted(missing)}"
assert not extra, (
f"### sections that are not model sections (doc convention): {sorted(extra)}"
)
@pytest.mark.parametrize("name", sorted(ALL_MODELS))
def test_the_doc_fields_match_the_model_fields_in_both_directions(name: str) -> None:
model = ALL_MODELS[name]
sections = extract_model_sections(DOCS.read_text(encoding="utf-8"))
assert name in sections, f"no ### {name} section in docs/PROTOCOL.md"
documented = set(sections[name])
implemented = set(model.model_fields)
undocumented = implemented - documented
ghost = documented - implemented
assert not undocumented, (
f"{name}: fields in the model but missing from the doc: {sorted(undocumented)}"
)
assert not ghost, (
f"{name}: fields documented but not in the model: {sorted(ghost)}"
)
def test_both_directions_are_covered_by_the_registry() -> None:
assert len(C_TO_S_MODELS) == 9
assert len(S_TO_C_MODELS) == 8
assert len(WIRE_MODELS) == 6
assert set(ALL_MODELS) == set(C_TO_S_MODELS) | set(S_TO_C_MODELS) | set(WIRE_MODELS)
def test_the_error_code_table_matches_the_constants() -> None:
cells = extract_section_first_cells(DOCS.read_text(encoding="utf-8"), "## Error codes")
assert len(cells) == len(set(cells)), "duplicate rows in the error-code table"
documented = set(cells)
implemented = {
ErrorCode.UNKNOWN_TYPE,
ErrorCode.BAD_JSON,
ErrorCode.BAD_FIELD,
ErrorCode.TOO_BIG,
ErrorCode.RATE_LIMITED,
ErrorCode.NOT_SEATED,
ErrorCode.NOT_HOST,
ErrorCode.LEVEL_LOCKED,
ErrorCode.WRONG_STATE,
ErrorCode.ROOM_FULL,
ErrorCode.ROOM_GONE,
ErrorCode.PROTOCOL_MISMATCH,
ErrorCode.EVICTED,
ErrorCode.INTERNAL,
}
assert documented == implemented, (
f"error-code table drift: missing {sorted(implemented - documented)}, "
f"ghosts {sorted(documented - implemented)}"
)
def test_the_close_code_table_matches_the_constants() -> None:
cells = extract_section_first_cells(DOCS.read_text(encoding="utf-8"), "## Close codes")
documented = {int(cell) for cell in cells}
implemented = {
CloseCode.NORMAL,
CloseCode.PROTOCOL_MISMATCH,
CloseCode.ROOM_GONE,
CloseCode.ROOM_FULL,
CloseCode.EVICTED,
CloseCode.ABUSE,
}
assert documented == implemented, (
f"close-code table drift: missing {sorted(implemented - documented)}, "
f"ghosts {sorted(documented - implemented)}"
)
def test_the_whole_contract_round_trips_through_the_models() -> None:
"""Every model here is a real Pydantic model with a `type` discriminator
(C→S) or a defaulted one (S→C) — a smoke over the registry itself."""
for name, model in ALL_MODELS.items():
assert isinstance(model, type)
assert issubclass(model, BaseModel)
fields = set(model.model_fields)
if name in C_TO_S_MODELS:
assert "type" in fields, f"{name} has no discriminator"
+380
View File
@@ -0,0 +1,380 @@
"""Unit: the wire contract's accept/reject surface (phase 06, task 01).
`parse_client_message` is the whole *shape* check — JSON decode,
discriminator dispatch, per-model field validation, the `hello` `p` gate —
and it must never raise: every bad frame comes back as a `ProtocolError`
with a stable `code`. This table pins the whole surface: the nine message
types parse (including the boundary `move` magnitudes), and every documented
bad shape yields exactly its documented `code` (`bad_json`, `unknown_type`,
`bad_field`, `protocol_mismatch`).
Also pinned here: the `ErrorCode` / `CloseCode` constant sets (a new code
must be documented before it exists), `ProtocolError.to_err`, and the
server→client models' wire shape (every model encodes with its exact
`model_fields` and the right `type` / `p`).
"""
from __future__ import annotations
import pytest
from pydantic import BaseModel
from undercooked.server.protocol import (
MIN_SUPPORTED_VERSION,
PROTOCOL_VERSION,
ClientMessage,
CloseCode,
CountdownMsg,
EmoteMsg,
ErrMsg,
ErrorCode,
EventWire,
EvMsg,
HelloMsg,
InputMsg,
LobbyMsg,
NextLevelMsg,
PingMsg,
PlayerScoreWire,
PongMsg,
ProtocolError,
ReadyMsg,
RoundMsg,
SeatWire,
SetLevelMsg,
SnapMsg,
SpectatorWire,
StartMsg,
ToLobbyMsg,
Vec2Msg,
WelcomeMsg,
YouWire,
parse_client_message,
wire_dict,
)
pytestmark = pytest.mark.unit
#: A fixture session id (named to keep `S105`/`S106` quiet).
SESSION_ID = "tok-1"
def _error_of(result: ClientMessage | ProtocolError) -> ProtocolError | None:
return result if isinstance(result, ProtocolError) else None
# ------------------------------------------------------------------- accept
ACCEPT_CASES: list[tuple[bytes, type[BaseModel]]] = [
(b'{"type":"hello","p":1}', HelloMsg),
(b'{"type":"hello","p":1,"token":"tok-1234","name":"Ada"}', HelloMsg),
(b'{"type":"ready"}', ReadyMsg),
(b'{"type":"set_level","level":"01_first_kitchen"}', SetLevelMsg),
(b'{"type":"start"}', StartMsg),
(b'{"type":"input","seq":0,"move":{"x":0.0,"y":0.0},"act":false,"face":null}', InputMsg),
(b'{"type":"input","seq":7,"move":{"x":0.6,"y":0.8},"act":true,"face":null}', InputMsg),
(b'{"type":"input","seq":8,"move":{"x":-1.0,"y":0.0},"act":false,"face":"up"}', InputMsg),
(
b'{"type":"input","seq":9,"move":{"x":0.7,"y":0.7},"act":false,'
b'"face":"right"}',
InputMsg,
),
(b'{"type":"emote","id":"wave"}', EmoteMsg),
(b'{"type":"to_lobby"}', ToLobbyMsg),
(b'{"type":"next_level"}', NextLevelMsg),
(b'{"type":"ping","t":0}', PingMsg),
(b'{"type":"ping","t":1234567}', PingMsg),
]
@pytest.mark.parametrize(("raw", "expected"), ACCEPT_CASES)
def test_every_message_type_parses(raw: bytes, expected: type[BaseModel]) -> None:
result = parse_client_message(raw)
assert not isinstance(result, ProtocolError)
assert type(result) is expected
def test_anonymous_hello_parses_and_carries_no_token() -> None:
result = parse_client_message(b'{"type":"hello","p":1}')
assert isinstance(result, HelloMsg)
assert result.token is None
assert result.name is None
def test_a_boundary_move_magnitude_is_accepted() -> None:
# hypot(0.6, 0.8) is 1.0 exactly: the boundary is inclusive.
result = parse_client_message(
b'{"type":"input","seq":1,"move":{"x":0.6,"y":0.8},"act":false,"face":null}'
)
assert isinstance(result, InputMsg)
assert isinstance(result.move, Vec2Msg)
# ------------------------------------------------------------------- reject
REJECT_CASES: list[tuple[bytes, str]] = [
# bad_json — not JSON at all.
(b"{", "bad_json"),
(b"", "bad_json"),
(b"[1, 2]", "bad_field"),
(b'"just a string"', "bad_field"),
(b"123.5", "bad_field"),
# bad_field — valid JSON, wrong shape.
(b"null", "bad_field"),
(b"{}", "bad_field"),
(b'{"type":5}', "bad_field"),
(b'{"type":"hello"}', "bad_field"), # p missing
(b'{"type":"hello","p":"1"}', "bad_field"), # p is a string
(b'{"type":"hello","p":3.5}', "bad_field"), # p is not an int
(b'{"type":"hello","p":1,"token":""}', "bad_field"), # token too short
(b'{"type":"hello","p":1,"token":"' + b"a" * 129 + b'"}', "bad_field"),
(b'{"type":"hello","p":1,"name":"' + b"x" * 65 + b'"}', "bad_field"),
(b'{"type":"input","seq":-1,"move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3.5,"move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":"3","move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":"true","face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":1,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":50,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":1.01,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":1,"y":1},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":"1","y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":NaN,"y":0},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":Infinity},"act":false,"face":null}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,"face":"north"}', "bad_field"),
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false}', "bad_field"), # face missing
(
b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,'
b'"face":null,"extra":1}',
"bad_field",
),
(
b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,'
b'"face":null,"pos":{"x":9}}',
"bad_field",
),
(b'{"type":"ping","t":-1}', "bad_field"),
(b'{"type":"ping","t":"1"}', "bad_field"),
(b'{"type":"emote","id":""}', "bad_field"),
(b'{"type":"set_level","level":""}', "bad_field"),
(b'{"type":"set_level"}', "bad_field"),
# unknown_type — a `type` the server does not know.
(b'{"type":"bogus"}', "unknown_type"),
(b'{"type":"HELLO"}', "unknown_type"), # discriminators are case-sensitive
(b'{"type":"position","x":1}', "unknown_type"),
# protocol_mismatch — `p` outside [MIN_SUPPORTED_VERSION, PROTOCOL_VERSION].
(b'{"type":"hello","p":0}', "protocol_mismatch"),
(b'{"type":"hello","p":2}', "protocol_mismatch"),
(b'{"type":"hello","p":99}', "protocol_mismatch"),
]
@pytest.mark.parametrize(("raw", "expected_code"), REJECT_CASES)
def test_bad_frames_yield_their_documented_code(raw: bytes, expected_code: str) -> None:
result = parse_client_message(raw)
error = _error_of(result)
assert error is not None, f"{raw!r} parsed into a message, expected {expected_code}"
assert error.code == expected_code
def test_parse_never_raises_over_the_whole_table() -> None:
for raw, _ in ACCEPT_CASES + REJECT_CASES:
try:
parse_client_message(raw)
except Exception as exc:
pytest.fail(f"parse_client_message raised {exc!r} for {raw!r}")
def test_parse_of_a_valid_ping_is_not_an_error() -> None:
result = parse_client_message(b'{"type":"ping","t":1}')
assert not isinstance(result, ProtocolError)
def test_the_task_criterion_truncated_frame_is_bad_json() -> None:
error = _error_of(parse_client_message(b"{"))
assert error is not None
assert error.code == "bad_json"
def test_unknown_type_error_names_the_offending_type() -> None:
error = _error_of(parse_client_message(b'{"type":"teleport"}'))
assert error is not None
assert "teleport" in error.detail
# ------------------------------------------------------------- error model
def test_protocol_error_to_err_builds_the_wire_frame() -> None:
error = ProtocolError(code="bad_field", detail="x: input should be a valid number")
err = error.to_err()
assert err.type == "err"
assert err.code == "bad_field"
assert err.msg == "x: input should be a valid number"
assert err.seq is None
with_seq = error.to_err(seq=42)
assert with_seq.seq == 42
def test_protocol_error_without_detail_falls_back_to_the_code() -> None:
err = ProtocolError(code="wrong_state").to_err()
assert err.msg == "wrong_state"
def test_the_error_code_set_is_pinned() -> None:
expected = {
"unknown_type",
"bad_json",
"bad_field",
"too_big",
"rate_limited",
"not_seated",
"not_host",
"level_locked",
"wrong_state",
"room_full",
"room_gone",
"protocol_mismatch",
"evicted",
"internal",
}
codes = {
ErrorCode.UNKNOWN_TYPE,
ErrorCode.BAD_JSON,
ErrorCode.BAD_FIELD,
ErrorCode.TOO_BIG,
ErrorCode.RATE_LIMITED,
ErrorCode.NOT_SEATED,
ErrorCode.NOT_HOST,
ErrorCode.LEVEL_LOCKED,
ErrorCode.WRONG_STATE,
ErrorCode.ROOM_FULL,
ErrorCode.ROOM_GONE,
ErrorCode.PROTOCOL_MISMATCH,
ErrorCode.EVICTED,
ErrorCode.INTERNAL,
}
assert codes == expected
def test_the_close_code_set_is_pinned() -> None:
assert CloseCode.NORMAL == 1000
assert CloseCode.PROTOCOL_MISMATCH == 4400
assert CloseCode.ROOM_GONE == 4401
assert CloseCode.ROOM_FULL == 4402
assert CloseCode.EVICTED == 4403
assert CloseCode.ABUSE == 4404
# ------------------------------------------------------------ server → client
def test_the_protocol_version_constants() -> None:
assert PROTOCOL_VERSION == 1
assert MIN_SUPPORTED_VERSION == 1
def test_welcome_encodes_its_exact_fields() -> None:
seats = [
SeatWire(index=0, name="Ada", color="#e5484d", ready=True, host=True, connected=True)
]
msg = WelcomeMsg(
you=YouWire(token=SESSION_ID, seat=0, host=True),
room="ABCDE",
state="lobby",
level="01_first_kitchen",
seats=seats,
)
dumped = wire_dict(msg)
assert set(dumped) == {
"type",
"p",
"you",
"room",
"state",
"level",
"seats",
"rejoined",
}
assert dumped["type"] == "welcome"
assert dumped["p"] == PROTOCOL_VERSION
assert dumped["you"] == {"token": SESSION_ID, "seat": 0, "host": True}
# A fresh join (the default) is not a rejoin (task 05: `rejoined`
# is `true` only when a `hello` reclaims a disconnected seat).
assert dumped["rejoined"] is False
def test_lobby_encodes_its_exact_fields() -> None:
msg = LobbyMsg(
code="ABCDE",
state="lobby",
level=None,
host_seat=0,
can_start=False,
seats=[],
spectators=[SpectatorWire(name="Mallory", color="#3b9eff")],
unlocked=1,
round_no=0,
)
dumped = wire_dict(msg)
assert set(dumped) == {
"type", "p", "code", "state", "level", "host_seat", "can_start",
"seats", "spectators", "unlocked", "round_no",
}
assert dumped["type"] == "lobby"
def test_countdown_snap_round_ev_pong_encode_their_fields() -> None:
countdown = wire_dict(CountdownMsg(n=3))
assert set(countdown) == {"type", "p", "n"} and countdown["n"] == 3
snap = wire_dict(SnapMsg(raw={"t": 4821, "p": []}))
assert set(snap) == {"type", "p", "raw"}
raw_body = snap["raw"]
assert isinstance(raw_body, dict) and raw_body["t"] == 4821
round_ = wire_dict(
RoundMsg(
result="won",
score=140,
target=140,
stars=3,
time_left=60.0,
counters={"served": 5, "missed": 0, "wasted": 0},
longest_streak=5,
players=[
PlayerScoreWire(
id="tok-1", name="Ada", score=140, dishes_served=5, missed_contrib=0
)
],
level_id="01_first_kitchen",
seed=1234,
ticks=1200,
unlocked="02_double_shift",
)
)
assert set(round_) == {
"type", "p", "result", "score", "target", "stars", "time_left", "counters",
"longest_streak", "players", "level_id", "seed", "ticks", "unlocked",
}
ev = wire_dict(EvMsg(events=[EventWire(kind="served", x=9.0, y=1.0, payload={"i": 17})]))
assert set(ev) == {"type", "p", "events"}
assert ev["events"] == [{"kind": "served", "x": 9.0, "y": 1.0, "payload": {"i": 17}}]
pong = wire_dict(PongMsg(t=42, now=12.5))
assert set(pong) == {"type", "p", "t", "now"} and pong["t"] == 42
def test_err_msg_encodes_with_an_optional_seq() -> None:
dumped = wire_dict(ErrMsg(code="bad_field", msg="no", seq=7))
assert set(dumped) == {"type", "p", "code", "msg", "seq"} and dumped["seq"] == 7
anonymous = wire_dict(ErrMsg(code="wrong_state", msg="no"))
assert anonymous["seq"] is None
def test_event_wire_payload_defaults_to_empty() -> None:
event = EventWire(kind="chopped", x=3.0, y=2.0)
assert event.payload == {}
assert wire_dict(event) == {"kind": "chopped", "x": 3.0, "y": 2.0, "payload": {}}
+94
View File
@@ -0,0 +1,94 @@
"""Unit: `MESSAGE_WHITELIST` — totality and the pinned matrix (phase 06, task 01).
The whitelist is the single source of "what is legal when" (PLAN §7
"Accepted when"), imported by the endpoint and never duplicated. These tests
pin the whole matrix (a silent edit changes the wire contract) and assert
its *totality*: every room state is classified, every classification is a
known client type, and every client type is legal in at least one state —
so a new `ClientMsgType` that is never whitelisted (or a state that is
never classified) fails loudly.
"""
from __future__ import annotations
import pytest
from undercooked.server.protocol import CLIENT_MSG_TYPES, MESSAGE_WHITELIST
from undercooked.server.room import RoomState
pytestmark = pytest.mark.unit
#: The pinned matrix — PLAN §7 "Accepted when", one row per room state.
EXPECTED_MATRIX: dict[RoomState, frozenset[str]] = {
RoomState.LOBBY: frozenset({"hello", "ready", "set_level", "start", "emote", "ping"}),
RoomState.COUNTDOWN: frozenset({"hello", "ping"}),
RoomState.PLAYING: frozenset({"hello", "input", "emote", "ping"}),
RoomState.ROUND_END: frozenset({"hello", "to_lobby", "next_level", "ping"}),
RoomState.CLOSED: frozenset({"hello", "ping"}),
}
def test_the_matrix_is_pinned() -> None:
actual = {state: frozenset(types) for state, types in MESSAGE_WHITELIST.items()}
assert actual == EXPECTED_MATRIX
def test_totality_every_room_state_is_classified() -> None:
assert set(MESSAGE_WHITELIST) == set(RoomState)
def test_totality_every_whitelisted_type_is_known() -> None:
for state, allowed in MESSAGE_WHITELIST.items():
unknown = allowed - CLIENT_MSG_TYPES
assert not unknown, f"{state.value}: unknown types {sorted(unknown)}"
def test_totality_every_client_type_is_legal_somewhere() -> None:
union: set[str] = set()
for allowed in MESSAGE_WHITELIST.values():
union |= set(allowed)
never = CLIENT_MSG_TYPES - union
assert not never, f"client types legal in no state at all: {sorted(never)}"
def test_hello_and_ping_are_legal_in_every_state() -> None:
for state, allowed in MESSAGE_WHITELIST.items():
assert {"hello", "ping"} <= allowed, (
f"{state.value}: hello (the rejoin handshake) and ping (the "
f"keep-alive) must be legal in every state"
)
def test_input_is_playing_only() -> None:
assert "input" in MESSAGE_WHITELIST[RoomState.PLAYING]
for state in (RoomState.LOBBY, RoomState.COUNTDOWN, RoomState.ROUND_END, RoomState.CLOSED):
assert "input" not in MESSAGE_WHITELIST[state]
def test_the_lobby_verbs_are_lobby_only() -> None:
for verb in ("ready", "set_level", "start"):
assert verb in MESSAGE_WHITELIST[RoomState.LOBBY]
for state in (RoomState.COUNTDOWN, RoomState.PLAYING, RoomState.ROUND_END, RoomState.CLOSED):
for verb in ("ready", "set_level", "start"):
assert verb not in MESSAGE_WHITELIST[state]
def test_emote_is_lobby_and_playing_only() -> None:
assert "emote" in MESSAGE_WHITELIST[RoomState.LOBBY]
assert "emote" in MESSAGE_WHITELIST[RoomState.PLAYING]
for state in (RoomState.COUNTDOWN, RoomState.ROUND_END, RoomState.CLOSED):
assert "emote" not in MESSAGE_WHITELIST[state]
def test_the_round_end_verbs_are_round_end_only() -> None:
for verb in ("to_lobby", "next_level"):
assert verb in MESSAGE_WHITELIST[RoomState.ROUND_END]
for state in (RoomState.LOBBY, RoomState.COUNTDOWN, RoomState.PLAYING, RoomState.CLOSED):
for verb in ("to_lobby", "next_level"):
assert verb not in MESSAGE_WHITELIST[state]
def test_countdown_accepts_no_gameplay_verb() -> None:
# The 3 s countdown is a wall: only the handshake and the keep-alive get
# through (a late `ready` or `input` is `wrong_state`).
assert MESSAGE_WHITELIST[RoomState.COUNTDOWN] == {"hello", "ping"}
+344
View File
@@ -0,0 +1,344 @@
"""Unit: the send-queue policy and the per-connection guardrail arithmetic
(phase 06 task 02) — the `Connection`'s queue is the only buffer between
the game loop and a slow reader, so it must be bounded and honest:
* **snapshot droppability under a slow reader** — once the queue is at
`MAX_SEND_QUEUE` a new frame may only enter after the *oldest snapshot*
is dropped; `lobby` / `round` / `err` frames are never dropped and keep
their order,
* **the one-`rate_limited`-err bound** — a flood queues at most one
`rate_limited` `err` (the client is told once; the queue cannot grow
with the flood),
* **no unbounded growth** — the queue caps at `MAX_SEND_QUEUE` for the
dominant (snapshot) traffic; only the never-dropped frames can push it
past that, and only by their own count (game-bounded: one
`lobby` / `round` per state change, one coalesced `rate_limited`),
* **the token bucket** — `rate` tokens per second, a burst of `burst`,
refilled strictly off the injected `Clock` (never a wall clock),
* **the abuse tracker** — 3 *consecutive* rate/oversize violations inside
the 10 s window close `4404` (a clean frame resets the run); 5 shape
violations inside the window close it (a violation older than the
window does not count).
"""
from __future__ import annotations
import asyncio
import contextlib
import json
from typing import Any
import pytest
from undercooked.server.clock import FakeClock
from undercooked.server.protocol import (
ErrMsg,
ErrorCode,
LobbyMsg,
RoundMsg,
SnapMsg,
)
from undercooked.server.ws import (
ABUSE_WINDOW_S,
MAX_SEND_QUEUE,
AbuseTracker,
Connection,
TokenBucket,
pump_out,
)
pytestmark = pytest.mark.unit
class _FakeWs:
"""A `WsLike` that records what the pump wrote (no socket)."""
def __init__(self) -> None:
self.sent: list[dict[str, Any]] = []
async def send_text(self, data: str) -> None:
self.sent.append(json.loads(data))
def _conn(clock: FakeClock) -> tuple[Connection, _FakeWs]:
ws = _FakeWs()
return (
Connection(
ws=ws,
token="tok-unit", # noqa: S106 - a test identity, not a credential
player_id="tok-unit",
seat_index=0,
role="player",
rate=TokenBucket(10.0, 10, clock),
),
ws,
)
def _snap(index: int) -> SnapMsg:
return SnapMsg(type="snap", raw={"t": index, "p": []})
def _lobby(index: int) -> LobbyMsg:
return LobbyMsg(
type="lobby",
code=f"C{index}",
state="lobby",
can_start=False,
seats=[],
spectators=[],
)
def _round() -> RoundMsg:
return RoundMsg(
type="round",
result="won",
score=1,
target=1,
stars=0,
time_left=0.0,
counters={},
longest_streak=0,
players=[],
level_id="x",
seed=1,
ticks=0,
)
# ------------------------------------------------------------------ drop policy
async def test_slow_reader_drops_oldest_snapshots() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
# A slow reader: nothing is pumped yet, so the queue fills.
for index in range(3):
conn.enqueue(_lobby(index)) # non-droppable
for index in range(20):
conn.enqueue(_snap(index)) # droppable
assert conn.send_queue.qsize() <= MAX_SEND_QUEUE
# Drain and check: the lobbies are all there, in order; the survivors
# are the NEWEST snapshots.
got: list[Any] = []
while not conn.send_queue.empty():
got.append(conn.send_queue.get_nowait())
types = [item.type for item in got]
assert types == ["lobby"] * 3 + ["snap"] * (MAX_SEND_QUEUE - 3)
survivors = [item.raw["t"] for item in got if item.type == "snap"]
assert survivors == list(
range(20 - (MAX_SEND_QUEUE - 3), 20)
), "the oldest snapshots must be the ones dropped"
async def test_non_droppable_frames_preserve_order() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
frames = [
_lobby(0),
_snap(0),
_round(),
_snap(1),
_lobby(1),
_snap(2),
_round(),
_snap(3),
_lobby(2),
_snap(4),
_snap(5),
_snap(6),
_snap(7),
_snap(8),
]
for frame in frames:
conn.enqueue(frame)
got: list[Any] = []
while not conn.send_queue.empty():
got.append(conn.send_queue.get_nowait())
# Nothing non-droppable was dropped; the snaps that survived are the
# newest, and every frame keeps its relative order.
non_droppable = [item for item in got if item.type in ("lobby", "round")]
assert list(non_droppable) == [
_lobby(0),
_round(),
_lobby(1),
_round(),
_lobby(2),
]
snaps = [item for item in got if item.type == "snap"]
assert [s.raw["t"] for s in snaps] == sorted(s.raw["t"] for s in snaps)
async def test_queue_cannot_grow_with_a_rate_limited_flood() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
for index in range(50): # a flood of refusals (e.g. 50 rate_limited errs)
conn.enqueue(
ErrMsg(
type="err",
code=ErrorCode.RATE_LIMITED,
msg=f"refusal {index}",
)
)
# At most ONE rate_limited err is queued (coalesced); the queue is
# bounded regardless of the flood's size.
held: list[Any] = []
while True:
try:
held.append(conn.send_queue.get_nowait())
except asyncio.QueueEmpty:
break
rate_limited = [item for item in held if item.code == ErrorCode.RATE_LIMITED]
assert len(rate_limited) == 1
assert len(held) <= MAX_SEND_QUEUE + 1
async def test_queue_caps_at_max_under_snapshot_flood() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
for index in range(100): # the dominant traffic, a slow reader: the
conn.enqueue(_snap(index)) # queue holds the newest, caps at the max
assert conn.send_queue.qsize() == MAX_SEND_QUEUE
held: list[Any] = []
while True:
try:
held.append(conn.send_queue.get_nowait())
except asyncio.QueueEmpty:
break
assert [item.raw["t"] for item in held] == list(range(92, 100))
async def test_all_non_droppable_frames_are_delivered_never_dropped() -> None:
clock = FakeClock()
conn, _ws = _conn(clock)
for index in range(30):
conn.enqueue(_lobby(index))
# Nothing is ever dropped: once the queue holds no snapshot there is
# nothing to drop, and the queue grows with the non-droppable frames
# the server actually sent (bounded by the game's own event count —
# a lobby/round/err per state change, a coalesced `rate_limited`).
assert conn.send_queue.qsize() == 30
async def test_pump_delivers_frames_in_order_to_a_slow_reader() -> None:
clock = FakeClock()
conn, ws = _conn(clock)
for index in range(15):
conn.enqueue(_snap(index))
assert conn.send_queue.qsize() == MAX_SEND_QUEUE
pump = asyncio.create_task(pump_out(ws, conn))
await asyncio.sleep(0) # let the pump run its passes
conn.alive = False
pump.cancel()
with contextlib.suppress(asyncio.CancelledError):
await pump
sent_snaps = [frame["raw"]["t"] for frame in ws.sent if frame["type"] == "snap"]
# Whatever the reader saw is in order and a suffix of the batch (only
# the oldest may have been dropped).
assert sent_snaps == sorted(sent_snaps)
assert sent_snaps == list(range(15 - len(sent_snaps), 15))
# ----------------------------------------------------------------- token bucket
def test_bucket_serves_its_burst_then_empty() -> None:
clock = FakeClock()
bucket = TokenBucket(rate=5.0, burst=5, clock=clock)
assert all(bucket.take() for _ in range(5))
assert not bucket.take() # the sixth, with no time elapsed: empty
def test_bucket_refills_off_the_injected_clock() -> None:
clock = FakeClock()
bucket = TokenBucket(rate=5.0, burst=5, clock=clock)
for _ in range(5):
bucket.take()
clock.advance(0.2) # one token (5/s * 0.2 s)
assert bucket.take()
assert not bucket.take()
clock.advance(1.0) # a full second: back to the burst cap
assert all(bucket.take() for _ in range(5))
assert not bucket.take()
def test_bucket_never_refills_past_the_burst_cap() -> None:
clock = FakeClock()
bucket = TokenBucket(rate=5.0, burst=3, clock=clock)
clock.advance(100.0) # a very long idle
assert bucket.tokens() == 3.0
assert all(bucket.take() for _ in range(3))
assert not bucket.take()
def test_bucket_rejects_impossible_parameters() -> None:
clock = FakeClock()
with pytest.raises(ValueError, match=r"rate must be > 0"):
TokenBucket(rate=0.0, burst=1, clock=clock)
with pytest.raises(ValueError, match=r"burst must be >= 1"):
TokenBucket(rate=1.0, burst=0, clock=clock)
def test_bucket_tokens_reports_the_refilled_count() -> None:
clock = FakeClock()
bucket = TokenBucket(rate=2.0, burst=10, clock=clock)
for _ in range(5):
bucket.take()
assert bucket.tokens() == 5.0
clock.advance(0.5) # +1 (2/s * 0.5 s)
assert bucket.tokens() == 6.0
# ------------------------------------------------------------- the abuse tracker
def test_rate_close_after_three_consecutive_violations() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
assert not tracker.note_rate_violation()
assert not tracker.note_rate_violation()
assert tracker.note_rate_violation() # the third, inside the window
def test_clean_frame_resets_the_consecutive_run() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
assert not tracker.note_rate_violation()
tracker.note_clean()
assert not tracker.note_rate_violation()
assert not tracker.note_rate_violation() # the run only has two now
clock.advance(1.0)
assert tracker.note_rate_violation() # three consecutive again
def test_rate_violations_outside_the_window_do_not_close() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
assert not tracker.note_rate_violation() # t=0
clock.advance(ABUSE_WINDOW_S + 0.001) # the first stamp falls out
assert not tracker.note_rate_violation() # t=10.001: the run is two, one in-window
assert not tracker.note_rate_violation() # t=10.001: the run is three, two in-window
clock.advance(0.1)
assert tracker.note_rate_violation() # now three fit in the window
def test_shape_close_after_five_in_the_window() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
for _ in range(4):
assert not tracker.note_shape_violation()
assert tracker.note_shape_violation() # the fifth
def test_shape_violations_outside_the_window_do_not_close() -> None:
clock = FakeClock()
tracker = AbuseTracker(clock)
for _ in range(4):
assert not tracker.note_shape_violation() # four at t=0
assert tracker.note_shape_violation() # the fifth closes (all in-window)
# Age the whole batch past the window, then it must not count again.
clock.advance(ABUSE_WINDOW_S + 0.001)
for _ in range(4):
assert not tracker.note_shape_violation() # one..four in-window
assert tracker.note_shape_violation() # the fifth in-window
+561
View File
@@ -0,0 +1,561 @@
"""Unit: the input-ingestion path (phase 06, task 03) — `ws.to_player_input`,
`ws.seq_verdict`, the `_dispatch_input` layers (seat check, seq discipline,
clamp, gap counting) and the room's per-tick queue (capacity `4 x seats`,
drop-oldest-for-that-player, `stats()` census).
The sim-side backstop of the clamp — a `(1000, 1000)` intent driven through
`to_player_input` + `Room.queue_input` + `tick_once` for 20 ticks never moves
a cook more than `MAX_SPEED x 0.05 x 1.01` per tick — is the hostile-speed
proof the live-socket suite in `tests/integration/test_ws_input.py` mirrors
at the wire layer (the wire's own `Vec2Msg` rejection of magnitude > 1 is the
early, explicit half of the rule; `movement.intent_direction()` is the
backstop the sim keeps behind both).
"""
from __future__ import annotations
import math
from typing import Any
import pytest
from undercooked.config import Settings
from undercooked.server.clock import FakeClock
from undercooked.server.protocol import ErrMsg, ErrorCode, InputMsg, Vec2Msg
from undercooked.server.room import Room, RoomState
from undercooked.server.ws import (
TOLERATED_SEQ_GAP,
Connection,
TokenBucket,
WsLike,
_dispatch_input,
seq_verdict,
to_player_input,
)
from undercooked.sim.content import ContentBundle
from undercooked.sim.level import Level, LevelDef
from undercooked.sim.model import Facing, ItemForm, ItemKind, StationKind, Vec2
from undercooked.sim.movement import MAX_SPEED
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
from undercooked.sim.state_build import SeatSpec, build_state
from undercooked.sim.step import STEP_DT, PlayerInput
pytestmark = pytest.mark.unit
# --------------------------------------------------------------------- helpers
class _FakeWs(WsLike):
"""A socket stand-in: records the text frames it was sent."""
def __init__(self) -> None:
self.sent: list[str] = []
async def send_text(self, data: str) -> None:
self.sent.append(data)
def make_room(**settings: Any) -> tuple[Room, FakeClock]:
"""A room on a `FakeClock`, one seated (host) player, `PLAYING` (the
`queue_input` path under test needs the state, not the countdown)."""
clock = FakeClock()
room = Room(code="INPT1", settings=Settings(**settings), clock=clock)
room.add_seat("tok-a", "Ada", True)
room.state = RoomState.PLAYING
return room, clock
def make_conn(
token: str, clock: FakeClock, *, seat_index: int = 0
) -> tuple[Connection, _FakeWs]:
ws = _FakeWs()
conn = Connection(
ws=ws,
token=token,
player_id=token,
seat_index=seat_index,
role="player",
rate=TokenBucket(40, 40, clock),
)
return conn, ws
def make_input(
seq: int,
x: float = 0.0,
y: float = 0.0,
act: object = False,
face: object = None,
) -> InputMsg:
"""A `InputMsg` via `model_construct` — bypasses the strict validation
on purpose: the clamp layer under test must stand whatever the wire
layer already rejected (the `(1000, 1000)` hostile frame, the non-bool
`act`, the unknown `face` all live here)."""
return InputMsg.model_construct(
type="input",
seq=seq,
move=Vec2Msg.model_construct(x=x, y=y),
act=act,
face=face,
)
# ---------------------------------------------------------------- seq_verdict
@pytest.mark.parametrize(
("last_seq", "seq", "expected"),
[
(0, 1, "accept"),
(5, 6, "accept"),
(5, 5, "duplicate"),
(5, 4, "stale"),
(5, 0, "stale"),
],
)
def test_seq_verdict_is_the_task_03_matrix(
last_seq: int, seq: int, expected: str
) -> None:
assert seq_verdict(last_seq, seq) == expected
def test_the_tolerated_gap_is_three() -> None:
assert TOLERATED_SEQ_GAP == 3
# ------------------------------------------------------- to_player_input (clamp)
def test_a_hostile_move_is_clamped_to_the_unit_disc() -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(conn, make_input(1, x=1000.0, y=1000.0), tick=7)
assert frame.player_id == "tok-a"
assert frame.seq == 1
assert frame.received_at_tick == 7
length = math.hypot(frame.move.x, frame.move.y)
assert length <= 1.0 + 1e-9, f"the clamp let a magnitude of {length} through"
# A unit diagonal (the (1000, 1000) direction, normalised) — not a boost.
assert abs(frame.move.x - frame.move.y) < 1e-9
assert frame.move.x == pytest.approx(0.707, abs=1e-3)
def test_a_unit_move_passes_through_unclamped() -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(conn, make_input(1, x=1.0, y=0.0), tick=0)
assert (frame.move.x, frame.move.y) == (1.0, 0.0)
def test_move_is_quantized_to_three_decimals() -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(conn, make_input(1, x=0.123456, y=-0.654321), tick=0)
assert frame.move.x == round(0.123456, 3)
assert frame.move.y == round(-0.654321, 3)
def test_act_is_coerced_to_bool() -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
assert to_player_input(conn, make_input(1, act=1), tick=0).action is True
assert to_player_input(conn, make_input(1, act=0), tick=0).action is False
# ------------------------------------------------- face derivation (task 03)
@pytest.mark.parametrize(
("x", "y", "expected"),
[
(1.0, 0.0, Facing.RIGHT),
(-1.0, 0.0, Facing.LEFT),
(0.0, -1.0, Facing.UP),
(0.0, 1.0, Facing.DOWN),
(0.7, 0.7, Facing.RIGHT), # a 45° press: the dominant (horizontal) axis
(0.7, -0.7, Facing.RIGHT),
(-0.7, 0.7, Facing.LEFT),
(-0.7, -0.7, Facing.LEFT),
],
)
def test_face_is_derived_from_move_when_null(
x: float, y: float, expected: Facing
) -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(conn, make_input(1, x=x, y=y, face=None), tick=0)
assert frame.face is expected
def test_face_is_derived_from_move_when_unknown() -> None:
"""An unknown `face` is unreachable over the wire (`InputMsg` rejects it
as `bad_field` first) — but `to_player_input` is the clamp layer, and a
directly-constructed one must derive, not raise."""
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(
conn, make_input(1, x=1.0, y=0.0, face="backwards"), tick=0
)
assert frame.face is Facing.RIGHT
def test_a_zero_move_derives_no_facing() -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(conn, make_input(1, x=0.0, y=0.0), tick=0)
assert frame.face is None
def test_an_explicit_known_face_wins_over_move() -> None:
_, clock = make_room()
conn, _ = make_conn("tok-a", clock)
frame = to_player_input(conn, make_input(1, x=1.0, y=0.0, face="up"), tick=0)
assert frame.face is Facing.UP
# --------------------------------------------------- _dispatch_input: seat check
def test_a_spectator_input_is_refused_not_seated() -> None:
"""A token with no seat (a spectator, or none) is `err not_seated`;
nothing is queued, and `last_seq` is untouched."""
room, clock = make_room()
room.add_spectator("tok-spec", "Spec")
conn, _ = make_conn("tok-spec", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
assert not room._queued_inputs
assert conn.last_seq == 0
(err,) = conn.send_queue._queue # type: ignore[reportAttributeAccessIssue]
assert isinstance(err, ErrMsg)
assert err.code == ErrorCode.NOT_SEATED
def test_a_disconnected_seat_is_silently_ignored() -> None:
room, clock = make_room()
room.mark_seat_disconnected("tok-a")
conn, ws = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
assert not room._queued_inputs
assert conn.last_seq == 0 # not accepted: the stat path never ran
assert room.stats()["inputs_received"] == 0
assert not ws.sent # no err, nothing
def test_the_room_layer_repeats_the_disconnected_guard() -> None:
"""`Room.queue_input` is the sim-side backstop: a `DISCONNECTED` seat's
frame is ignored there too (no event, no queue, no stat)."""
room, _ = make_room()
room.mark_seat_disconnected("tok-a")
assert (
room.queue_input(
"tok-a",
PlayerInput(player_id="tok-a", seq=1, move=Vec2(1.0, 0.0)),
)
is False
)
assert not room._queued_inputs
assert not room.events_since_snapshot
assert room.stats()["inputs_received"] == 0
# ------------------------------------------- _dispatch_input: the seq discipline
def test_an_accepted_input_advances_last_seq_and_queues() -> None:
room, clock = make_room()
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
assert conn.last_seq == 1
assert len(room._queued_inputs) == 1
assert room._queued_inputs[0].received_at_tick == 0 # the sim tick (None state)
assert room.stats()["inputs_received"] == 1
assert room.stats()["input_gaps"] == 0
def test_an_equal_seq_is_silently_dropped() -> None:
room, clock = make_room()
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0)) # the resend
assert conn.last_seq == 1
assert len(room._queued_inputs) == 1 # the first one only
assert room.stats()["inputs_received"] == 1
assert room.stats()["stale_seqs"] == 0
assert not [
event
for event in room.events_since_snapshot
if event.kind == "resync"
]
def test_a_backwards_seq_is_refused_with_seq_and_a_resync_event() -> None:
room, clock = make_room()
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(5, x=1.0, y=0.0))
_dispatch_input(conn, room, make_input(3, x=1.0, y=0.0)) # behind 5
assert conn.last_seq == 5 # never lowered
assert len(room._queued_inputs) == 1
assert room.stats()["stale_seqs"] == 1
(err,) = conn.send_queue._queue # type: ignore[reportAttributeAccessIssue]
assert isinstance(err, ErrMsg)
assert err.code == ErrorCode.BAD_FIELD
assert err.seq == 3 # the frame's seq: what the client resyncs off
resyncs = [
event
for event in room.events_since_snapshot
if event.kind == "resync"
]
assert len(resyncs) == 1
assert resyncs[0].payload == {"player_id": "tok-a", "seq": 3}
def test_a_gap_of_three_or_less_is_tolerated_silently() -> None:
room, clock = make_room()
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
_dispatch_input(conn, room, make_input(4, x=1.0, y=0.0)) # a jump of 3
assert conn.last_seq == 4
assert len(room._queued_inputs) == 2
assert room.stats()["input_gaps"] == 0
def test_a_larger_gap_is_accepted_and_counted() -> None:
room, clock = make_room()
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
_dispatch_input(conn, room, make_input(50, x=1.0, y=0.0)) # a jump of 49
assert conn.last_seq == 50
assert len(room._queued_inputs) == 2
assert room.stats()["input_gaps"] == 1
def test_a_hostile_move_counts_clamped_moves_and_clamps() -> None:
room, clock = make_room()
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1000.0, y=1000.0))
assert room.stats()["clamped_moves"] == 1
(frame,) = room._queued_inputs
assert math.hypot(frame.move.x, frame.move.y) <= 1.0 + 1e-9
def test_input_in_a_non_playing_room_is_dropped_silently() -> None:
room, clock = make_room()
room.state = RoomState.LOBBY
conn, _ = make_conn("tok-a", clock)
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
assert not room._queued_inputs
assert room.stats()["inputs_received"] == 0
# --------------------------------------------------- the room's per-tick queue
def _fill(room: Room, token: str, count: int, start_seq: int = 1) -> None:
for offset in range(count):
assert room.queue_input(
token,
PlayerInput(
player_id=token, seq=start_seq + offset, move=Vec2(1.0, 0.0)
),
)
def test_the_queue_capacity_is_four_times_the_seats() -> None:
room, _ = make_room() # one seat: capacity 4
_fill(room, "tok-a", 4)
assert len(room._queued_inputs) == 4
# The 5th (same player): the oldest of *that player's* entries drops.
assert room.queue_input(
"tok-a", PlayerInput(player_id="tok-a", seq=5, move=Vec2(1.0, 0.0))
)
assert len(room._queued_inputs) == 4
assert room._queued_inputs[0].seq == 2 # seq 1 was the drop
assert room.stats()["inputs_dropped"] == 1
assert room.stats()["inputs_received"] == 5
def test_overflow_never_drops_another_players_entries() -> None:
"""The queue is full of player A's frames: player B's first frame is
the drop (a spammer cannot clear another player's tick); A's oldest
still drops for A's own next frame."""
room, _ = make_room()
room.add_seat("tok-b", "Bob", False)
_fill(room, "tok-a", 8) # 2 seats: capacity 8; 8 of A's frames
assert (
room.queue_input(
"tok-b", PlayerInput(player_id="tok-b", seq=1, move=Vec2(1.0, 0.0))
)
is False
)
assert not any(frame.player_id == "tok-b" for frame in room._queued_inputs)
assert room.stats()["inputs_dropped"] == 1
# A's own 9th frame evicts A's oldest, as usual.
assert room.queue_input(
"tok-a", PlayerInput(player_id="tok-a", seq=9, move=Vec2(1.0, 0.0))
)
assert room._queued_inputs[0].seq == 2
assert len(room._queued_inputs) == 8
def test_an_unknown_or_spectator_id_is_a_buffered_err_event() -> None:
room, _ = make_room()
room.add_spectator("tok-spec", "Spec")
assert (
room.queue_input(
"tok-spec", PlayerInput(player_id="tok-spec", seq=1)
)
is False
)
assert (
room.queue_input("tok-none", PlayerInput(player_id="tok-none", seq=1))
is False
)
reasons = [
event.payload.get("reason")
for event in room.events_since_snapshot
if event.kind == "err"
]
assert reasons == ["not_seated", "unknown_player"]
assert room.stats()["inputs_received"] == 0
def test_note_input_stat_rejects_a_misspelling() -> None:
room, _ = make_room()
with pytest.raises(KeyError):
room.note_input_stat("inputs_received_typo")
with pytest.raises(KeyError):
room.note_input_stat("")
def test_stats_is_a_fixed_shape_census_of_zeroes_fresh() -> None:
room, _ = make_room()
assert room.stats() == {
"inputs_received": 0,
"inputs_dropped": 0,
"stale_seqs": 0,
"clamped_moves": 0,
"input_gaps": 0,
"events_dropped": 0,
}
def test_note_stale_seq_counts_and_buffers_the_resync_event() -> None:
room, _ = make_room()
room.note_stale_seq("tok-a", 3)
assert room.stats()["stale_seqs"] == 1
resyncs = [
event for event in room.events_since_snapshot if event.kind == "resync"
]
assert len(resyncs) == 1
assert resyncs[0].payload == {"player_id": "tok-a", "seq": 3}
# --------------------------------- the hostile-speed proof (sim-side, per tick)
def test_a_hostile_intent_moved_through_the_whole_path_never_exceeds_max_speed() -> None:
"""`(1000, 1000)` every tick, through `to_player_input` +
`Room.queue_input` + `tick_once`: the per-tick distance stays within
`MAX_SPEED x dt x 1.01`. This is the clamp chain's proof (the wire
layer's `Vec2Msg` rejection is a separate, earlier line of defence —
the live suite asserts it there)."""
# A bare, open 9x4 kitchen (no walls to lean on): the cook's motion is
# the velocity's, so the distance bound is the velocity bound. The
# `LevelDef`'s own guards (`target_score > 0`, a non-empty `pool`)
# want a recipe, so `soup` rides along (a high `target` keeps the
# 20-tick window from ever ending the round).
level = Level.build(
LevelDef.model_validate(
{
"id": "hostile_1",
"name": "Hostile",
"order": 1,
"time_limit": 30.0,
"target_score": 1000,
"grid": [
"#########",
"#.......#",
"#....SV.#",
"#.......#",
"#########",
],
"tiles": {
".": {"kind": "floor"},
"#": {"kind": "wall"},
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
"V": {"kind": "floor", "station": "serve"},
},
"spawns": [[4, 2]],
"orders": {
"pool": ["soup"],
"concurrent": 1,
"spawn_interval": [10, 10],
"deadline": 30,
},
}
)
)
bundle = ContentBundle(
recipe_book=RecipeBook(
recipes={
"soup": Recipe(
id="soup",
name="Soup",
base_score=40,
ingredients=[
Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW)
],
steps=[
Step(kind="serve", station=StationKind.SERVE, seconds=0.0)
],
)
},
ingredients=[ItemKind.TOMATO],
),
levels=(level,),
)
clock = FakeClock()
room = Room(code="INPT1", settings=Settings(), clock=clock, content=bundle)
room.add_seat("tok-a", "Ada", True)
room.state = RoomState.PLAYING
state = build_state(
level,
[SeatSpec(player_id="tok-a", seat=0, display_name="Ada", color="")],
7,
book=bundle.recipe_book,
)
room.sim_state = state
conn, _ = make_conn("tok-a", clock)
room.tick_once() # warm-up: sets `_last_at` (a 0-`dt` tick advances
# nothing — with the substep guard it also keeps the queue)
previous: Vec2 | None = None
worst = 0.0
for tick in range(1, 21):
_dispatch_input(
conn,
room,
make_input(tick, x=1000.0, y=1000.0, act=True),
)
assert len(room._queued_inputs) == 1, "one frame per tick, one drop max"
clock.advance(0.05)
room.tick_once()
assert room.sim_state is not None
(player,) = room.sim_state.players
if previous is not None:
distance = (player.pos - previous).length
worst = max(worst, distance)
previous = player.pos
bound = MAX_SPEED * STEP_DT * 1.01
assert worst <= bound, (
f"the cook moved {worst:.4f} tiles in one tick; the bound is {bound:.4f}"
)