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