Files
ducoterra f26849c1f3 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`.
2026-10-09 11:13:25 -04:00

518 lines
21 KiB
Python

"""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,
)