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,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
|
||||
Reference in New Issue
Block a user