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,45 @@
|
||||
# levels/01_first_kitchen.yaml — campaign level 1, the tutorial kitchen.
|
||||
# Schema: docs/LEVELS.md · validated at startup and by `undercooked-levels
|
||||
# validate`. Every '#' is wall, every station stands on walkable floor.
|
||||
|
||||
id: 01_first_kitchen
|
||||
name: First Kitchen
|
||||
order: 1
|
||||
difficulty: 1
|
||||
time_limit: 150
|
||||
target_score: 45
|
||||
blurb: >-
|
||||
Your first shift: grab a tomato and an onion from the shelves, chop them,
|
||||
cook the soup, plate it, and run it to the serve window before the clock
|
||||
runs out. One dish, one of everything, nowhere to bump into each other.
|
||||
grid:
|
||||
- "############"
|
||||
- "#T..C...S..#"
|
||||
- "#..........#"
|
||||
- "#.P......X.#"
|
||||
- "#..........#"
|
||||
- "#..K....O..#"
|
||||
- "#..........#"
|
||||
- "#..........#"
|
||||
- "############"
|
||||
tiles:
|
||||
"#": {kind: wall}
|
||||
".": {kind: floor}
|
||||
"T": {kind: floor, station: spawn_shelf, item: tomato}
|
||||
"O": {kind: floor, station: spawn_shelf, item: onion}
|
||||
"C": {kind: floor, station: chop_board}
|
||||
"S": {kind: floor, station: stove}
|
||||
"P": {kind: floor, station: prep}
|
||||
"X": {kind: floor, station: serve}
|
||||
"K": {kind: floor, station: sink}
|
||||
spawns: [[2, 7], [9, 7]]
|
||||
orders:
|
||||
pool: [soup]
|
||||
concurrent: 1
|
||||
spawn_interval: [16, 20]
|
||||
deadline: 60
|
||||
hazards: []
|
||||
escalation_notes: >-
|
||||
Tutorial baseline (PLAN §8 campaign plan): one recipe, one order at a time,
|
||||
slowest spawn interval, longest time budget, open floor, no hazards. Every
|
||||
later level moves at least two axes up from these numbers.
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
# levels/recipes.yaml — the campaign recipe book (PLAN §5/§8, A8).
|
||||
#
|
||||
# Every dish the game knows lives here; no Python enumerates recipes. The
|
||||
# schema is `src/undercooked/sim/recipe.py` and the reference is
|
||||
# docs/LEVELS.md — every model is strict (`extra="forbid"`), so a typo here
|
||||
# fails the load loudly instead of silently defaulting.
|
||||
#
|
||||
# Step lists mirror PLAN §5's station pipelines: pick from the spawn shelf,
|
||||
# chop (~0.9 s), cook on the stove, plate at the prep station, serve at the
|
||||
# window. `seconds` feeds only the validator's greedy target-plausibility
|
||||
# bound; the simulation (phase 04) re-enacts these steps per tick.
|
||||
|
||||
# Catalog of the item kinds the campaign's dishes consume (documentation of
|
||||
# intent; spawn shelves are what actually put items on the floor).
|
||||
ingredients: [tomato, onion, lettuce, dough, patty, rice, fish, plate]
|
||||
|
||||
recipes:
|
||||
# tomato + onion -> chop -> pot on the stove -> plate
|
||||
soup:
|
||||
id: soup
|
||||
name: Tomato Soup
|
||||
base_score: 40
|
||||
ingredients:
|
||||
- {kind: tomato, form: raw}
|
||||
- {kind: onion, form: raw}
|
||||
steps:
|
||||
- {kind: pick, station: spawn_shelf}
|
||||
- {kind: chop, station: chop_board, seconds: 0.9}
|
||||
- {kind: cook, station: stove, seconds: 5.0}
|
||||
- {kind: plate, station: prep, seconds: 0.4}
|
||||
- {kind: serve, station: serve}
|
||||
tags: [hot, soup]
|
||||
|
||||
# lettuce + tomato -> chop -> plate; the fast, no-fire dish
|
||||
salad:
|
||||
id: salad
|
||||
name: Garden Salad
|
||||
base_score: 30
|
||||
ingredients:
|
||||
- {kind: lettuce, form: raw}
|
||||
- {kind: tomato, form: raw}
|
||||
steps:
|
||||
- {kind: pick, station: spawn_shelf}
|
||||
- {kind: chop, station: chop_board, seconds: 0.9}
|
||||
- {kind: plate, station: prep, seconds: 0.4}
|
||||
- {kind: serve, station: serve}
|
||||
tags: [cold, quick]
|
||||
|
||||
# rice + onion straight onto the stove (PLAN §5: raw rice needs no board)
|
||||
fried_rice:
|
||||
id: fried_rice
|
||||
name: Fried Rice
|
||||
base_score: 50
|
||||
ingredients:
|
||||
- {kind: rice, form: raw}
|
||||
- {kind: onion, form: raw}
|
||||
steps:
|
||||
- {kind: pick, station: spawn_shelf}
|
||||
- {kind: cook, station: stove, seconds: 6.0}
|
||||
- {kind: plate, station: prep, seconds: 0.4}
|
||||
- {kind: serve, station: serve}
|
||||
tags: [hot, grain]
|
||||
|
||||
# patty + dough + lettuce -> chop + pan on the stove -> plate. Defined now;
|
||||
# later levels (phase 11) put it in an order pool.
|
||||
burger:
|
||||
id: burger
|
||||
name: Chef's Burger
|
||||
base_score: 70
|
||||
ingredients:
|
||||
- {kind: patty, form: raw}
|
||||
- {kind: dough, form: raw}
|
||||
- {kind: lettuce, form: raw}
|
||||
steps:
|
||||
- {kind: pick, station: spawn_shelf}
|
||||
- {kind: chop, station: chop_board, seconds: 0.9}
|
||||
- {kind: cook, station: stove, seconds: 5.0}
|
||||
- {kind: plate, station: prep, seconds: 0.5}
|
||||
- {kind: serve, station: serve}
|
||||
tags: [hot, stacked]
|
||||
@@ -0,0 +1,59 @@
|
||||
# tests/fixtures/levels_round/01_first_kitchen.yaml — the shipped campaign
|
||||
# level 1, verbatim in layout and content, with two numbers tuned for the
|
||||
# phase-06 two-client round suite (tests/integration/test_ws_round_two_clients.py).
|
||||
#
|
||||
# `target_score` is 20 (the shipped 45): one served soup scores 44-66
|
||||
# (`round(40 x 1.0 x 1.1 x (1 + 0.5 x fraction))` — sim/scoring.py), so the
|
||||
# first serve always crosses the target and the round ends `WON` on that
|
||||
# substep, whatever the wall-clock jitter of a live 20 Hz tick. The shipped
|
||||
# 150 s / 45 target would make a live-suite win depend on choreography timing
|
||||
# (a second serve within the 60 s order deadline); the wire contract the
|
||||
# suite locks (the round's events, payload and lobby return) is the same.
|
||||
#
|
||||
# `time_limit` is 60 s (the shipped 150): a safety margin around the ~25 s
|
||||
# scripted soup. If the choreography ever stalls, the round ends `LOST` at the
|
||||
# buzzer instead of running a two-and-a-half-minute test.
|
||||
#
|
||||
# Schema: docs/LEVELS.md — validated by the server's content gate at boot and
|
||||
# by `undercooked-levels validate` on a copy.
|
||||
|
||||
id: 01_first_kitchen
|
||||
name: First Kitchen
|
||||
order: 1
|
||||
difficulty: 1
|
||||
time_limit: 60
|
||||
target_score: 20
|
||||
blurb: >-
|
||||
Your first shift: grab a tomato and an onion from the shelves, chop them,
|
||||
cook the soup, plate it, and run it to the serve window before the clock
|
||||
runs out. One dish, one of everything, nowhere to bump into each other.
|
||||
grid:
|
||||
- "############"
|
||||
- "#T..C...S..#"
|
||||
- "#..........#"
|
||||
- "#.P......X.#"
|
||||
- "#..........#"
|
||||
- "#..K....O..#"
|
||||
- "#..........#"
|
||||
- "#..........#"
|
||||
- "############"
|
||||
tiles:
|
||||
"#": {kind: wall}
|
||||
".": {kind: floor}
|
||||
"T": {kind: floor, station: spawn_shelf, item: tomato}
|
||||
"O": {kind: floor, station: spawn_shelf, item: onion}
|
||||
"C": {kind: floor, station: chop_board}
|
||||
"S": {kind: floor, station: stove}
|
||||
"P": {kind: floor, station: prep}
|
||||
"X": {kind: floor, station: serve}
|
||||
"K": {kind: floor, station: sink}
|
||||
spawns: [[2, 7], [9, 7]]
|
||||
orders:
|
||||
pool: [soup]
|
||||
concurrent: 1
|
||||
spawn_interval: [16, 20]
|
||||
deadline: 60
|
||||
hazards: []
|
||||
escalation_notes: >-
|
||||
Integration-suite copy of the tutorial baseline (PLAN §8 campaign plan):
|
||||
one recipe, one order at a time, open floor, no hazards.
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
# levels/recipes.yaml — the campaign recipe book (PLAN §5/§8, A8).
|
||||
#
|
||||
# Every dish the game knows lives here; no Python enumerates recipes. The
|
||||
# schema is `src/undercooked/sim/recipe.py` and the reference is
|
||||
# docs/LEVELS.md — every model is strict (`extra="forbid"`), so a typo here
|
||||
# fails the load loudly instead of silently defaulting.
|
||||
#
|
||||
# Step lists mirror PLAN §5's station pipelines: pick from the spawn shelf,
|
||||
# chop (~0.9 s), cook on the stove, plate at the prep station, serve at the
|
||||
# window. `seconds` feeds only the validator's greedy target-plausibility
|
||||
# bound; the simulation (phase 04) re-enacts these steps per tick.
|
||||
|
||||
# Catalog of the item kinds the campaign's dishes consume (documentation of
|
||||
# intent; spawn shelves are what actually put items on the floor).
|
||||
ingredients: [tomato, onion, lettuce, dough, patty, rice, fish, plate]
|
||||
|
||||
recipes:
|
||||
# tomato + onion -> chop -> pot on the stove -> plate
|
||||
soup:
|
||||
id: soup
|
||||
name: Tomato Soup
|
||||
base_score: 40
|
||||
ingredients:
|
||||
- {kind: tomato, form: raw}
|
||||
- {kind: onion, form: raw}
|
||||
steps:
|
||||
- {kind: pick, station: spawn_shelf}
|
||||
- {kind: chop, station: chop_board, seconds: 0.9}
|
||||
- {kind: cook, station: stove, seconds: 5.0}
|
||||
- {kind: plate, station: prep, seconds: 0.4}
|
||||
- {kind: serve, station: serve}
|
||||
tags: [hot, soup]
|
||||
|
||||
# lettuce + tomato -> chop -> plate; the fast, no-fire dish
|
||||
salad:
|
||||
id: salad
|
||||
name: Garden Salad
|
||||
base_score: 30
|
||||
ingredients:
|
||||
- {kind: lettuce, form: raw}
|
||||
- {kind: tomato, form: raw}
|
||||
steps:
|
||||
- {kind: pick, station: spawn_shelf}
|
||||
- {kind: chop, station: chop_board, seconds: 0.9}
|
||||
- {kind: plate, station: prep, seconds: 0.4}
|
||||
- {kind: serve, station: serve}
|
||||
tags: [cold, quick]
|
||||
|
||||
# rice + onion straight onto the stove (PLAN §5: raw rice needs no board)
|
||||
fried_rice:
|
||||
id: fried_rice
|
||||
name: Fried Rice
|
||||
base_score: 50
|
||||
ingredients:
|
||||
- {kind: rice, form: raw}
|
||||
- {kind: onion, form: raw}
|
||||
steps:
|
||||
- {kind: pick, station: spawn_shelf}
|
||||
- {kind: cook, station: stove, seconds: 6.0}
|
||||
- {kind: plate, station: prep, seconds: 0.4}
|
||||
- {kind: serve, station: serve}
|
||||
tags: [hot, grain]
|
||||
|
||||
# patty + dough + lettuce -> chop + pan on the stove -> plate. Defined now;
|
||||
# later levels (phase 11) put it in an order pool.
|
||||
burger:
|
||||
id: burger
|
||||
name: Chef's Burger
|
||||
base_score: 70
|
||||
ingredients:
|
||||
- {kind: patty, form: raw}
|
||||
- {kind: dough, form: raw}
|
||||
- {kind: lettuce, form: raw}
|
||||
steps:
|
||||
- {kind: pick, station: spawn_shelf}
|
||||
- {kind: chop, station: chop_board, seconds: 0.9}
|
||||
- {kind: cook, station: stove, seconds: 5.0}
|
||||
- {kind: plate, station: prep, seconds: 0.5}
|
||||
- {kind: serve, station: serve}
|
||||
tags: [hot, stacked]
|
||||
@@ -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()
|
||||
@@ -210,6 +210,7 @@ def test_the_docs_on_disk_are_the_shipped_documents() -> None:
|
||||
"docs/DESIGN.md",
|
||||
"docs/DEPLOYMENT.md",
|
||||
"docs/LEVELS.md",
|
||||
"docs/PROTOCOL.md",
|
||||
"docs/ROOMS.md",
|
||||
"docs/SIMULATION.md",
|
||||
"docs/TESTING.md",
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
"""Unit: the snapshot encoder keeps the sim's internals off the wire
|
||||
(phase 06, task 04) — the recursive no-leak guard, the 2-decimal
|
||||
quantization, the explicit event-payload map, the delta tombstones, and
|
||||
`snapshot_size`'s UTF-8 accounting.
|
||||
|
||||
The no-leak rule (task 04): a frame contains no `token`, no
|
||||
`display_name`, no `seed`, no station internal `id`, no `rng_state` —
|
||||
checked recursively, on *values* as well as *keys* (a station's
|
||||
internal id, `stove_1`, is a value, not a key). The sim's internal
|
||||
identity is projected out by design: a station's wire identity is its
|
||||
kind + coordinates (`k` + `x`/`y`), and an event's payload goes through
|
||||
the explicit `EVENT_PAYLOAD_SHORT` map — a key that is not listed there
|
||||
never leaves the server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.server.encoding import (
|
||||
EVENT_PAYLOAD_SHORT,
|
||||
encode_events,
|
||||
encode_snapshot,
|
||||
event_wire_dict,
|
||||
snapshot_size,
|
||||
)
|
||||
from undercooked.sim.grid import Grid
|
||||
from undercooked.sim.model import (
|
||||
Event,
|
||||
EventKind,
|
||||
Facing,
|
||||
Hazard,
|
||||
HazardKind,
|
||||
Item,
|
||||
ItemForm,
|
||||
ItemKind,
|
||||
KitchenState,
|
||||
Order,
|
||||
Player,
|
||||
PlayerState,
|
||||
Station,
|
||||
StationKind,
|
||||
StationState,
|
||||
TileKind,
|
||||
Vec2,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
#: The forbidden *key names* — the sim's internal fields, never on the
|
||||
#: wire (task 04's no-leak list; `station` is the internal-id carrier
|
||||
#: the explicit payload map exists to drop).
|
||||
FORBIDDEN_KEYS: frozenset[str] = frozenset(
|
||||
{"token", "display_name", "seed", "rng_state", "station"}
|
||||
)
|
||||
|
||||
#: The station/hazard internal ids (values — they must not appear
|
||||
#: anywhere in a frame).
|
||||
STOVE_ID = "stove_1"
|
||||
CHOP_ID = "chop_board_1"
|
||||
HARD_ID = "hz-fire-1"
|
||||
|
||||
A_DISPLAY_NAME = "A very unique cook name"
|
||||
A_SEED = 0x0BADF00D
|
||||
A_RNG_STATE = 0xCAFEBABE
|
||||
A_PLAYER_ID = "tok-abcdef0123456789" # the token value (allowed as `i`)
|
||||
|
||||
|
||||
def make_state() -> KitchenState:
|
||||
"""A hand-built state with every internal field set to a unique
|
||||
value: two stations (internal ids, contents, progress), one player
|
||||
(token id, display name, unquantized position, a carried item), one
|
||||
order, one hazard, a big `seed` and `rng_state`."""
|
||||
return KitchenState(
|
||||
level_id="01_first_kitchen",
|
||||
seed=A_SEED,
|
||||
grid=Grid(width=8, height=8, tiles=(TileKind.FLOOR,) * 64),
|
||||
tick=4821,
|
||||
stations=(
|
||||
Station(
|
||||
id=STOVE_ID,
|
||||
kind=StationKind.STOVE,
|
||||
x=3.23456,
|
||||
y=2.0,
|
||||
contents=(Item(ItemKind.ONION, ItemForm.RAW, quality=0.98765),),
|
||||
state=StationState.WORKING,
|
||||
progress=0.4242,
|
||||
),
|
||||
Station(
|
||||
id=CHOP_ID,
|
||||
kind=StationKind.CHOP_BOARD,
|
||||
x=5.0,
|
||||
y=7.0,
|
||||
state=StationState.DIRTY,
|
||||
dirty=True,
|
||||
),
|
||||
),
|
||||
players=(
|
||||
Player(
|
||||
id=A_PLAYER_ID,
|
||||
pos=Vec2(1.23456, -2.34567),
|
||||
vel=Vec2(0.5, -0.5),
|
||||
facing=Facing.RIGHT,
|
||||
carrying=Item(ItemKind.TOMATO, ItemForm.CHOPPED, quality=0.98765),
|
||||
state=PlayerState.MOVING,
|
||||
seat=1,
|
||||
display_name=A_DISPLAY_NAME,
|
||||
),
|
||||
),
|
||||
orders=(
|
||||
Order(id=7, recipe_id="soup", time_left=30.0, deadline=1200, value=40),
|
||||
),
|
||||
order_deadline=1200,
|
||||
hazards=(
|
||||
Hazard(
|
||||
id=HARD_ID,
|
||||
kind=HazardKind.FIRE,
|
||||
pos=Vec2(7.654321, 5.2),
|
||||
life=12.346,
|
||||
),
|
||||
),
|
||||
score=120,
|
||||
target_score=140,
|
||||
time_left=83.654,
|
||||
rng_state=A_RNG_STATE,
|
||||
)
|
||||
|
||||
|
||||
def walk(node: Any) -> Iterator[tuple[Any, Any]]:
|
||||
"""Every `(key, value)` pair in the JSON tree (keys only on objects)."""
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
yield key, value
|
||||
yield from walk(value)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
yield from walk(item)
|
||||
|
||||
|
||||
def assert_no_leak(frame: dict[str, Any]) -> None:
|
||||
"""The recursive no-leak check: no forbidden key, and no internal
|
||||
value (a station/hazard id, the display name, the seed, the
|
||||
rng_state) anywhere in the tree."""
|
||||
values: list[Any] = []
|
||||
for key, value in walk(frame):
|
||||
assert key not in FORBIDDEN_KEYS, f"forbidden key on the wire: {key!r}"
|
||||
values.append(value)
|
||||
for forbidden in (STOVE_ID, CHOP_ID, HARD_ID, A_DISPLAY_NAME):
|
||||
assert forbidden not in values, f"internal value on the wire: {forbidden!r}"
|
||||
assert A_SEED not in values, "the round seed leaked onto the wire"
|
||||
assert A_RNG_STATE not in values, "the rng_state leaked onto the wire"
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the no-leak guard
|
||||
|
||||
|
||||
def test_no_forbidden_key_or_internal_value_reaches_the_wire() -> None:
|
||||
assert_no_leak(encode_snapshot(make_state()))
|
||||
|
||||
|
||||
def test_the_delta_frame_is_no_leakier_than_the_full() -> None:
|
||||
state = make_state()
|
||||
full = encode_snapshot(state)
|
||||
moved = state.with_players(
|
||||
(replace(state.players[0], pos=Vec2(4.321, 2.765), state=PlayerState.IDLE),)
|
||||
)
|
||||
assert_no_leak(encode_snapshot(moved, full))
|
||||
|
||||
|
||||
def test_the_event_entries_carry_only_the_mapped_keys() -> None:
|
||||
"""An event whose payload carries the internal keys (`station`,
|
||||
`tick`, an unknown one) is projected through the explicit map — the
|
||||
unlisted keys (and the internal id they carry) never reach the
|
||||
wire."""
|
||||
events = [
|
||||
Event(
|
||||
kind=EventKind.RESYNC,
|
||||
payload={"player_id": A_PLAYER_ID, "seq": 41},
|
||||
),
|
||||
Event(
|
||||
kind=EventKind.DENIED,
|
||||
x=3.23456,
|
||||
y=2.0,
|
||||
payload={
|
||||
"player_id": A_PLAYER_ID,
|
||||
"reason": "contested",
|
||||
"station": STOVE_ID, # the internal id: must not survive
|
||||
"tick": 4821, # the frame's `t` is the tick: dropped
|
||||
"mystery": "no-such-mapping",
|
||||
},
|
||||
),
|
||||
]
|
||||
encoded = encode_events(events)
|
||||
assert len(encoded) == 2
|
||||
allowed = {"k", "x", "y", *EVENT_PAYLOAD_SHORT.values()}
|
||||
for entry in encoded:
|
||||
for key, _ in walk(entry):
|
||||
assert key in allowed, f"an unlisted event key reached the wire: {key!r}"
|
||||
(denied,) = [entry for entry in encoded if entry["k"] == "denied"]
|
||||
assert denied == {"k": "denied", "x": 3.23, "y": 2.0, "i": A_PLAYER_ID, "r": "contested"}
|
||||
assert STOVE_ID not in [value for _, value in walk(encoded)]
|
||||
|
||||
|
||||
def test_the_event_wire_dict_drops_unlisted_keys_the_same_way() -> None:
|
||||
event = Event(
|
||||
kind=EventKind.BURNT,
|
||||
x=3.23456,
|
||||
y=2.0,
|
||||
payload={
|
||||
"station": STOVE_ID,
|
||||
"item": "onion",
|
||||
"form": "burnt",
|
||||
"tick": 4821,
|
||||
"mystery": 1,
|
||||
},
|
||||
)
|
||||
wire = event_wire_dict(event)
|
||||
assert wire["kind"] == "burnt"
|
||||
assert wire["x"] == 3.23
|
||||
assert wire["payload"] == {"it": "onion", "fm": "burnt"}
|
||||
|
||||
|
||||
# ----------------------------------------------------------- quantization
|
||||
|
||||
|
||||
def test_quantization_is_to_two_decimals() -> None:
|
||||
frame = encode_snapshot(make_state())
|
||||
(player,) = frame["p"]
|
||||
(station,) = (
|
||||
entry for entry in frame["st"] if entry["k"] == "chop_board"
|
||||
)
|
||||
(stove,) = (entry for entry in frame["st"] if entry["k"] == "stove")
|
||||
(order,) = frame["o"]
|
||||
(hazard,) = frame["hz"]
|
||||
# The player's position (Vec2.quantized(2)).
|
||||
assert (player["x"], player["y"]) == (1.23, -2.35)
|
||||
# The station's position, progress and the content's quality.
|
||||
assert (stove["x"], stove["y"]) == (3.23, 2.0)
|
||||
assert stove["p"] == 0.42
|
||||
assert stove["q"] == 0.99 # round(0.98765, 2)
|
||||
# The order's deadline share (30 s of a 60 s window) and the clock.
|
||||
assert order["l"] == 0.5
|
||||
assert frame["tl"] == 83.65 # round(83.654, 2)
|
||||
# The hazard's position and life (seconds, 2 decimals).
|
||||
assert (hazard["x"], hazard["y"]) == (7.65, 5.2)
|
||||
assert hazard["l"] == 12.35
|
||||
# The carried item's short form (the `h` field) and the station's
|
||||
# first content (`g`).
|
||||
assert player["h"] == "tomato_chopped"
|
||||
assert stove["g"] == "onion_raw"
|
||||
assert station["g"] is None
|
||||
assert station["q"] == 1.0 # an empty station's quality
|
||||
|
||||
|
||||
def test_facing_is_an_index_of_zero_to_three() -> None:
|
||||
frame = encode_snapshot(make_state())
|
||||
(player,) = frame["p"]
|
||||
assert player["f"] == 1 # Facing.RIGHT
|
||||
for entry in frame["p"]:
|
||||
assert entry["f"] in (0, 1, 2, 3)
|
||||
|
||||
|
||||
def test_snapshot_size_is_the_utf8_length_of_the_frame_json() -> None:
|
||||
frame = encode_snapshot(make_state())
|
||||
assert snapshot_size(frame) == len(json.dumps(frame).encode("utf-8"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the delta
|
||||
|
||||
|
||||
def test_removed_orders_and_hazards_are_tombstoned() -> None:
|
||||
state = make_state()
|
||||
full = encode_snapshot(state)
|
||||
without = state.with_orders(()).with_hazards(())
|
||||
delta = encode_snapshot(without, full)
|
||||
assert delta["full"] is False
|
||||
(order_tombstone,) = [
|
||||
entry for entry in delta["d"]["o"] if entry.get("gone") is True
|
||||
]
|
||||
assert order_tombstone == {"id": 7, "gone": True}
|
||||
(hazard_tombstone,) = [
|
||||
entry for entry in delta["d"]["hz"] if entry.get("gone") is True
|
||||
]
|
||||
assert hazard_tombstone == {"t": "fire", "x": 7.65, "y": 5.2, "gone": True}
|
||||
|
||||
|
||||
def test_removed_players_and_stations_are_tombstoned_too() -> None:
|
||||
"""Every collection tombstones a vanished entity: a player (a cook
|
||||
that left the round) and a station (a fixture that is gone) carry
|
||||
their identity keys under `gone` so the client can drop them."""
|
||||
state = make_state()
|
||||
full = encode_snapshot(state)
|
||||
without = state.with_players(()).with_stations(())
|
||||
delta = encode_snapshot(without, full)
|
||||
(player_tombstone,) = [
|
||||
entry for entry in delta["d"]["p"] if entry.get("gone") is True
|
||||
]
|
||||
assert player_tombstone == {"i": A_PLAYER_ID, "gone": True}
|
||||
station_tombstones = [
|
||||
entry for entry in delta["d"]["st"] if entry.get("gone") is True
|
||||
]
|
||||
assert len(station_tombstones) == 2
|
||||
assert {
|
||||
(entry["k"], entry["x"], entry["y"])
|
||||
for entry in station_tombstones
|
||||
} == {("stove", 3.23, 2.0), ("chop_board", 5.0, 7.0)}
|
||||
|
||||
|
||||
def test_a_malformed_prev_degrades_to_an_empty_prev() -> None:
|
||||
"""A `prev` whose entity collection is not a list (a client bug in
|
||||
the stream, not a sim state) degrades to an empty previous: every
|
||||
current entity is in, and no tombstone is invented."""
|
||||
state = make_state()
|
||||
delta = encode_snapshot(state, prev={"p": {}, "st": "nope"})
|
||||
expected_player = {
|
||||
"i": A_PLAYER_ID,
|
||||
"x": 1.23,
|
||||
"y": -2.35,
|
||||
"f": 1,
|
||||
"h": "tomato_chopped",
|
||||
"s": "moving",
|
||||
"pi": 1,
|
||||
}
|
||||
assert delta["d"]["p"] == [expected_player]
|
||||
assert len(delta["d"]["st"]) == 2
|
||||
assert not any(entry.get("gone") for entry in delta["d"]["st"])
|
||||
|
||||
|
||||
def test_a_non_finite_or_null_payload_value_never_reaches_the_wire() -> None:
|
||||
"""`_short_value`'s guard: a `None` (and a `NaN`/`inf`) payload
|
||||
value is dropped — a null or a `NaN` in a JSON frame is noise, not
|
||||
information; a finite float is quantized to 2 decimals."""
|
||||
event = Event(
|
||||
kind=EventKind.SERVED,
|
||||
payload={
|
||||
"order_id": 7,
|
||||
"points": float("nan"),
|
||||
"quality": None,
|
||||
"streak": float("inf"),
|
||||
"delta": 12.346,
|
||||
},
|
||||
)
|
||||
(entry,) = encode_events([event])
|
||||
assert entry == {"k": "served", "x": 0.0, "y": 0.0, "oi": 7, "d": 12.35}
|
||||
(wire,) = [event_wire_dict(event)]
|
||||
assert wire["payload"] == {"oi": 7, "d": 12.35}
|
||||
|
||||
|
||||
def test_an_unchanged_entity_is_out_of_the_delta() -> None:
|
||||
"""The diff is against the *previous* frame (not the last full): an
|
||||
entity unchanged since `prev` is out, and one that moved *back* to
|
||||
its original values is in (the client holds the previous values —
|
||||
the move back is a change it must hear about). Decoding the chain
|
||||
(full → delta1) must land on `moved`'s full frame."""
|
||||
state = make_state()
|
||||
full = encode_snapshot(state)
|
||||
moved = state.with_players(
|
||||
(replace(state.players[0], pos=Vec2(4.321, 2.765)),)
|
||||
)
|
||||
delta1 = encode_snapshot(moved, full)
|
||||
assert delta1["d"]["p"][0]["x"] == 4.32 # the move is in
|
||||
# Unchanged since the previous frame: out.
|
||||
delta2 = encode_snapshot(moved, delta1)
|
||||
assert delta2["d"]["p"] == []
|
||||
# Back at the original values: in (a change since the previous frame).
|
||||
delta3 = encode_snapshot(state, delta2)
|
||||
(player,) = delta3["d"]["p"]
|
||||
assert player["x"] == 1.23 and player["y"] == -2.35
|
||||
# And the chain decodes: applying delta1 to the full lands on
|
||||
# `moved`'s full frame.
|
||||
assert _apply_delta(full, delta1) == encode_snapshot(moved)
|
||||
|
||||
|
||||
def _apply_delta(base: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]:
|
||||
"""The client's merge: upsert by identity key (preserving the base's
|
||||
order, new entities appended — the encoder's own semantics), drop
|
||||
the `gone` tombstones, take the scalars and `e` from the delta."""
|
||||
# The scalars (`t`, `s`, `tgt`, `tl`) and `e` ride the delta; the
|
||||
# entity arrays merge onto the base.
|
||||
decoded = {
|
||||
key: value
|
||||
for key, value in delta.items()
|
||||
if key not in ("d", "full")
|
||||
}
|
||||
identity: dict[str, Any] = {
|
||||
"p": lambda e: (e["i"],),
|
||||
"st": lambda e: (e["k"], e["x"], e["y"]),
|
||||
"o": lambda e: (e["id"],),
|
||||
"hz": lambda e: (e["t"], e["x"], e["y"]),
|
||||
}
|
||||
for key in ("p", "st", "o", "hz"):
|
||||
base_entries: list[dict[str, Any]] = base.get(key, [])
|
||||
delta_entries: list[dict[str, Any]] = delta["d"][key]
|
||||
by_identity = {identity[key](entry): dict(entry) for entry in delta_entries}
|
||||
merged: list[dict[str, Any]] = []
|
||||
seen: set[tuple[Any, ...]] = set()
|
||||
for entry in base_entries:
|
||||
key_ = identity[key](entry)
|
||||
if by_identity.get(key_, {}).get("gone") is True:
|
||||
continue # tombstoned
|
||||
merged.append(by_identity.get(key_, dict(entry)))
|
||||
seen.add(key_)
|
||||
for entry in delta_entries:
|
||||
key_ = identity[key](entry)
|
||||
if key_ not in seen and entry.get("gone") is not True:
|
||||
merged.append(dict(entry))
|
||||
decoded[key] = merged
|
||||
decoded["full"] = True # a decoded chain is a full state
|
||||
return decoded
|
||||
|
||||
|
||||
def test_the_delta_against_a_delta_prev_reads_the_prev_from_d() -> None:
|
||||
"""A delta frame's entities live under `d` — the next delta must
|
||||
diff against *that* (a prev that is itself a delta)."""
|
||||
state = make_state()
|
||||
full = encode_snapshot(state)
|
||||
moved = state.with_players(
|
||||
(replace(state.players[0], pos=Vec2(4.321, 2.765)),)
|
||||
)
|
||||
delta1 = encode_snapshot(moved, full)
|
||||
moved_again = state.with_players(
|
||||
(replace(state.players[0], pos=Vec2(4.5, 2.765)),)
|
||||
)
|
||||
delta2 = encode_snapshot(moved_again, delta1)
|
||||
(player,) = delta2["d"]["p"]
|
||||
assert player["x"] == 4.5 # the change since delta1, not since the full
|
||||
# `y` is unchanged since delta1, but the whole entry is in (it is a
|
||||
# whole-entry diff): the entry carries both values.
|
||||
assert player["y"] == 2.77
|
||||
@@ -0,0 +1,303 @@
|
||||
"""`sim/ghost.py` — the ghost's state and hands (phase 06 task 05, US-09).
|
||||
|
||||
The wall-clock grace is the room's (measured off `seat.last_seen`); this file
|
||||
pins the sim's half, pure and deterministic:
|
||||
|
||||
* **`mark_ghost`** — the sim-side half of a drop: `connected=False`, the cook
|
||||
in `DISCONNECTED`; idempotent (a second call changes nothing);
|
||||
* **`restore_player`** — the sim-side half of the rejoin inside the grace:
|
||||
`connected=True`, the cook in `IDLE`; a still-carried item is kept;
|
||||
idempotent;
|
||||
* **`release_ghost_carry`** — the park's release: the carried item goes onto
|
||||
the station on the cook's tile (a marked set-down, one `DROPPED`) or into
|
||||
`counters["lost_items"]` (one `GHOST_PARK`) on a bare tile; an item is never
|
||||
deleted silently; idempotent (a double call cannot double-release); a
|
||||
not-yet-marked (connected) cook is switched first — the forced release on
|
||||
a `SeatAbandoned` when the grace ran out before the park deadline;
|
||||
* **the ghost parks in the movement stage** — a disconnected cook's (stale)
|
||||
input does not move it, and a stun that runs out on a disconnected cook
|
||||
revives it in `DISCONNECTED`, not `IDLE`;
|
||||
* **the events encode** — `ghost_on`/`ghost_off`/`ghost_park` ride the `k`
|
||||
names with the short payload keys (`i`/`it`/`fm`), and the unlisted keys
|
||||
never leave the server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.server.encoding import encode_events
|
||||
from undercooked.sim.carry import first_loose_entry
|
||||
from undercooked.sim.ghost import (
|
||||
PARK_AFTER_S,
|
||||
mark_ghost,
|
||||
release_ghost_carry,
|
||||
restore_player,
|
||||
station_at,
|
||||
)
|
||||
from undercooked.sim.grid import Grid
|
||||
from undercooked.sim.model import (
|
||||
Event,
|
||||
EventKind,
|
||||
Facing,
|
||||
Item,
|
||||
ItemForm,
|
||||
ItemKind,
|
||||
KitchenState,
|
||||
Player,
|
||||
PlayerState,
|
||||
Station,
|
||||
StationKind,
|
||||
TileKind,
|
||||
Vec2,
|
||||
)
|
||||
from undercooked.sim.movement import apply_stun, resolve_moves
|
||||
from undercooked.sim.step import PlayerInput
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
WIDTH, HEIGHT = 7, 5
|
||||
#: The cook's cell — the station test's fixture sits on it.
|
||||
CELL_X, CELL_Y = 3, 2
|
||||
|
||||
|
||||
def make_state(
|
||||
stations: tuple[Station, ...] = (),
|
||||
players: tuple[Player, ...] = (),
|
||||
) -> KitchenState:
|
||||
return KitchenState(
|
||||
level_id="03_ghost",
|
||||
seed=7,
|
||||
grid=Grid(width=WIDTH, height=HEIGHT, tiles=(TileKind.FLOOR,) * (WIDTH * HEIGHT)),
|
||||
stations=stations,
|
||||
players=players,
|
||||
target_score=40,
|
||||
time_left=90.0,
|
||||
)
|
||||
|
||||
|
||||
def cook(
|
||||
player_id: str = "p0",
|
||||
*,
|
||||
pos: Vec2 | None = None,
|
||||
carrying: Item | None = None,
|
||||
connected: bool = True,
|
||||
state: PlayerState = PlayerState.IDLE,
|
||||
stun: float = 0.0,
|
||||
seat: int = 0,
|
||||
) -> Player:
|
||||
return Player(
|
||||
id=player_id,
|
||||
pos=pos if pos is not None else Vec2(CELL_X + 0.5, CELL_Y + 0.5),
|
||||
facing=Facing.DOWN,
|
||||
carrying=carrying,
|
||||
connected=connected,
|
||||
state=state,
|
||||
stun=stun,
|
||||
seat=seat,
|
||||
)
|
||||
|
||||
|
||||
def tomato(form: ItemForm = ItemForm.RAW) -> Item:
|
||||
return Item(kind=ItemKind.TOMATO, form=form)
|
||||
|
||||
|
||||
def fixture(station_id: str, kind: StationKind = StationKind.PREP) -> Station:
|
||||
return Station(id=station_id, kind=kind, x=float(CELL_X), y=float(CELL_Y))
|
||||
|
||||
|
||||
# --- the constant ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_park_deadline_is_three_seconds() -> None:
|
||||
assert PARK_AFTER_S == 3.0
|
||||
|
||||
|
||||
# --- mark / restore -------------------------------------------------------------
|
||||
|
||||
|
||||
def test_mark_ghost_switches_the_cook_and_is_idempotent() -> None:
|
||||
state = make_state(players=(cook(),))
|
||||
once = mark_ghost(state, state.players[0])
|
||||
assert once.players[0].connected is False
|
||||
assert once.players[0].state is PlayerState.DISCONNECTED
|
||||
# Idempotent: a second call changes nothing (the event is the room's,
|
||||
# so the sim must not double-apply the switch either).
|
||||
twice = mark_ghost(once, once.players[0])
|
||||
assert twice is once
|
||||
assert twice.players[0].connected is False
|
||||
|
||||
|
||||
def test_mark_ghost_keeps_the_carry() -> None:
|
||||
state = make_state(players=(cook(carrying=tomato()),))
|
||||
marked = mark_ghost(state, state.players[0])
|
||||
assert marked.players[0].carrying is not None # the item waits for the park
|
||||
assert marked.players[0].state is PlayerState.DISCONNECTED
|
||||
|
||||
|
||||
def test_restore_player_brings_the_cook_back_and_keeps_the_carry() -> None:
|
||||
ghost = mark_ghost(make_state(players=(cook(carrying=tomato()),)), cook(carrying=tomato()))
|
||||
player = ghost.players[0]
|
||||
restored = restore_player(ghost, player)
|
||||
assert restored.players[0].connected is True
|
||||
assert restored.players[0].state is PlayerState.IDLE
|
||||
assert restored.players[0].carrying is not None # the rejoin keeps the carry
|
||||
# Idempotent: a second restore changes nothing.
|
||||
again = restore_player(restored, restored.players[0])
|
||||
assert again is restored
|
||||
|
||||
|
||||
# --- the release (the park / the forced release) --------------------------------
|
||||
|
||||
|
||||
def test_station_at_finds_the_station_on_the_tile_and_none_on_bare_floor() -> None:
|
||||
shelf = fixture("prep_0", StationKind.PREP)
|
||||
state = make_state(stations=(shelf,), players=(cook(),))
|
||||
assert station_at(state, state.players[0].pos) is shelf
|
||||
bare = make_state(players=(cook(pos=Vec2(1.5, 1.5)),))
|
||||
assert station_at(bare, Vec2(1.5, 1.5)) is None
|
||||
|
||||
|
||||
def test_release_parks_onto_the_station_with_a_marked_set_down() -> None:
|
||||
shelf = fixture("prep_0", StationKind.PREP)
|
||||
state = make_state(stations=(shelf,), players=(cook(carrying=tomato()),))
|
||||
state = mark_ghost(state, state.players[0])
|
||||
released, events = release_ghost_carry(state, state.players[0])
|
||||
assert released.players[0].carrying is None
|
||||
station = released.stations[0]
|
||||
assert station.contents == (tomato(),)
|
||||
# A marked set-down: grabbable at once (the hand-off window applies — a
|
||||
# released item can be passed).
|
||||
entry = first_loose_entry(released, station)
|
||||
assert entry is not None and entry[0] == tomato()
|
||||
assert [e.kind for e in events] == [EventKind.DROPPED]
|
||||
|
||||
|
||||
def test_release_parks_into_lost_items_on_a_bare_tile() -> None:
|
||||
state = make_state(players=(cook(pos=Vec2(1.5, 1.5), carrying=tomato(ItemForm.CHOPPED)),))
|
||||
state = mark_ghost(state, state.players[0])
|
||||
released, events = release_ghost_carry(state, state.players[0])
|
||||
assert released.players[0].carrying is None
|
||||
assert released.counters["lost_items"] == 1
|
||||
assert len(events) == 1
|
||||
event = events[0]
|
||||
assert event.kind is EventKind.GHOST_PARK
|
||||
assert event.payload == {
|
||||
"player_id": "p0",
|
||||
"item": "tomato",
|
||||
"form": "chopped",
|
||||
}
|
||||
|
||||
|
||||
def test_release_is_idempotent_a_double_call_cannot_double_release() -> None:
|
||||
shelf = fixture("prep_0", StationKind.PREP)
|
||||
state = make_state(stations=(shelf,), players=(cook(carrying=tomato()),))
|
||||
state = mark_ghost(state, state.players[0])
|
||||
released, first = release_ghost_carry(state, state.players[0])
|
||||
assert len(first) == 1
|
||||
# Second call: the hand is empty, nothing to release — no events, no
|
||||
# counter bump, the set-down untouched.
|
||||
again, second = release_ghost_carry(released, released.players[0])
|
||||
assert second == ()
|
||||
assert released.counters.get("lost_items", 0) == 0
|
||||
assert again.stations[0].contents == (tomato(),)
|
||||
# Same for the counter path: the tombstone is counted exactly once.
|
||||
bare = make_state(players=(cook(pos=Vec2(1.5, 1.5), carrying=tomato()),))
|
||||
bare = mark_ghost(bare, bare.players[0])
|
||||
parked, _ = release_ghost_carry(bare, bare.players[0])
|
||||
again_bare, second_bare = release_ghost_carry(parked, parked.players[0])
|
||||
assert second_bare == ()
|
||||
assert again_bare.counters["lost_items"] == 1
|
||||
|
||||
|
||||
def test_release_of_a_connected_cook_switches_and_releases() -> None:
|
||||
"""The forced release on a `SeatAbandoned`: the seat is emptied before
|
||||
the 3 s park deadline, and the item must release at once (the sim
|
||||
switches the ghost first, then releases)."""
|
||||
shelf = fixture("prep_0", StationKind.PREP)
|
||||
state = make_state(stations=(shelf,), players=(cook(carrying=tomato()),))
|
||||
released, events = release_ghost_carry(state, state.players[0])
|
||||
assert released.players[0].connected is False
|
||||
assert released.players[0].state is PlayerState.DISCONNECTED
|
||||
assert released.players[0].carrying is None
|
||||
assert released.stations[0].contents == (tomato(),)
|
||||
assert [e.kind for e in events] == [EventKind.DROPPED]
|
||||
|
||||
|
||||
def test_release_of_an_empty_hand_is_a_silent_noop() -> None:
|
||||
state = make_state(players=(cook(pos=Vec2(1.5, 1.5)),))
|
||||
state = mark_ghost(state, state.players[0])
|
||||
released, events = release_ghost_carry(state, state.players[0])
|
||||
assert events == ()
|
||||
assert "lost_items" not in released.counters
|
||||
|
||||
|
||||
# --- the ghost parks in the movement stage ---------------------------------------
|
||||
|
||||
|
||||
def test_a_stun_that_runs_out_on_a_disconnected_cook_revives_it_disconnected() -> None:
|
||||
players, _ = apply_stun((cook(connected=False, state=PlayerState.STUNNED, stun=0.05),), 0.05)
|
||||
assert players[0].state is PlayerState.DISCONNECTED
|
||||
assert players[0].connected is False
|
||||
# A stun still running keeps the cook pinned (and stunned).
|
||||
running, _ = apply_stun((cook(connected=False, state=PlayerState.STUNNED, stun=0.3),), 0.05)
|
||||
assert running[0].state is PlayerState.STUNNED
|
||||
assert running[0].stun == pytest.approx(0.25)
|
||||
# A stun-zero STUNNED cook revives to `DISCONNECTED` as well, not `IDLE`.
|
||||
zero, _ = apply_stun((cook(connected=False, state=PlayerState.STUNNED, stun=0.0),), 0.05)
|
||||
assert zero[0].state is PlayerState.DISCONNECTED
|
||||
# A connected cook still revives to `IDLE`.
|
||||
alive, _ = apply_stun((cook(state=PlayerState.STUNNED, stun=0.05),), 0.05)
|
||||
assert alive[0].state is PlayerState.IDLE
|
||||
|
||||
|
||||
def test_a_disconnected_cook_parks_stale_input_does_not_move_it() -> None:
|
||||
state = make_state(players=(cook(connected=False, state=PlayerState.DISCONNECTED),))
|
||||
before = state.players[0].pos
|
||||
after, events = resolve_moves(
|
||||
state, 0.05, {"p0": PlayerInput(player_id="p0", move=Vec2(1.0, 0.0))}
|
||||
)
|
||||
assert after[0].pos == before # the ghost does not walk
|
||||
assert after[0].vel.length == 0.0
|
||||
assert after[0].state is PlayerState.DISCONNECTED
|
||||
assert events == ()
|
||||
# A connected cook on the same frame still moves.
|
||||
state2 = make_state(players=(cook(state=PlayerState.IDLE),))
|
||||
after2, _ = resolve_moves(
|
||||
state2, 0.05, {"p0": PlayerInput(player_id="p0", move=Vec2(1.0, 0.0))}
|
||||
)
|
||||
assert after2[0].pos.x > state2.players[0].pos.x
|
||||
assert after2[0].state is PlayerState.MOVING
|
||||
|
||||
|
||||
# --- the events encode -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_the_ghost_events_encode_with_their_k_names_and_short_payloads() -> None:
|
||||
encoded = encode_events(
|
||||
(
|
||||
Event(
|
||||
kind=EventKind.GHOST_ON,
|
||||
x=3.5,
|
||||
y=2.5,
|
||||
payload={"player_id": "p0"},
|
||||
),
|
||||
Event(kind=EventKind.GHOST_OFF, x=3.5, y=2.5, payload={"player_id": "p0"}),
|
||||
Event(
|
||||
kind=EventKind.GHOST_PARK,
|
||||
x=1.5,
|
||||
y=1.5,
|
||||
payload={"player_id": "p0", "item": "tomato", "form": "chopped"},
|
||||
),
|
||||
)
|
||||
)
|
||||
assert [entry["k"] for entry in encoded] == ["ghost_on", "ghost_off", "ghost_park"]
|
||||
assert encoded[0] == {"k": "ghost_on", "x": 3.5, "y": 2.5, "i": "p0"}
|
||||
assert encoded[2] == {
|
||||
"k": "ghost_park",
|
||||
"x": 1.5,
|
||||
"y": 1.5,
|
||||
"i": "p0",
|
||||
"it": "tomato",
|
||||
"fm": "chopped",
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Unit: `docs/PROTOCOL.md` and `protocol.py` cannot drift (phase 06, task 01).
|
||||
|
||||
The phase's completion criteria demand that "every field in code appears in
|
||||
the doc" — and, in the other direction, that the doc never names a field
|
||||
that does not exist. This test is the guard: for **every** message model
|
||||
in both directions (the nine client→server, the eight server→client, and
|
||||
the six wire objects they nest) it extracts the field names from the doc's
|
||||
``### ModelName`` table and compares them with the model's ``model_fields``
|
||||
— set-equal, in both directions. It also keeps the doc's error-code and
|
||||
close-code tables equal to the `ErrorCode` / `CloseCode` constants.
|
||||
|
||||
Doc convention (what this test relies on): every ``###`` heading in
|
||||
``docs/PROTOCOL.md`` is exactly one model section (no other ``###``
|
||||
headings — use ``##`` for prose sections), and each section holds one
|
||||
markdown table whose first column is the field name. So: an added field
|
||||
that is not documented fails here, a documented field that is not in the
|
||||
model fails here, a renamed model fails here, and a new error code without
|
||||
a doc row fails here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from undercooked.server.protocol import (
|
||||
CloseCode,
|
||||
CountdownMsg,
|
||||
EmoteMsg,
|
||||
ErrMsg,
|
||||
ErrorCode,
|
||||
EventWire,
|
||||
EvMsg,
|
||||
HelloMsg,
|
||||
InputMsg,
|
||||
LobbyMsg,
|
||||
NextLevelMsg,
|
||||
PingMsg,
|
||||
PlayerScoreWire,
|
||||
PongMsg,
|
||||
ReadyMsg,
|
||||
RoundMsg,
|
||||
SeatWire,
|
||||
SetLevelMsg,
|
||||
SnapMsg,
|
||||
SpectatorWire,
|
||||
StartMsg,
|
||||
ToLobbyMsg,
|
||||
Vec2Msg,
|
||||
WelcomeMsg,
|
||||
YouWire,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
DOCS = Path(__file__).resolve().parents[2] / "docs" / "PROTOCOL.md"
|
||||
|
||||
#: Every message model in both directions, plus the wire objects they nest.
|
||||
C_TO_S_MODELS: dict[str, type[BaseModel]] = {
|
||||
"HelloMsg": HelloMsg,
|
||||
"ReadyMsg": ReadyMsg,
|
||||
"SetLevelMsg": SetLevelMsg,
|
||||
"StartMsg": StartMsg,
|
||||
"InputMsg": InputMsg,
|
||||
"EmoteMsg": EmoteMsg,
|
||||
"ToLobbyMsg": ToLobbyMsg,
|
||||
"NextLevelMsg": NextLevelMsg,
|
||||
"PingMsg": PingMsg,
|
||||
}
|
||||
S_TO_C_MODELS: dict[str, type[BaseModel]] = {
|
||||
"WelcomeMsg": WelcomeMsg,
|
||||
"LobbyMsg": LobbyMsg,
|
||||
"CountdownMsg": CountdownMsg,
|
||||
"SnapMsg": SnapMsg,
|
||||
"RoundMsg": RoundMsg,
|
||||
"EvMsg": EvMsg,
|
||||
"ErrMsg": ErrMsg,
|
||||
"PongMsg": PongMsg,
|
||||
}
|
||||
WIRE_MODELS: dict[str, type[BaseModel]] = {
|
||||
"Vec2Msg": Vec2Msg,
|
||||
"YouWire": YouWire,
|
||||
"SeatWire": SeatWire,
|
||||
"SpectatorWire": SpectatorWire,
|
||||
"EventWire": EventWire,
|
||||
"PlayerScoreWire": PlayerScoreWire,
|
||||
}
|
||||
ALL_MODELS: dict[str, type[BaseModel]] = {
|
||||
**C_TO_S_MODELS,
|
||||
**S_TO_C_MODELS,
|
||||
**WIRE_MODELS,
|
||||
}
|
||||
|
||||
SECTION_HEADING = re.compile(r"^###\s+(\S+)\s*$")
|
||||
|
||||
|
||||
def _clean(cell: str) -> str:
|
||||
return cell.strip().strip("`").strip()
|
||||
|
||||
|
||||
def extract_model_sections(text: str) -> dict[str, list[str]]:
|
||||
"""Field names (first table column) per ``###`` section.
|
||||
|
||||
A section runs from its ``###`` heading to the next heading of any
|
||||
level; inside it, only table rows count, and the header row (first cell
|
||||
``Field``) and the separator row (dashes/colons) are skipped.
|
||||
"""
|
||||
sections: dict[str, list[str]] = {}
|
||||
current: str | None = None
|
||||
for line in text.splitlines():
|
||||
heading = SECTION_HEADING.match(line)
|
||||
if heading is not None:
|
||||
name = heading.group(1)
|
||||
sections.setdefault(name, [])
|
||||
current = name
|
||||
continue
|
||||
if line.lstrip().startswith("#"):
|
||||
current = None # any other heading ends the section
|
||||
continue
|
||||
if current is None:
|
||||
continue
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("|"):
|
||||
continue
|
||||
cells = [_clean(cell) for cell in stripped.strip("|").split("|")]
|
||||
if not cells or cells[0] in {"Field", ""}:
|
||||
continue
|
||||
if set(cells[0]) <= {"-", ":"}:
|
||||
continue
|
||||
sections[current].append(cells[0])
|
||||
return sections
|
||||
|
||||
|
||||
def extract_section_first_cells(text: str, heading: str) -> list[str]:
|
||||
"""First table column of one ``##`` section (the code tables)."""
|
||||
lines = text.splitlines()
|
||||
try:
|
||||
start = next(i for i, line in enumerate(lines) if line.strip() == heading)
|
||||
except StopIteration:
|
||||
pytest.fail(f"{heading!r} section not found in docs/PROTOCOL.md")
|
||||
cells: list[str] = []
|
||||
for line in lines[start + 1 :]:
|
||||
if line.lstrip().startswith("#"):
|
||||
break
|
||||
stripped = line.strip()
|
||||
if not stripped.startswith("|"):
|
||||
continue
|
||||
first = _clean(stripped.strip("|").split("|")[0])
|
||||
if first in {"Code", ""} or set(first) <= {"-", ":"}:
|
||||
continue
|
||||
cells.append(first)
|
||||
return cells
|
||||
|
||||
|
||||
def test_the_doc_exists() -> None:
|
||||
assert DOCS.is_file(), "docs/PROTOCOL.md is the phase deliverable — it must exist"
|
||||
|
||||
|
||||
def test_documented_sections_are_exactly_the_model_registry() -> None:
|
||||
sections = extract_model_sections(DOCS.read_text(encoding="utf-8"))
|
||||
missing = set(ALL_MODELS) - set(sections)
|
||||
extra = set(sections) - set(ALL_MODELS)
|
||||
assert not missing, f"models with no ### section in the doc: {sorted(missing)}"
|
||||
assert not extra, (
|
||||
f"### sections that are not model sections (doc convention): {sorted(extra)}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", sorted(ALL_MODELS))
|
||||
def test_the_doc_fields_match_the_model_fields_in_both_directions(name: str) -> None:
|
||||
model = ALL_MODELS[name]
|
||||
sections = extract_model_sections(DOCS.read_text(encoding="utf-8"))
|
||||
assert name in sections, f"no ### {name} section in docs/PROTOCOL.md"
|
||||
documented = set(sections[name])
|
||||
implemented = set(model.model_fields)
|
||||
undocumented = implemented - documented
|
||||
ghost = documented - implemented
|
||||
assert not undocumented, (
|
||||
f"{name}: fields in the model but missing from the doc: {sorted(undocumented)}"
|
||||
)
|
||||
assert not ghost, (
|
||||
f"{name}: fields documented but not in the model: {sorted(ghost)}"
|
||||
)
|
||||
|
||||
|
||||
def test_both_directions_are_covered_by_the_registry() -> None:
|
||||
assert len(C_TO_S_MODELS) == 9
|
||||
assert len(S_TO_C_MODELS) == 8
|
||||
assert len(WIRE_MODELS) == 6
|
||||
assert set(ALL_MODELS) == set(C_TO_S_MODELS) | set(S_TO_C_MODELS) | set(WIRE_MODELS)
|
||||
|
||||
|
||||
def test_the_error_code_table_matches_the_constants() -> None:
|
||||
cells = extract_section_first_cells(DOCS.read_text(encoding="utf-8"), "## Error codes")
|
||||
assert len(cells) == len(set(cells)), "duplicate rows in the error-code table"
|
||||
documented = set(cells)
|
||||
implemented = {
|
||||
ErrorCode.UNKNOWN_TYPE,
|
||||
ErrorCode.BAD_JSON,
|
||||
ErrorCode.BAD_FIELD,
|
||||
ErrorCode.TOO_BIG,
|
||||
ErrorCode.RATE_LIMITED,
|
||||
ErrorCode.NOT_SEATED,
|
||||
ErrorCode.NOT_HOST,
|
||||
ErrorCode.LEVEL_LOCKED,
|
||||
ErrorCode.WRONG_STATE,
|
||||
ErrorCode.ROOM_FULL,
|
||||
ErrorCode.ROOM_GONE,
|
||||
ErrorCode.PROTOCOL_MISMATCH,
|
||||
ErrorCode.EVICTED,
|
||||
ErrorCode.INTERNAL,
|
||||
}
|
||||
assert documented == implemented, (
|
||||
f"error-code table drift: missing {sorted(implemented - documented)}, "
|
||||
f"ghosts {sorted(documented - implemented)}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_close_code_table_matches_the_constants() -> None:
|
||||
cells = extract_section_first_cells(DOCS.read_text(encoding="utf-8"), "## Close codes")
|
||||
documented = {int(cell) for cell in cells}
|
||||
implemented = {
|
||||
CloseCode.NORMAL,
|
||||
CloseCode.PROTOCOL_MISMATCH,
|
||||
CloseCode.ROOM_GONE,
|
||||
CloseCode.ROOM_FULL,
|
||||
CloseCode.EVICTED,
|
||||
CloseCode.ABUSE,
|
||||
}
|
||||
assert documented == implemented, (
|
||||
f"close-code table drift: missing {sorted(implemented - documented)}, "
|
||||
f"ghosts {sorted(documented - implemented)}"
|
||||
)
|
||||
|
||||
|
||||
def test_the_whole_contract_round_trips_through_the_models() -> None:
|
||||
"""Every model here is a real Pydantic model with a `type` discriminator
|
||||
(C→S) or a defaulted one (S→C) — a smoke over the registry itself."""
|
||||
for name, model in ALL_MODELS.items():
|
||||
assert isinstance(model, type)
|
||||
assert issubclass(model, BaseModel)
|
||||
fields = set(model.model_fields)
|
||||
if name in C_TO_S_MODELS:
|
||||
assert "type" in fields, f"{name} has no discriminator"
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Unit: the wire contract's accept/reject surface (phase 06, task 01).
|
||||
|
||||
`parse_client_message` is the whole *shape* check — JSON decode,
|
||||
discriminator dispatch, per-model field validation, the `hello` `p` gate —
|
||||
and it must never raise: every bad frame comes back as a `ProtocolError`
|
||||
with a stable `code`. This table pins the whole surface: the nine message
|
||||
types parse (including the boundary `move` magnitudes), and every documented
|
||||
bad shape yields exactly its documented `code` (`bad_json`, `unknown_type`,
|
||||
`bad_field`, `protocol_mismatch`).
|
||||
|
||||
Also pinned here: the `ErrorCode` / `CloseCode` constant sets (a new code
|
||||
must be documented before it exists), `ProtocolError.to_err`, and the
|
||||
server→client models' wire shape (every model encodes with its exact
|
||||
`model_fields` and the right `type` / `p`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from undercooked.server.protocol import (
|
||||
MIN_SUPPORTED_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
ClientMessage,
|
||||
CloseCode,
|
||||
CountdownMsg,
|
||||
EmoteMsg,
|
||||
ErrMsg,
|
||||
ErrorCode,
|
||||
EventWire,
|
||||
EvMsg,
|
||||
HelloMsg,
|
||||
InputMsg,
|
||||
LobbyMsg,
|
||||
NextLevelMsg,
|
||||
PingMsg,
|
||||
PlayerScoreWire,
|
||||
PongMsg,
|
||||
ProtocolError,
|
||||
ReadyMsg,
|
||||
RoundMsg,
|
||||
SeatWire,
|
||||
SetLevelMsg,
|
||||
SnapMsg,
|
||||
SpectatorWire,
|
||||
StartMsg,
|
||||
ToLobbyMsg,
|
||||
Vec2Msg,
|
||||
WelcomeMsg,
|
||||
YouWire,
|
||||
parse_client_message,
|
||||
wire_dict,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
#: A fixture session id (named to keep `S105`/`S106` quiet).
|
||||
SESSION_ID = "tok-1"
|
||||
|
||||
|
||||
def _error_of(result: ClientMessage | ProtocolError) -> ProtocolError | None:
|
||||
return result if isinstance(result, ProtocolError) else None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- accept
|
||||
|
||||
|
||||
ACCEPT_CASES: list[tuple[bytes, type[BaseModel]]] = [
|
||||
(b'{"type":"hello","p":1}', HelloMsg),
|
||||
(b'{"type":"hello","p":1,"token":"tok-1234","name":"Ada"}', HelloMsg),
|
||||
(b'{"type":"ready"}', ReadyMsg),
|
||||
(b'{"type":"set_level","level":"01_first_kitchen"}', SetLevelMsg),
|
||||
(b'{"type":"start"}', StartMsg),
|
||||
(b'{"type":"input","seq":0,"move":{"x":0.0,"y":0.0},"act":false,"face":null}', InputMsg),
|
||||
(b'{"type":"input","seq":7,"move":{"x":0.6,"y":0.8},"act":true,"face":null}', InputMsg),
|
||||
(b'{"type":"input","seq":8,"move":{"x":-1.0,"y":0.0},"act":false,"face":"up"}', InputMsg),
|
||||
(
|
||||
b'{"type":"input","seq":9,"move":{"x":0.7,"y":0.7},"act":false,'
|
||||
b'"face":"right"}',
|
||||
InputMsg,
|
||||
),
|
||||
(b'{"type":"emote","id":"wave"}', EmoteMsg),
|
||||
(b'{"type":"to_lobby"}', ToLobbyMsg),
|
||||
(b'{"type":"next_level"}', NextLevelMsg),
|
||||
(b'{"type":"ping","t":0}', PingMsg),
|
||||
(b'{"type":"ping","t":1234567}', PingMsg),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("raw", "expected"), ACCEPT_CASES)
|
||||
def test_every_message_type_parses(raw: bytes, expected: type[BaseModel]) -> None:
|
||||
result = parse_client_message(raw)
|
||||
assert not isinstance(result, ProtocolError)
|
||||
assert type(result) is expected
|
||||
|
||||
|
||||
def test_anonymous_hello_parses_and_carries_no_token() -> None:
|
||||
result = parse_client_message(b'{"type":"hello","p":1}')
|
||||
assert isinstance(result, HelloMsg)
|
||||
assert result.token is None
|
||||
assert result.name is None
|
||||
|
||||
|
||||
def test_a_boundary_move_magnitude_is_accepted() -> None:
|
||||
# hypot(0.6, 0.8) is 1.0 exactly: the boundary is inclusive.
|
||||
result = parse_client_message(
|
||||
b'{"type":"input","seq":1,"move":{"x":0.6,"y":0.8},"act":false,"face":null}'
|
||||
)
|
||||
assert isinstance(result, InputMsg)
|
||||
assert isinstance(result.move, Vec2Msg)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- reject
|
||||
|
||||
|
||||
REJECT_CASES: list[tuple[bytes, str]] = [
|
||||
# bad_json — not JSON at all.
|
||||
(b"{", "bad_json"),
|
||||
(b"", "bad_json"),
|
||||
(b"[1, 2]", "bad_field"),
|
||||
(b'"just a string"', "bad_field"),
|
||||
(b"123.5", "bad_field"),
|
||||
# bad_field — valid JSON, wrong shape.
|
||||
(b"null", "bad_field"),
|
||||
(b"{}", "bad_field"),
|
||||
(b'{"type":5}', "bad_field"),
|
||||
(b'{"type":"hello"}', "bad_field"), # p missing
|
||||
(b'{"type":"hello","p":"1"}', "bad_field"), # p is a string
|
||||
(b'{"type":"hello","p":3.5}', "bad_field"), # p is not an int
|
||||
(b'{"type":"hello","p":1,"token":""}', "bad_field"), # token too short
|
||||
(b'{"type":"hello","p":1,"token":"' + b"a" * 129 + b'"}', "bad_field"),
|
||||
(b'{"type":"hello","p":1,"name":"' + b"x" * 65 + b'"}', "bad_field"),
|
||||
(b'{"type":"input","seq":-1,"move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"),
|
||||
(b'{"type":"input","seq":3.5,"move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"),
|
||||
(b'{"type":"input","seq":"3","move":{"x":0,"y":0},"act":false,"face":null}', "bad_field"),
|
||||
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":"true","face":null}', "bad_field"),
|
||||
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":1,"face":null}', "bad_field"),
|
||||
(b'{"type":"input","seq":3,"move":{"x":50,"y":0},"act":false,"face":null}', "bad_field"),
|
||||
(b'{"type":"input","seq":3,"move":{"x":1.01,"y":0},"act":false,"face":null}', "bad_field"),
|
||||
(b'{"type":"input","seq":3,"move":{"x":1,"y":1},"act":false,"face":null}', "bad_field"),
|
||||
(b'{"type":"input","seq":3,"move":{"x":"1","y":0},"act":false,"face":null}', "bad_field"),
|
||||
(b'{"type":"input","seq":3,"move":{"x":NaN,"y":0},"act":false,"face":null}', "bad_field"),
|
||||
(b'{"type":"input","seq":3,"move":{"x":0,"y":Infinity},"act":false,"face":null}', "bad_field"),
|
||||
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,"face":"north"}', "bad_field"),
|
||||
(b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false}', "bad_field"), # face missing
|
||||
(
|
||||
b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,'
|
||||
b'"face":null,"extra":1}',
|
||||
"bad_field",
|
||||
),
|
||||
(
|
||||
b'{"type":"input","seq":3,"move":{"x":0,"y":0},"act":false,'
|
||||
b'"face":null,"pos":{"x":9}}',
|
||||
"bad_field",
|
||||
),
|
||||
(b'{"type":"ping","t":-1}', "bad_field"),
|
||||
(b'{"type":"ping","t":"1"}', "bad_field"),
|
||||
(b'{"type":"emote","id":""}', "bad_field"),
|
||||
(b'{"type":"set_level","level":""}', "bad_field"),
|
||||
(b'{"type":"set_level"}', "bad_field"),
|
||||
# unknown_type — a `type` the server does not know.
|
||||
(b'{"type":"bogus"}', "unknown_type"),
|
||||
(b'{"type":"HELLO"}', "unknown_type"), # discriminators are case-sensitive
|
||||
(b'{"type":"position","x":1}', "unknown_type"),
|
||||
# protocol_mismatch — `p` outside [MIN_SUPPORTED_VERSION, PROTOCOL_VERSION].
|
||||
(b'{"type":"hello","p":0}', "protocol_mismatch"),
|
||||
(b'{"type":"hello","p":2}', "protocol_mismatch"),
|
||||
(b'{"type":"hello","p":99}', "protocol_mismatch"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("raw", "expected_code"), REJECT_CASES)
|
||||
def test_bad_frames_yield_their_documented_code(raw: bytes, expected_code: str) -> None:
|
||||
result = parse_client_message(raw)
|
||||
error = _error_of(result)
|
||||
assert error is not None, f"{raw!r} parsed into a message, expected {expected_code}"
|
||||
assert error.code == expected_code
|
||||
|
||||
|
||||
def test_parse_never_raises_over_the_whole_table() -> None:
|
||||
for raw, _ in ACCEPT_CASES + REJECT_CASES:
|
||||
try:
|
||||
parse_client_message(raw)
|
||||
except Exception as exc:
|
||||
pytest.fail(f"parse_client_message raised {exc!r} for {raw!r}")
|
||||
|
||||
|
||||
def test_parse_of_a_valid_ping_is_not_an_error() -> None:
|
||||
result = parse_client_message(b'{"type":"ping","t":1}')
|
||||
assert not isinstance(result, ProtocolError)
|
||||
|
||||
|
||||
def test_the_task_criterion_truncated_frame_is_bad_json() -> None:
|
||||
error = _error_of(parse_client_message(b"{"))
|
||||
assert error is not None
|
||||
assert error.code == "bad_json"
|
||||
|
||||
|
||||
def test_unknown_type_error_names_the_offending_type() -> None:
|
||||
error = _error_of(parse_client_message(b'{"type":"teleport"}'))
|
||||
assert error is not None
|
||||
assert "teleport" in error.detail
|
||||
|
||||
|
||||
# ------------------------------------------------------------- error model
|
||||
|
||||
|
||||
def test_protocol_error_to_err_builds_the_wire_frame() -> None:
|
||||
error = ProtocolError(code="bad_field", detail="x: input should be a valid number")
|
||||
err = error.to_err()
|
||||
assert err.type == "err"
|
||||
assert err.code == "bad_field"
|
||||
assert err.msg == "x: input should be a valid number"
|
||||
assert err.seq is None
|
||||
with_seq = error.to_err(seq=42)
|
||||
assert with_seq.seq == 42
|
||||
|
||||
|
||||
def test_protocol_error_without_detail_falls_back_to_the_code() -> None:
|
||||
err = ProtocolError(code="wrong_state").to_err()
|
||||
assert err.msg == "wrong_state"
|
||||
|
||||
|
||||
def test_the_error_code_set_is_pinned() -> None:
|
||||
expected = {
|
||||
"unknown_type",
|
||||
"bad_json",
|
||||
"bad_field",
|
||||
"too_big",
|
||||
"rate_limited",
|
||||
"not_seated",
|
||||
"not_host",
|
||||
"level_locked",
|
||||
"wrong_state",
|
||||
"room_full",
|
||||
"room_gone",
|
||||
"protocol_mismatch",
|
||||
"evicted",
|
||||
"internal",
|
||||
}
|
||||
codes = {
|
||||
ErrorCode.UNKNOWN_TYPE,
|
||||
ErrorCode.BAD_JSON,
|
||||
ErrorCode.BAD_FIELD,
|
||||
ErrorCode.TOO_BIG,
|
||||
ErrorCode.RATE_LIMITED,
|
||||
ErrorCode.NOT_SEATED,
|
||||
ErrorCode.NOT_HOST,
|
||||
ErrorCode.LEVEL_LOCKED,
|
||||
ErrorCode.WRONG_STATE,
|
||||
ErrorCode.ROOM_FULL,
|
||||
ErrorCode.ROOM_GONE,
|
||||
ErrorCode.PROTOCOL_MISMATCH,
|
||||
ErrorCode.EVICTED,
|
||||
ErrorCode.INTERNAL,
|
||||
}
|
||||
assert codes == expected
|
||||
|
||||
|
||||
def test_the_close_code_set_is_pinned() -> None:
|
||||
assert CloseCode.NORMAL == 1000
|
||||
assert CloseCode.PROTOCOL_MISMATCH == 4400
|
||||
assert CloseCode.ROOM_GONE == 4401
|
||||
assert CloseCode.ROOM_FULL == 4402
|
||||
assert CloseCode.EVICTED == 4403
|
||||
assert CloseCode.ABUSE == 4404
|
||||
|
||||
|
||||
# ------------------------------------------------------------ server → client
|
||||
|
||||
|
||||
def test_the_protocol_version_constants() -> None:
|
||||
assert PROTOCOL_VERSION == 1
|
||||
assert MIN_SUPPORTED_VERSION == 1
|
||||
|
||||
|
||||
def test_welcome_encodes_its_exact_fields() -> None:
|
||||
seats = [
|
||||
SeatWire(index=0, name="Ada", color="#e5484d", ready=True, host=True, connected=True)
|
||||
]
|
||||
msg = WelcomeMsg(
|
||||
you=YouWire(token=SESSION_ID, seat=0, host=True),
|
||||
room="ABCDE",
|
||||
state="lobby",
|
||||
level="01_first_kitchen",
|
||||
seats=seats,
|
||||
)
|
||||
dumped = wire_dict(msg)
|
||||
assert set(dumped) == {
|
||||
"type",
|
||||
"p",
|
||||
"you",
|
||||
"room",
|
||||
"state",
|
||||
"level",
|
||||
"seats",
|
||||
"rejoined",
|
||||
}
|
||||
assert dumped["type"] == "welcome"
|
||||
assert dumped["p"] == PROTOCOL_VERSION
|
||||
assert dumped["you"] == {"token": SESSION_ID, "seat": 0, "host": True}
|
||||
# A fresh join (the default) is not a rejoin (task 05: `rejoined`
|
||||
# is `true` only when a `hello` reclaims a disconnected seat).
|
||||
assert dumped["rejoined"] is False
|
||||
|
||||
|
||||
def test_lobby_encodes_its_exact_fields() -> None:
|
||||
msg = LobbyMsg(
|
||||
code="ABCDE",
|
||||
state="lobby",
|
||||
level=None,
|
||||
host_seat=0,
|
||||
can_start=False,
|
||||
seats=[],
|
||||
spectators=[SpectatorWire(name="Mallory", color="#3b9eff")],
|
||||
unlocked=1,
|
||||
round_no=0,
|
||||
)
|
||||
dumped = wire_dict(msg)
|
||||
assert set(dumped) == {
|
||||
"type", "p", "code", "state", "level", "host_seat", "can_start",
|
||||
"seats", "spectators", "unlocked", "round_no",
|
||||
}
|
||||
assert dumped["type"] == "lobby"
|
||||
|
||||
|
||||
def test_countdown_snap_round_ev_pong_encode_their_fields() -> None:
|
||||
countdown = wire_dict(CountdownMsg(n=3))
|
||||
assert set(countdown) == {"type", "p", "n"} and countdown["n"] == 3
|
||||
|
||||
snap = wire_dict(SnapMsg(raw={"t": 4821, "p": []}))
|
||||
assert set(snap) == {"type", "p", "raw"}
|
||||
raw_body = snap["raw"]
|
||||
assert isinstance(raw_body, dict) and raw_body["t"] == 4821
|
||||
|
||||
round_ = wire_dict(
|
||||
RoundMsg(
|
||||
result="won",
|
||||
score=140,
|
||||
target=140,
|
||||
stars=3,
|
||||
time_left=60.0,
|
||||
counters={"served": 5, "missed": 0, "wasted": 0},
|
||||
longest_streak=5,
|
||||
players=[
|
||||
PlayerScoreWire(
|
||||
id="tok-1", name="Ada", score=140, dishes_served=5, missed_contrib=0
|
||||
)
|
||||
],
|
||||
level_id="01_first_kitchen",
|
||||
seed=1234,
|
||||
ticks=1200,
|
||||
unlocked="02_double_shift",
|
||||
)
|
||||
)
|
||||
assert set(round_) == {
|
||||
"type", "p", "result", "score", "target", "stars", "time_left", "counters",
|
||||
"longest_streak", "players", "level_id", "seed", "ticks", "unlocked",
|
||||
}
|
||||
|
||||
ev = wire_dict(EvMsg(events=[EventWire(kind="served", x=9.0, y=1.0, payload={"i": 17})]))
|
||||
assert set(ev) == {"type", "p", "events"}
|
||||
assert ev["events"] == [{"kind": "served", "x": 9.0, "y": 1.0, "payload": {"i": 17}}]
|
||||
|
||||
pong = wire_dict(PongMsg(t=42, now=12.5))
|
||||
assert set(pong) == {"type", "p", "t", "now"} and pong["t"] == 42
|
||||
|
||||
|
||||
def test_err_msg_encodes_with_an_optional_seq() -> None:
|
||||
dumped = wire_dict(ErrMsg(code="bad_field", msg="no", seq=7))
|
||||
assert set(dumped) == {"type", "p", "code", "msg", "seq"} and dumped["seq"] == 7
|
||||
anonymous = wire_dict(ErrMsg(code="wrong_state", msg="no"))
|
||||
assert anonymous["seq"] is None
|
||||
|
||||
|
||||
def test_event_wire_payload_defaults_to_empty() -> None:
|
||||
event = EventWire(kind="chopped", x=3.0, y=2.0)
|
||||
assert event.payload == {}
|
||||
assert wire_dict(event) == {"kind": "chopped", "x": 3.0, "y": 2.0, "payload": {}}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Unit: `MESSAGE_WHITELIST` — totality and the pinned matrix (phase 06, task 01).
|
||||
|
||||
The whitelist is the single source of "what is legal when" (PLAN §7
|
||||
"Accepted when"), imported by the endpoint and never duplicated. These tests
|
||||
pin the whole matrix (a silent edit changes the wire contract) and assert
|
||||
its *totality*: every room state is classified, every classification is a
|
||||
known client type, and every client type is legal in at least one state —
|
||||
so a new `ClientMsgType` that is never whitelisted (or a state that is
|
||||
never classified) fails loudly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.server.protocol import CLIENT_MSG_TYPES, MESSAGE_WHITELIST
|
||||
from undercooked.server.room import RoomState
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
#: The pinned matrix — PLAN §7 "Accepted when", one row per room state.
|
||||
EXPECTED_MATRIX: dict[RoomState, frozenset[str]] = {
|
||||
RoomState.LOBBY: frozenset({"hello", "ready", "set_level", "start", "emote", "ping"}),
|
||||
RoomState.COUNTDOWN: frozenset({"hello", "ping"}),
|
||||
RoomState.PLAYING: frozenset({"hello", "input", "emote", "ping"}),
|
||||
RoomState.ROUND_END: frozenset({"hello", "to_lobby", "next_level", "ping"}),
|
||||
RoomState.CLOSED: frozenset({"hello", "ping"}),
|
||||
}
|
||||
|
||||
|
||||
def test_the_matrix_is_pinned() -> None:
|
||||
actual = {state: frozenset(types) for state, types in MESSAGE_WHITELIST.items()}
|
||||
assert actual == EXPECTED_MATRIX
|
||||
|
||||
|
||||
def test_totality_every_room_state_is_classified() -> None:
|
||||
assert set(MESSAGE_WHITELIST) == set(RoomState)
|
||||
|
||||
|
||||
def test_totality_every_whitelisted_type_is_known() -> None:
|
||||
for state, allowed in MESSAGE_WHITELIST.items():
|
||||
unknown = allowed - CLIENT_MSG_TYPES
|
||||
assert not unknown, f"{state.value}: unknown types {sorted(unknown)}"
|
||||
|
||||
|
||||
def test_totality_every_client_type_is_legal_somewhere() -> None:
|
||||
union: set[str] = set()
|
||||
for allowed in MESSAGE_WHITELIST.values():
|
||||
union |= set(allowed)
|
||||
never = CLIENT_MSG_TYPES - union
|
||||
assert not never, f"client types legal in no state at all: {sorted(never)}"
|
||||
|
||||
|
||||
def test_hello_and_ping_are_legal_in_every_state() -> None:
|
||||
for state, allowed in MESSAGE_WHITELIST.items():
|
||||
assert {"hello", "ping"} <= allowed, (
|
||||
f"{state.value}: hello (the rejoin handshake) and ping (the "
|
||||
f"keep-alive) must be legal in every state"
|
||||
)
|
||||
|
||||
|
||||
def test_input_is_playing_only() -> None:
|
||||
assert "input" in MESSAGE_WHITELIST[RoomState.PLAYING]
|
||||
for state in (RoomState.LOBBY, RoomState.COUNTDOWN, RoomState.ROUND_END, RoomState.CLOSED):
|
||||
assert "input" not in MESSAGE_WHITELIST[state]
|
||||
|
||||
|
||||
def test_the_lobby_verbs_are_lobby_only() -> None:
|
||||
for verb in ("ready", "set_level", "start"):
|
||||
assert verb in MESSAGE_WHITELIST[RoomState.LOBBY]
|
||||
for state in (RoomState.COUNTDOWN, RoomState.PLAYING, RoomState.ROUND_END, RoomState.CLOSED):
|
||||
for verb in ("ready", "set_level", "start"):
|
||||
assert verb not in MESSAGE_WHITELIST[state]
|
||||
|
||||
|
||||
def test_emote_is_lobby_and_playing_only() -> None:
|
||||
assert "emote" in MESSAGE_WHITELIST[RoomState.LOBBY]
|
||||
assert "emote" in MESSAGE_WHITELIST[RoomState.PLAYING]
|
||||
for state in (RoomState.COUNTDOWN, RoomState.ROUND_END, RoomState.CLOSED):
|
||||
assert "emote" not in MESSAGE_WHITELIST[state]
|
||||
|
||||
|
||||
def test_the_round_end_verbs_are_round_end_only() -> None:
|
||||
for verb in ("to_lobby", "next_level"):
|
||||
assert verb in MESSAGE_WHITELIST[RoomState.ROUND_END]
|
||||
for state in (RoomState.LOBBY, RoomState.COUNTDOWN, RoomState.PLAYING, RoomState.CLOSED):
|
||||
for verb in ("to_lobby", "next_level"):
|
||||
assert verb not in MESSAGE_WHITELIST[state]
|
||||
|
||||
|
||||
def test_countdown_accepts_no_gameplay_verb() -> None:
|
||||
# The 3 s countdown is a wall: only the handshake and the keep-alive get
|
||||
# through (a late `ready` or `input` is `wrong_state`).
|
||||
assert MESSAGE_WHITELIST[RoomState.COUNTDOWN] == {"hello", "ping"}
|
||||
@@ -0,0 +1,344 @@
|
||||
"""Unit: the send-queue policy and the per-connection guardrail arithmetic
|
||||
(phase 06 task 02) — the `Connection`'s queue is the only buffer between
|
||||
the game loop and a slow reader, so it must be bounded and honest:
|
||||
|
||||
* **snapshot droppability under a slow reader** — once the queue is at
|
||||
`MAX_SEND_QUEUE` a new frame may only enter after the *oldest snapshot*
|
||||
is dropped; `lobby` / `round` / `err` frames are never dropped and keep
|
||||
their order,
|
||||
* **the one-`rate_limited`-err bound** — a flood queues at most one
|
||||
`rate_limited` `err` (the client is told once; the queue cannot grow
|
||||
with the flood),
|
||||
* **no unbounded growth** — the queue caps at `MAX_SEND_QUEUE` for the
|
||||
dominant (snapshot) traffic; only the never-dropped frames can push it
|
||||
past that, and only by their own count (game-bounded: one
|
||||
`lobby` / `round` per state change, one coalesced `rate_limited`),
|
||||
* **the token bucket** — `rate` tokens per second, a burst of `burst`,
|
||||
refilled strictly off the injected `Clock` (never a wall clock),
|
||||
* **the abuse tracker** — 3 *consecutive* rate/oversize violations inside
|
||||
the 10 s window close `4404` (a clean frame resets the run); 5 shape
|
||||
violations inside the window close it (a violation older than the
|
||||
window does not count).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.server.clock import FakeClock
|
||||
from undercooked.server.protocol import (
|
||||
ErrMsg,
|
||||
ErrorCode,
|
||||
LobbyMsg,
|
||||
RoundMsg,
|
||||
SnapMsg,
|
||||
)
|
||||
from undercooked.server.ws import (
|
||||
ABUSE_WINDOW_S,
|
||||
MAX_SEND_QUEUE,
|
||||
AbuseTracker,
|
||||
Connection,
|
||||
TokenBucket,
|
||||
pump_out,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
class _FakeWs:
|
||||
"""A `WsLike` that records what the pump wrote (no socket)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[dict[str, Any]] = []
|
||||
|
||||
async def send_text(self, data: str) -> None:
|
||||
self.sent.append(json.loads(data))
|
||||
|
||||
|
||||
def _conn(clock: FakeClock) -> tuple[Connection, _FakeWs]:
|
||||
ws = _FakeWs()
|
||||
return (
|
||||
Connection(
|
||||
ws=ws,
|
||||
token="tok-unit", # noqa: S106 - a test identity, not a credential
|
||||
player_id="tok-unit",
|
||||
seat_index=0,
|
||||
role="player",
|
||||
rate=TokenBucket(10.0, 10, clock),
|
||||
),
|
||||
ws,
|
||||
)
|
||||
|
||||
|
||||
def _snap(index: int) -> SnapMsg:
|
||||
return SnapMsg(type="snap", raw={"t": index, "p": []})
|
||||
|
||||
|
||||
def _lobby(index: int) -> LobbyMsg:
|
||||
return LobbyMsg(
|
||||
type="lobby",
|
||||
code=f"C{index}",
|
||||
state="lobby",
|
||||
can_start=False,
|
||||
seats=[],
|
||||
spectators=[],
|
||||
)
|
||||
|
||||
|
||||
def _round() -> RoundMsg:
|
||||
return RoundMsg(
|
||||
type="round",
|
||||
result="won",
|
||||
score=1,
|
||||
target=1,
|
||||
stars=0,
|
||||
time_left=0.0,
|
||||
counters={},
|
||||
longest_streak=0,
|
||||
players=[],
|
||||
level_id="x",
|
||||
seed=1,
|
||||
ticks=0,
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ drop policy
|
||||
|
||||
|
||||
async def test_slow_reader_drops_oldest_snapshots() -> None:
|
||||
clock = FakeClock()
|
||||
conn, _ws = _conn(clock)
|
||||
# A slow reader: nothing is pumped yet, so the queue fills.
|
||||
for index in range(3):
|
||||
conn.enqueue(_lobby(index)) # non-droppable
|
||||
for index in range(20):
|
||||
conn.enqueue(_snap(index)) # droppable
|
||||
assert conn.send_queue.qsize() <= MAX_SEND_QUEUE
|
||||
# Drain and check: the lobbies are all there, in order; the survivors
|
||||
# are the NEWEST snapshots.
|
||||
got: list[Any] = []
|
||||
while not conn.send_queue.empty():
|
||||
got.append(conn.send_queue.get_nowait())
|
||||
types = [item.type for item in got]
|
||||
assert types == ["lobby"] * 3 + ["snap"] * (MAX_SEND_QUEUE - 3)
|
||||
survivors = [item.raw["t"] for item in got if item.type == "snap"]
|
||||
assert survivors == list(
|
||||
range(20 - (MAX_SEND_QUEUE - 3), 20)
|
||||
), "the oldest snapshots must be the ones dropped"
|
||||
|
||||
|
||||
async def test_non_droppable_frames_preserve_order() -> None:
|
||||
clock = FakeClock()
|
||||
conn, _ws = _conn(clock)
|
||||
frames = [
|
||||
_lobby(0),
|
||||
_snap(0),
|
||||
_round(),
|
||||
_snap(1),
|
||||
_lobby(1),
|
||||
_snap(2),
|
||||
_round(),
|
||||
_snap(3),
|
||||
_lobby(2),
|
||||
_snap(4),
|
||||
_snap(5),
|
||||
_snap(6),
|
||||
_snap(7),
|
||||
_snap(8),
|
||||
]
|
||||
for frame in frames:
|
||||
conn.enqueue(frame)
|
||||
got: list[Any] = []
|
||||
while not conn.send_queue.empty():
|
||||
got.append(conn.send_queue.get_nowait())
|
||||
# Nothing non-droppable was dropped; the snaps that survived are the
|
||||
# newest, and every frame keeps its relative order.
|
||||
non_droppable = [item for item in got if item.type in ("lobby", "round")]
|
||||
assert list(non_droppable) == [
|
||||
_lobby(0),
|
||||
_round(),
|
||||
_lobby(1),
|
||||
_round(),
|
||||
_lobby(2),
|
||||
]
|
||||
snaps = [item for item in got if item.type == "snap"]
|
||||
assert [s.raw["t"] for s in snaps] == sorted(s.raw["t"] for s in snaps)
|
||||
|
||||
|
||||
async def test_queue_cannot_grow_with_a_rate_limited_flood() -> None:
|
||||
clock = FakeClock()
|
||||
conn, _ws = _conn(clock)
|
||||
for index in range(50): # a flood of refusals (e.g. 50 rate_limited errs)
|
||||
conn.enqueue(
|
||||
ErrMsg(
|
||||
type="err",
|
||||
code=ErrorCode.RATE_LIMITED,
|
||||
msg=f"refusal {index}",
|
||||
)
|
||||
)
|
||||
# At most ONE rate_limited err is queued (coalesced); the queue is
|
||||
# bounded regardless of the flood's size.
|
||||
held: list[Any] = []
|
||||
while True:
|
||||
try:
|
||||
held.append(conn.send_queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
rate_limited = [item for item in held if item.code == ErrorCode.RATE_LIMITED]
|
||||
assert len(rate_limited) == 1
|
||||
assert len(held) <= MAX_SEND_QUEUE + 1
|
||||
|
||||
|
||||
async def test_queue_caps_at_max_under_snapshot_flood() -> None:
|
||||
clock = FakeClock()
|
||||
conn, _ws = _conn(clock)
|
||||
for index in range(100): # the dominant traffic, a slow reader: the
|
||||
conn.enqueue(_snap(index)) # queue holds the newest, caps at the max
|
||||
assert conn.send_queue.qsize() == MAX_SEND_QUEUE
|
||||
held: list[Any] = []
|
||||
while True:
|
||||
try:
|
||||
held.append(conn.send_queue.get_nowait())
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
assert [item.raw["t"] for item in held] == list(range(92, 100))
|
||||
|
||||
|
||||
async def test_all_non_droppable_frames_are_delivered_never_dropped() -> None:
|
||||
clock = FakeClock()
|
||||
conn, _ws = _conn(clock)
|
||||
for index in range(30):
|
||||
conn.enqueue(_lobby(index))
|
||||
# Nothing is ever dropped: once the queue holds no snapshot there is
|
||||
# nothing to drop, and the queue grows with the non-droppable frames
|
||||
# the server actually sent (bounded by the game's own event count —
|
||||
# a lobby/round/err per state change, a coalesced `rate_limited`).
|
||||
assert conn.send_queue.qsize() == 30
|
||||
|
||||
|
||||
async def test_pump_delivers_frames_in_order_to_a_slow_reader() -> None:
|
||||
clock = FakeClock()
|
||||
conn, ws = _conn(clock)
|
||||
for index in range(15):
|
||||
conn.enqueue(_snap(index))
|
||||
assert conn.send_queue.qsize() == MAX_SEND_QUEUE
|
||||
pump = asyncio.create_task(pump_out(ws, conn))
|
||||
await asyncio.sleep(0) # let the pump run its passes
|
||||
conn.alive = False
|
||||
pump.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await pump
|
||||
sent_snaps = [frame["raw"]["t"] for frame in ws.sent if frame["type"] == "snap"]
|
||||
# Whatever the reader saw is in order and a suffix of the batch (only
|
||||
# the oldest may have been dropped).
|
||||
assert sent_snaps == sorted(sent_snaps)
|
||||
assert sent_snaps == list(range(15 - len(sent_snaps), 15))
|
||||
|
||||
|
||||
# ----------------------------------------------------------------- token bucket
|
||||
|
||||
|
||||
def test_bucket_serves_its_burst_then_empty() -> None:
|
||||
clock = FakeClock()
|
||||
bucket = TokenBucket(rate=5.0, burst=5, clock=clock)
|
||||
assert all(bucket.take() for _ in range(5))
|
||||
assert not bucket.take() # the sixth, with no time elapsed: empty
|
||||
|
||||
|
||||
def test_bucket_refills_off_the_injected_clock() -> None:
|
||||
clock = FakeClock()
|
||||
bucket = TokenBucket(rate=5.0, burst=5, clock=clock)
|
||||
for _ in range(5):
|
||||
bucket.take()
|
||||
clock.advance(0.2) # one token (5/s * 0.2 s)
|
||||
assert bucket.take()
|
||||
assert not bucket.take()
|
||||
clock.advance(1.0) # a full second: back to the burst cap
|
||||
assert all(bucket.take() for _ in range(5))
|
||||
assert not bucket.take()
|
||||
|
||||
|
||||
def test_bucket_never_refills_past_the_burst_cap() -> None:
|
||||
clock = FakeClock()
|
||||
bucket = TokenBucket(rate=5.0, burst=3, clock=clock)
|
||||
clock.advance(100.0) # a very long idle
|
||||
assert bucket.tokens() == 3.0
|
||||
assert all(bucket.take() for _ in range(3))
|
||||
assert not bucket.take()
|
||||
|
||||
|
||||
def test_bucket_rejects_impossible_parameters() -> None:
|
||||
clock = FakeClock()
|
||||
with pytest.raises(ValueError, match=r"rate must be > 0"):
|
||||
TokenBucket(rate=0.0, burst=1, clock=clock)
|
||||
with pytest.raises(ValueError, match=r"burst must be >= 1"):
|
||||
TokenBucket(rate=1.0, burst=0, clock=clock)
|
||||
|
||||
|
||||
def test_bucket_tokens_reports_the_refilled_count() -> None:
|
||||
clock = FakeClock()
|
||||
bucket = TokenBucket(rate=2.0, burst=10, clock=clock)
|
||||
for _ in range(5):
|
||||
bucket.take()
|
||||
assert bucket.tokens() == 5.0
|
||||
clock.advance(0.5) # +1 (2/s * 0.5 s)
|
||||
assert bucket.tokens() == 6.0
|
||||
|
||||
|
||||
# ------------------------------------------------------------- the abuse tracker
|
||||
|
||||
|
||||
def test_rate_close_after_three_consecutive_violations() -> None:
|
||||
clock = FakeClock()
|
||||
tracker = AbuseTracker(clock)
|
||||
assert not tracker.note_rate_violation()
|
||||
assert not tracker.note_rate_violation()
|
||||
assert tracker.note_rate_violation() # the third, inside the window
|
||||
|
||||
|
||||
def test_clean_frame_resets_the_consecutive_run() -> None:
|
||||
clock = FakeClock()
|
||||
tracker = AbuseTracker(clock)
|
||||
assert not tracker.note_rate_violation()
|
||||
tracker.note_clean()
|
||||
assert not tracker.note_rate_violation()
|
||||
assert not tracker.note_rate_violation() # the run only has two now
|
||||
clock.advance(1.0)
|
||||
assert tracker.note_rate_violation() # three consecutive again
|
||||
|
||||
|
||||
def test_rate_violations_outside_the_window_do_not_close() -> None:
|
||||
clock = FakeClock()
|
||||
tracker = AbuseTracker(clock)
|
||||
assert not tracker.note_rate_violation() # t=0
|
||||
clock.advance(ABUSE_WINDOW_S + 0.001) # the first stamp falls out
|
||||
assert not tracker.note_rate_violation() # t=10.001: the run is two, one in-window
|
||||
assert not tracker.note_rate_violation() # t=10.001: the run is three, two in-window
|
||||
clock.advance(0.1)
|
||||
assert tracker.note_rate_violation() # now three fit in the window
|
||||
|
||||
|
||||
def test_shape_close_after_five_in_the_window() -> None:
|
||||
clock = FakeClock()
|
||||
tracker = AbuseTracker(clock)
|
||||
for _ in range(4):
|
||||
assert not tracker.note_shape_violation()
|
||||
assert tracker.note_shape_violation() # the fifth
|
||||
|
||||
|
||||
def test_shape_violations_outside_the_window_do_not_close() -> None:
|
||||
clock = FakeClock()
|
||||
tracker = AbuseTracker(clock)
|
||||
for _ in range(4):
|
||||
assert not tracker.note_shape_violation() # four at t=0
|
||||
assert tracker.note_shape_violation() # the fifth closes (all in-window)
|
||||
# Age the whole batch past the window, then it must not count again.
|
||||
clock.advance(ABUSE_WINDOW_S + 0.001)
|
||||
for _ in range(4):
|
||||
assert not tracker.note_shape_violation() # one..four in-window
|
||||
assert tracker.note_shape_violation() # the fifth in-window
|
||||
@@ -0,0 +1,561 @@
|
||||
"""Unit: the input-ingestion path (phase 06, task 03) — `ws.to_player_input`,
|
||||
`ws.seq_verdict`, the `_dispatch_input` layers (seat check, seq discipline,
|
||||
clamp, gap counting) and the room's per-tick queue (capacity `4 x seats`,
|
||||
drop-oldest-for-that-player, `stats()` census).
|
||||
|
||||
The sim-side backstop of the clamp — a `(1000, 1000)` intent driven through
|
||||
`to_player_input` + `Room.queue_input` + `tick_once` for 20 ticks never moves
|
||||
a cook more than `MAX_SPEED x 0.05 x 1.01` per tick — is the hostile-speed
|
||||
proof the live-socket suite in `tests/integration/test_ws_input.py` mirrors
|
||||
at the wire layer (the wire's own `Vec2Msg` rejection of magnitude > 1 is the
|
||||
early, explicit half of the rule; `movement.intent_direction()` is the
|
||||
backstop the sim keeps behind both).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from undercooked.config import Settings
|
||||
from undercooked.server.clock import FakeClock
|
||||
from undercooked.server.protocol import ErrMsg, ErrorCode, InputMsg, Vec2Msg
|
||||
from undercooked.server.room import Room, RoomState
|
||||
from undercooked.server.ws import (
|
||||
TOLERATED_SEQ_GAP,
|
||||
Connection,
|
||||
TokenBucket,
|
||||
WsLike,
|
||||
_dispatch_input,
|
||||
seq_verdict,
|
||||
to_player_input,
|
||||
)
|
||||
from undercooked.sim.content import ContentBundle
|
||||
from undercooked.sim.level import Level, LevelDef
|
||||
from undercooked.sim.model import Facing, ItemForm, ItemKind, StationKind, Vec2
|
||||
from undercooked.sim.movement import MAX_SPEED
|
||||
from undercooked.sim.recipe import Ingredient, Recipe, RecipeBook, Step
|
||||
from undercooked.sim.state_build import SeatSpec, build_state
|
||||
from undercooked.sim.step import STEP_DT, PlayerInput
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
# --------------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
class _FakeWs(WsLike):
|
||||
"""A socket stand-in: records the text frames it was sent."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[str] = []
|
||||
|
||||
async def send_text(self, data: str) -> None:
|
||||
self.sent.append(data)
|
||||
|
||||
|
||||
def make_room(**settings: Any) -> tuple[Room, FakeClock]:
|
||||
"""A room on a `FakeClock`, one seated (host) player, `PLAYING` (the
|
||||
`queue_input` path under test needs the state, not the countdown)."""
|
||||
clock = FakeClock()
|
||||
room = Room(code="INPT1", settings=Settings(**settings), clock=clock)
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
room.state = RoomState.PLAYING
|
||||
return room, clock
|
||||
|
||||
|
||||
def make_conn(
|
||||
token: str, clock: FakeClock, *, seat_index: int = 0
|
||||
) -> tuple[Connection, _FakeWs]:
|
||||
ws = _FakeWs()
|
||||
conn = Connection(
|
||||
ws=ws,
|
||||
token=token,
|
||||
player_id=token,
|
||||
seat_index=seat_index,
|
||||
role="player",
|
||||
rate=TokenBucket(40, 40, clock),
|
||||
)
|
||||
return conn, ws
|
||||
|
||||
|
||||
def make_input(
|
||||
seq: int,
|
||||
x: float = 0.0,
|
||||
y: float = 0.0,
|
||||
act: object = False,
|
||||
face: object = None,
|
||||
) -> InputMsg:
|
||||
"""A `InputMsg` via `model_construct` — bypasses the strict validation
|
||||
on purpose: the clamp layer under test must stand whatever the wire
|
||||
layer already rejected (the `(1000, 1000)` hostile frame, the non-bool
|
||||
`act`, the unknown `face` all live here)."""
|
||||
return InputMsg.model_construct(
|
||||
type="input",
|
||||
seq=seq,
|
||||
move=Vec2Msg.model_construct(x=x, y=y),
|
||||
act=act,
|
||||
face=face,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- seq_verdict
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("last_seq", "seq", "expected"),
|
||||
[
|
||||
(0, 1, "accept"),
|
||||
(5, 6, "accept"),
|
||||
(5, 5, "duplicate"),
|
||||
(5, 4, "stale"),
|
||||
(5, 0, "stale"),
|
||||
],
|
||||
)
|
||||
def test_seq_verdict_is_the_task_03_matrix(
|
||||
last_seq: int, seq: int, expected: str
|
||||
) -> None:
|
||||
assert seq_verdict(last_seq, seq) == expected
|
||||
|
||||
|
||||
def test_the_tolerated_gap_is_three() -> None:
|
||||
assert TOLERATED_SEQ_GAP == 3
|
||||
|
||||
|
||||
# ------------------------------------------------------- to_player_input (clamp)
|
||||
|
||||
|
||||
def test_a_hostile_move_is_clamped_to_the_unit_disc() -> None:
|
||||
_, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
frame = to_player_input(conn, make_input(1, x=1000.0, y=1000.0), tick=7)
|
||||
assert frame.player_id == "tok-a"
|
||||
assert frame.seq == 1
|
||||
assert frame.received_at_tick == 7
|
||||
length = math.hypot(frame.move.x, frame.move.y)
|
||||
assert length <= 1.0 + 1e-9, f"the clamp let a magnitude of {length} through"
|
||||
# A unit diagonal (the (1000, 1000) direction, normalised) — not a boost.
|
||||
assert abs(frame.move.x - frame.move.y) < 1e-9
|
||||
assert frame.move.x == pytest.approx(0.707, abs=1e-3)
|
||||
|
||||
|
||||
def test_a_unit_move_passes_through_unclamped() -> None:
|
||||
_, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
frame = to_player_input(conn, make_input(1, x=1.0, y=0.0), tick=0)
|
||||
assert (frame.move.x, frame.move.y) == (1.0, 0.0)
|
||||
|
||||
|
||||
def test_move_is_quantized_to_three_decimals() -> None:
|
||||
_, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
frame = to_player_input(conn, make_input(1, x=0.123456, y=-0.654321), tick=0)
|
||||
assert frame.move.x == round(0.123456, 3)
|
||||
assert frame.move.y == round(-0.654321, 3)
|
||||
|
||||
|
||||
def test_act_is_coerced_to_bool() -> None:
|
||||
_, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
assert to_player_input(conn, make_input(1, act=1), tick=0).action is True
|
||||
assert to_player_input(conn, make_input(1, act=0), tick=0).action is False
|
||||
|
||||
|
||||
# ------------------------------------------------- face derivation (task 03)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("x", "y", "expected"),
|
||||
[
|
||||
(1.0, 0.0, Facing.RIGHT),
|
||||
(-1.0, 0.0, Facing.LEFT),
|
||||
(0.0, -1.0, Facing.UP),
|
||||
(0.0, 1.0, Facing.DOWN),
|
||||
(0.7, 0.7, Facing.RIGHT), # a 45° press: the dominant (horizontal) axis
|
||||
(0.7, -0.7, Facing.RIGHT),
|
||||
(-0.7, 0.7, Facing.LEFT),
|
||||
(-0.7, -0.7, Facing.LEFT),
|
||||
],
|
||||
)
|
||||
def test_face_is_derived_from_move_when_null(
|
||||
x: float, y: float, expected: Facing
|
||||
) -> None:
|
||||
_, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
frame = to_player_input(conn, make_input(1, x=x, y=y, face=None), tick=0)
|
||||
assert frame.face is expected
|
||||
|
||||
|
||||
def test_face_is_derived_from_move_when_unknown() -> None:
|
||||
"""An unknown `face` is unreachable over the wire (`InputMsg` rejects it
|
||||
as `bad_field` first) — but `to_player_input` is the clamp layer, and a
|
||||
directly-constructed one must derive, not raise."""
|
||||
_, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
frame = to_player_input(
|
||||
conn, make_input(1, x=1.0, y=0.0, face="backwards"), tick=0
|
||||
)
|
||||
assert frame.face is Facing.RIGHT
|
||||
|
||||
|
||||
def test_a_zero_move_derives_no_facing() -> None:
|
||||
_, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
frame = to_player_input(conn, make_input(1, x=0.0, y=0.0), tick=0)
|
||||
assert frame.face is None
|
||||
|
||||
|
||||
def test_an_explicit_known_face_wins_over_move() -> None:
|
||||
_, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
frame = to_player_input(conn, make_input(1, x=1.0, y=0.0, face="up"), tick=0)
|
||||
assert frame.face is Facing.UP
|
||||
|
||||
|
||||
# --------------------------------------------------- _dispatch_input: seat check
|
||||
|
||||
|
||||
def test_a_spectator_input_is_refused_not_seated() -> None:
|
||||
"""A token with no seat (a spectator, or none) is `err not_seated`;
|
||||
nothing is queued, and `last_seq` is untouched."""
|
||||
room, clock = make_room()
|
||||
room.add_spectator("tok-spec", "Spec")
|
||||
conn, _ = make_conn("tok-spec", clock)
|
||||
|
||||
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
|
||||
assert not room._queued_inputs
|
||||
assert conn.last_seq == 0
|
||||
(err,) = conn.send_queue._queue # type: ignore[reportAttributeAccessIssue]
|
||||
assert isinstance(err, ErrMsg)
|
||||
assert err.code == ErrorCode.NOT_SEATED
|
||||
|
||||
|
||||
def test_a_disconnected_seat_is_silently_ignored() -> None:
|
||||
room, clock = make_room()
|
||||
room.mark_seat_disconnected("tok-a")
|
||||
conn, ws = make_conn("tok-a", clock)
|
||||
|
||||
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
|
||||
assert not room._queued_inputs
|
||||
assert conn.last_seq == 0 # not accepted: the stat path never ran
|
||||
assert room.stats()["inputs_received"] == 0
|
||||
assert not ws.sent # no err, nothing
|
||||
|
||||
|
||||
def test_the_room_layer_repeats_the_disconnected_guard() -> None:
|
||||
"""`Room.queue_input` is the sim-side backstop: a `DISCONNECTED` seat's
|
||||
frame is ignored there too (no event, no queue, no stat)."""
|
||||
room, _ = make_room()
|
||||
room.mark_seat_disconnected("tok-a")
|
||||
assert (
|
||||
room.queue_input(
|
||||
"tok-a",
|
||||
PlayerInput(player_id="tok-a", seq=1, move=Vec2(1.0, 0.0)),
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert not room._queued_inputs
|
||||
assert not room.events_since_snapshot
|
||||
assert room.stats()["inputs_received"] == 0
|
||||
|
||||
|
||||
# ------------------------------------------- _dispatch_input: the seq discipline
|
||||
|
||||
|
||||
def test_an_accepted_input_advances_last_seq_and_queues() -> None:
|
||||
room, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
|
||||
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
|
||||
assert conn.last_seq == 1
|
||||
assert len(room._queued_inputs) == 1
|
||||
assert room._queued_inputs[0].received_at_tick == 0 # the sim tick (None state)
|
||||
assert room.stats()["inputs_received"] == 1
|
||||
assert room.stats()["input_gaps"] == 0
|
||||
|
||||
|
||||
def test_an_equal_seq_is_silently_dropped() -> None:
|
||||
room, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
|
||||
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
|
||||
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0)) # the resend
|
||||
assert conn.last_seq == 1
|
||||
assert len(room._queued_inputs) == 1 # the first one only
|
||||
assert room.stats()["inputs_received"] == 1
|
||||
assert room.stats()["stale_seqs"] == 0
|
||||
assert not [
|
||||
event
|
||||
for event in room.events_since_snapshot
|
||||
if event.kind == "resync"
|
||||
]
|
||||
|
||||
|
||||
def test_a_backwards_seq_is_refused_with_seq_and_a_resync_event() -> None:
|
||||
room, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
|
||||
_dispatch_input(conn, room, make_input(5, x=1.0, y=0.0))
|
||||
_dispatch_input(conn, room, make_input(3, x=1.0, y=0.0)) # behind 5
|
||||
assert conn.last_seq == 5 # never lowered
|
||||
assert len(room._queued_inputs) == 1
|
||||
assert room.stats()["stale_seqs"] == 1
|
||||
(err,) = conn.send_queue._queue # type: ignore[reportAttributeAccessIssue]
|
||||
assert isinstance(err, ErrMsg)
|
||||
assert err.code == ErrorCode.BAD_FIELD
|
||||
assert err.seq == 3 # the frame's seq: what the client resyncs off
|
||||
resyncs = [
|
||||
event
|
||||
for event in room.events_since_snapshot
|
||||
if event.kind == "resync"
|
||||
]
|
||||
assert len(resyncs) == 1
|
||||
assert resyncs[0].payload == {"player_id": "tok-a", "seq": 3}
|
||||
|
||||
|
||||
def test_a_gap_of_three_or_less_is_tolerated_silently() -> None:
|
||||
room, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
|
||||
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
|
||||
_dispatch_input(conn, room, make_input(4, x=1.0, y=0.0)) # a jump of 3
|
||||
assert conn.last_seq == 4
|
||||
assert len(room._queued_inputs) == 2
|
||||
assert room.stats()["input_gaps"] == 0
|
||||
|
||||
|
||||
def test_a_larger_gap_is_accepted_and_counted() -> None:
|
||||
room, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
|
||||
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
|
||||
_dispatch_input(conn, room, make_input(50, x=1.0, y=0.0)) # a jump of 49
|
||||
assert conn.last_seq == 50
|
||||
assert len(room._queued_inputs) == 2
|
||||
assert room.stats()["input_gaps"] == 1
|
||||
|
||||
|
||||
def test_a_hostile_move_counts_clamped_moves_and_clamps() -> None:
|
||||
room, clock = make_room()
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
|
||||
_dispatch_input(conn, room, make_input(1, x=1000.0, y=1000.0))
|
||||
assert room.stats()["clamped_moves"] == 1
|
||||
(frame,) = room._queued_inputs
|
||||
assert math.hypot(frame.move.x, frame.move.y) <= 1.0 + 1e-9
|
||||
|
||||
|
||||
def test_input_in_a_non_playing_room_is_dropped_silently() -> None:
|
||||
room, clock = make_room()
|
||||
room.state = RoomState.LOBBY
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
|
||||
_dispatch_input(conn, room, make_input(1, x=1.0, y=0.0))
|
||||
assert not room._queued_inputs
|
||||
assert room.stats()["inputs_received"] == 0
|
||||
|
||||
|
||||
# --------------------------------------------------- the room's per-tick queue
|
||||
|
||||
|
||||
def _fill(room: Room, token: str, count: int, start_seq: int = 1) -> None:
|
||||
for offset in range(count):
|
||||
assert room.queue_input(
|
||||
token,
|
||||
PlayerInput(
|
||||
player_id=token, seq=start_seq + offset, move=Vec2(1.0, 0.0)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_the_queue_capacity_is_four_times_the_seats() -> None:
|
||||
room, _ = make_room() # one seat: capacity 4
|
||||
_fill(room, "tok-a", 4)
|
||||
assert len(room._queued_inputs) == 4
|
||||
# The 5th (same player): the oldest of *that player's* entries drops.
|
||||
assert room.queue_input(
|
||||
"tok-a", PlayerInput(player_id="tok-a", seq=5, move=Vec2(1.0, 0.0))
|
||||
)
|
||||
assert len(room._queued_inputs) == 4
|
||||
assert room._queued_inputs[0].seq == 2 # seq 1 was the drop
|
||||
assert room.stats()["inputs_dropped"] == 1
|
||||
assert room.stats()["inputs_received"] == 5
|
||||
|
||||
|
||||
def test_overflow_never_drops_another_players_entries() -> None:
|
||||
"""The queue is full of player A's frames: player B's first frame is
|
||||
the drop (a spammer cannot clear another player's tick); A's oldest
|
||||
still drops for A's own next frame."""
|
||||
room, _ = make_room()
|
||||
room.add_seat("tok-b", "Bob", False)
|
||||
_fill(room, "tok-a", 8) # 2 seats: capacity 8; 8 of A's frames
|
||||
assert (
|
||||
room.queue_input(
|
||||
"tok-b", PlayerInput(player_id="tok-b", seq=1, move=Vec2(1.0, 0.0))
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert not any(frame.player_id == "tok-b" for frame in room._queued_inputs)
|
||||
assert room.stats()["inputs_dropped"] == 1
|
||||
# A's own 9th frame evicts A's oldest, as usual.
|
||||
assert room.queue_input(
|
||||
"tok-a", PlayerInput(player_id="tok-a", seq=9, move=Vec2(1.0, 0.0))
|
||||
)
|
||||
assert room._queued_inputs[0].seq == 2
|
||||
assert len(room._queued_inputs) == 8
|
||||
|
||||
|
||||
def test_an_unknown_or_spectator_id_is_a_buffered_err_event() -> None:
|
||||
room, _ = make_room()
|
||||
room.add_spectator("tok-spec", "Spec")
|
||||
assert (
|
||||
room.queue_input(
|
||||
"tok-spec", PlayerInput(player_id="tok-spec", seq=1)
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert (
|
||||
room.queue_input("tok-none", PlayerInput(player_id="tok-none", seq=1))
|
||||
is False
|
||||
)
|
||||
reasons = [
|
||||
event.payload.get("reason")
|
||||
for event in room.events_since_snapshot
|
||||
if event.kind == "err"
|
||||
]
|
||||
assert reasons == ["not_seated", "unknown_player"]
|
||||
assert room.stats()["inputs_received"] == 0
|
||||
|
||||
|
||||
def test_note_input_stat_rejects_a_misspelling() -> None:
|
||||
room, _ = make_room()
|
||||
with pytest.raises(KeyError):
|
||||
room.note_input_stat("inputs_received_typo")
|
||||
with pytest.raises(KeyError):
|
||||
room.note_input_stat("")
|
||||
|
||||
|
||||
def test_stats_is_a_fixed_shape_census_of_zeroes_fresh() -> None:
|
||||
room, _ = make_room()
|
||||
assert room.stats() == {
|
||||
"inputs_received": 0,
|
||||
"inputs_dropped": 0,
|
||||
"stale_seqs": 0,
|
||||
"clamped_moves": 0,
|
||||
"input_gaps": 0,
|
||||
"events_dropped": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_note_stale_seq_counts_and_buffers_the_resync_event() -> None:
|
||||
room, _ = make_room()
|
||||
room.note_stale_seq("tok-a", 3)
|
||||
assert room.stats()["stale_seqs"] == 1
|
||||
resyncs = [
|
||||
event for event in room.events_since_snapshot if event.kind == "resync"
|
||||
]
|
||||
assert len(resyncs) == 1
|
||||
assert resyncs[0].payload == {"player_id": "tok-a", "seq": 3}
|
||||
|
||||
|
||||
# --------------------------------- the hostile-speed proof (sim-side, per tick)
|
||||
|
||||
|
||||
def test_a_hostile_intent_moved_through_the_whole_path_never_exceeds_max_speed() -> None:
|
||||
"""`(1000, 1000)` every tick, through `to_player_input` +
|
||||
`Room.queue_input` + `tick_once`: the per-tick distance stays within
|
||||
`MAX_SPEED x dt x 1.01`. This is the clamp chain's proof (the wire
|
||||
layer's `Vec2Msg` rejection is a separate, earlier line of defence —
|
||||
the live suite asserts it there)."""
|
||||
# A bare, open 9x4 kitchen (no walls to lean on): the cook's motion is
|
||||
# the velocity's, so the distance bound is the velocity bound. The
|
||||
# `LevelDef`'s own guards (`target_score > 0`, a non-empty `pool`)
|
||||
# want a recipe, so `soup` rides along (a high `target` keeps the
|
||||
# 20-tick window from ever ending the round).
|
||||
level = Level.build(
|
||||
LevelDef.model_validate(
|
||||
{
|
||||
"id": "hostile_1",
|
||||
"name": "Hostile",
|
||||
"order": 1,
|
||||
"time_limit": 30.0,
|
||||
"target_score": 1000,
|
||||
"grid": [
|
||||
"#########",
|
||||
"#.......#",
|
||||
"#....SV.#",
|
||||
"#.......#",
|
||||
"#########",
|
||||
],
|
||||
"tiles": {
|
||||
".": {"kind": "floor"},
|
||||
"#": {"kind": "wall"},
|
||||
"S": {"kind": "floor", "station": "spawn_shelf", "item": "tomato"},
|
||||
"V": {"kind": "floor", "station": "serve"},
|
||||
},
|
||||
"spawns": [[4, 2]],
|
||||
"orders": {
|
||||
"pool": ["soup"],
|
||||
"concurrent": 1,
|
||||
"spawn_interval": [10, 10],
|
||||
"deadline": 30,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
bundle = ContentBundle(
|
||||
recipe_book=RecipeBook(
|
||||
recipes={
|
||||
"soup": Recipe(
|
||||
id="soup",
|
||||
name="Soup",
|
||||
base_score=40,
|
||||
ingredients=[
|
||||
Ingredient(kind=ItemKind.TOMATO, form=ItemForm.RAW)
|
||||
],
|
||||
steps=[
|
||||
Step(kind="serve", station=StationKind.SERVE, seconds=0.0)
|
||||
],
|
||||
)
|
||||
},
|
||||
ingredients=[ItemKind.TOMATO],
|
||||
),
|
||||
levels=(level,),
|
||||
)
|
||||
clock = FakeClock()
|
||||
room = Room(code="INPT1", settings=Settings(), clock=clock, content=bundle)
|
||||
room.add_seat("tok-a", "Ada", True)
|
||||
room.state = RoomState.PLAYING
|
||||
state = build_state(
|
||||
level,
|
||||
[SeatSpec(player_id="tok-a", seat=0, display_name="Ada", color="")],
|
||||
7,
|
||||
book=bundle.recipe_book,
|
||||
)
|
||||
room.sim_state = state
|
||||
conn, _ = make_conn("tok-a", clock)
|
||||
room.tick_once() # warm-up: sets `_last_at` (a 0-`dt` tick advances
|
||||
# nothing — with the substep guard it also keeps the queue)
|
||||
previous: Vec2 | None = None
|
||||
worst = 0.0
|
||||
for tick in range(1, 21):
|
||||
_dispatch_input(
|
||||
conn,
|
||||
room,
|
||||
make_input(tick, x=1000.0, y=1000.0, act=True),
|
||||
)
|
||||
assert len(room._queued_inputs) == 1, "one frame per tick, one drop max"
|
||||
clock.advance(0.05)
|
||||
room.tick_once()
|
||||
assert room.sim_state is not None
|
||||
(player,) = room.sim_state.players
|
||||
if previous is not None:
|
||||
distance = (player.pos - previous).length
|
||||
worst = max(worst, distance)
|
||||
previous = player.pos
|
||||
bound = MAX_SPEED * STEP_DT * 1.01
|
||||
assert worst <= bound, (
|
||||
f"the cook moved {worst:.4f} tiles in one tick; the bound is {bound:.4f}"
|
||||
)
|
||||
Reference in New Issue
Block a user