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`.
518 lines
21 KiB
Python
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,
|
|
)
|