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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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 == []
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user