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,589 @@
|
||||
"""Integration: a complete round over **real sockets**, two clients
|
||||
(phase 06 task 06; US-02/US-03/US-05) — the `live_server` / `ws_client`
|
||||
fixtures from `tests/integration/conftest.py` drive a playable campaign
|
||||
(`tests/fixtures/levels_round/`: the shipped level 1 verbatim in layout
|
||||
and content, `target_score` 20 and `time_limit` 60 s so the scripted
|
||||
first serve wins the round — one soup scores 44-66 by
|
||||
`sim/scoring.py`, the shipped 150 s / 45 target would make the win
|
||||
depend on a *second* serve's timing; the wire contract under test is
|
||||
the same).
|
||||
|
||||
The round, end to end, on the wire:
|
||||
|
||||
* **the lobby** — A creates the room (HTTP, a known token), B joins
|
||||
(HTTP), both connect and handshake (`welcome` + `lobby`); A (the
|
||||
host) sends `set_level`, both send `ready`, then A sends `start`;
|
||||
* **the countdown and the stream** — both receive `countdown`
|
||||
`n = 3, 2, 1`, then 30 `snap` frames with non-decreasing `t` and ≥ 20
|
||||
distinct ticks (a substep-less wall tick — the 20 Hz cadence jitters
|
||||
48-51 ms — re-encodes the same sim tick, so "increasing" means that,
|
||||
not *strictly* increasing, and the 30-frame window carries the
|
||||
re-encode tolerance);
|
||||
* **the inputs** — A drives the scripted soup (walk → shelf pick →
|
||||
board hold → stove hold → prep hold → serve press; one rising-edge
|
||||
press per verb, holds until the view's station reads `ready`), B
|
||||
drives a walking loop; both clients' merged snapshot views carry the
|
||||
state;
|
||||
* **the `SERVED` event and the score** — both observe the same
|
||||
`served` event (the payload's `oi` / `pts` / `q` / `stk`) and the
|
||||
same final `score` in the `round` frame;
|
||||
* **the `round` frame** — the round ends `WON` on the serve's
|
||||
substep; both receive `round` and the two payloads are JSON-equal
|
||||
(byte-identical under the canonical dump: the only
|
||||
viewer-specific field, `unlocked`, is `None` for both — a win on
|
||||
the campaign's single level unlocks nothing);
|
||||
* **the lobby return** — after `round`, A's `to_lobby` returns both to
|
||||
`LOBBY` with the seats preserved (two seats, the ready flags reset,
|
||||
the crown on A).
|
||||
|
||||
The choreography is feedback-driven off the merged view (positions are
|
||||
one snapshot stale — ≤ 100 ms at the 20 Hz stream — so every station
|
||||
visit settles: wait for the view to confirm arrival, hold the action
|
||||
while the station works, release, one fresh press for the pick). The
|
||||
staging points are the cells *directly* adjacent to each station, on
|
||||
the axis the approach comes in on (the reach zone's discrete half:
|
||||
the front cell, the two diagonal-front cells, the tile under the cook
|
||||
— a station whose cell is not in that set is not a candidate at all).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
#: The playable campaign (the shipped level 1, layout-verbatim,
|
||||
#: `target_score` 20 / `time_limit` 60 s — the module docstring's
|
||||
#: tuning): a room built from it can start a round and the first
|
||||
#: serve wins it.
|
||||
ROUND_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels_round"
|
||||
|
||||
# The level 1 geometry (grid cell (col, row); the wire's position is in
|
||||
# tile units, a cell (x, y) centre is (x + 0.5, y + 0.5).
|
||||
SHELF_STAGE = (2.5, 1.5) # cell (2, 1), facing LEFT at the shelf (1, 1)
|
||||
BOARD_STAGE = (3.5, 1.5) # cell (3, 1), facing RIGHT at the board (4, 1)
|
||||
STOVE_STAGE = (7.5, 1.5) # cell (7, 1), facing RIGHT at the stove (8, 1)
|
||||
PREP_STAGE = (3.5, 3.5) # cell (3, 3), facing LEFT at the prep (2, 3)
|
||||
SERVE_STAGE = (9.5, 3.5) # the window's own cell (walkable, `PASS_THROUGH`)
|
||||
|
||||
# ~16.7 Hz feed: a frame in (almost) every wall-tick window — the
|
||||
# per-tick queue then carries an intent to every substep-advancing tick,
|
||||
# so the cook's velocity holds near the cap instead of oscillating
|
||||
# (a slower feed lets it brake between frames; the carried items rot
|
||||
# with the time, and the serve's `quality` is what the win turns on).
|
||||
INPUT_INTERVAL_S = 0.06
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
def _input(seq: int, move: tuple[float, float] | None, act: bool) -> dict[str, Any]:
|
||||
x, y = move if move is not None else (0.0, 0.0)
|
||||
return {
|
||||
"type": "input",
|
||||
"seq": seq,
|
||||
"move": {"x": x, "y": y},
|
||||
"act": act,
|
||||
"face": None,
|
||||
}
|
||||
|
||||
|
||||
class _Sequencer:
|
||||
"""The client's monotonic `seq` counter (one per connection — the
|
||||
seq discipline is per connection)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.next = 0
|
||||
|
||||
def __call__(self) -> int:
|
||||
self.next += 1
|
||||
return self.next
|
||||
|
||||
|
||||
async def _walk_to(
|
||||
client: Any,
|
||||
pid: str,
|
||||
seq: _Sequencer,
|
||||
target: tuple[float, float],
|
||||
band: float,
|
||||
*,
|
||||
wait_s: float = 40.0,
|
||||
settle: bool = False,
|
||||
) -> None:
|
||||
"""Walk the view position to within `band` of `target`.
|
||||
|
||||
With `settle` (the final waypoint) the arrival is then confirmed
|
||||
*twice* — the second check after a 150 ms settle (a full snapshot
|
||||
cycle, the view is ≤ 100 ms stale at 20 Hz) — with a zero `move`
|
||||
so the derived facing keeps the last approach vector (aimed at the
|
||||
target, which the staging points sit on the axis of) and the
|
||||
deceleration of the last approach vector runs out in ~0.12 s.
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + wait_s
|
||||
while True:
|
||||
player = client.view.player(pid)
|
||||
assert player is not None, "the scripted cook vanished from the view"
|
||||
dx = target[0] - player["x"]
|
||||
dy = target[1] - player["y"]
|
||||
distance = math.hypot(dx, dy)
|
||||
if distance <= band:
|
||||
if settle:
|
||||
await client.send(_input(seq(), None, False))
|
||||
await asyncio.sleep(0.15) # the settle: the view catches up
|
||||
player = client.view.player(pid)
|
||||
assert player is not None
|
||||
distance = math.hypot(
|
||||
target[0] - player["x"], target[1] - player["y"]
|
||||
)
|
||||
if distance <= band + 0.2: # the deceleration tail
|
||||
return
|
||||
continue # the settle was not enough: walk again
|
||||
return # pass the waypoint immediately (the caller walks on —
|
||||
# a stopped cook does not leave the band on its own, and a
|
||||
# sleeping loop here would dead-lock the choreography)
|
||||
assert loop.time() < deadline, f"timed out walking to {target}"
|
||||
length = distance if distance > 1e-6 else 1.0
|
||||
await client.send(_input(seq(), (dx / length, dy / length), False))
|
||||
await asyncio.sleep(INPUT_INTERVAL_S)
|
||||
|
||||
|
||||
async def _walk(
|
||||
client: Any,
|
||||
pid: str,
|
||||
seq: _Sequencer,
|
||||
waypoints: list[tuple[float, float]],
|
||||
band: float = 0.4,
|
||||
*,
|
||||
wait_s: float = 40.0,
|
||||
) -> None:
|
||||
"""Walk the waypoint list; the last one is the settled stop.
|
||||
|
||||
The waypoints detour around the *blocking* fixtures (a body of
|
||||
`2 x RADIUS` = 0.7 tiles cannot pass a station's cell — the sweep
|
||||
stops it before it intrudes, and a body touching a blocking cell's
|
||||
edge cannot move into it: the scripted path has to route around,
|
||||
the same way a human cook would). The corridors keep ≥ 0.15 tile
|
||||
between the body edge and every blocking cell.
|
||||
"""
|
||||
for target in waypoints[:-1]:
|
||||
await _walk_to(client, pid, seq, target, 0.35, wait_s=wait_s)
|
||||
await _walk_to(
|
||||
client, pid, seq, waypoints[-1], band, wait_s=wait_s, settle=True
|
||||
)
|
||||
|
||||
|
||||
async def _press(client: Any, seq: _Sequencer) -> None:
|
||||
"""One action press: a rising edge (an `act: true` frame after an
|
||||
`act: false` one — the `action_gate`'s one-press-one-event), then
|
||||
the release."""
|
||||
await client.send(_input(seq(), None, True))
|
||||
await asyncio.sleep(0.09)
|
||||
await client.send(_input(seq(), None, False))
|
||||
|
||||
|
||||
async def _hold_until(
|
||||
client: Any, seq: _Sequencer, predicate: Any, wait_s: float = 20.0
|
||||
) -> None:
|
||||
"""Hold the action down (the first frame is the edge — the caller
|
||||
just walked with `act: false`) until `predicate()`; the station's
|
||||
progress advances while the worker is in reach and holding
|
||||
(`sim/stations.py` stage 4)."""
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + wait_s
|
||||
while not predicate():
|
||||
assert loop.time() < deadline, "timed out holding the action"
|
||||
await client.send(_input(seq(), None, True))
|
||||
await asyncio.sleep(INPUT_INTERVAL_S)
|
||||
|
||||
|
||||
async def _wait_for(predicate: Any, what: str, wait_s: float = 20.0) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + wait_s
|
||||
while not predicate():
|
||||
if loop.time() > deadline:
|
||||
pytest.fail(f"timed out waiting for {what}")
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
|
||||
def _station_ready(client: Any, kind: str) -> bool:
|
||||
"""The view's station of `kind` reads `ready` (the work is done)."""
|
||||
station = client.view.station(kind)
|
||||
return station is not None and station.get("s") == "ready"
|
||||
|
||||
|
||||
def _carrying(client: Any, pid: str, short: str) -> bool:
|
||||
"""The view's player carries the item with the short form `short`
|
||||
(e.g. `tomato_chopped`)."""
|
||||
player = client.view.player(pid)
|
||||
return player is not None and player.get("h") == short
|
||||
|
||||
|
||||
def _create_room(url: str, token: str) -> str:
|
||||
response = httpx.post(f"{url}/api/rooms", json={"token": token}, timeout=5.0)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()["room_code"]
|
||||
|
||||
|
||||
def _join(url: str, code: str, token: str) -> None:
|
||||
response = httpx.post(
|
||||
f"{url}/api/rooms/{code}/join", json={"token": token}, timeout=5.0
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
|
||||
|
||||
async def _start_playing(
|
||||
url: str, app: Any, ws_client: Any
|
||||
) -> tuple[str, Any, Any, Any]:
|
||||
"""Two clients, the room, and the lobby, to `PLAYING` (the module
|
||||
docstring's lobby half); returns `(code, room, a, b)`."""
|
||||
supervisor = app.state.supervisor
|
||||
code = _create_room(url, "tok-a")
|
||||
_join(url, code, "tok-b")
|
||||
a = await ws_client(url, code, "tok-a", name="A")
|
||||
b = await ws_client(url, code, "tok-b", name="B")
|
||||
for client in (a, b):
|
||||
welcome = await client.expect("welcome")
|
||||
assert welcome["state"] == "lobby"
|
||||
assert welcome["rejoined"] is False
|
||||
lobby = await client.expect("lobby")
|
||||
assert lobby["state"] == "lobby"
|
||||
a.send_model({"type": "set_level", "level": "01_first_kitchen"})
|
||||
await _wait_for(
|
||||
lambda: supervisor.get(code) is not None
|
||||
and supervisor.get(code).level is not None,
|
||||
"the room to hold the level pick",
|
||||
)
|
||||
a.send_model({"type": "ready"})
|
||||
b.send_model({"type": "ready"})
|
||||
# The ready-ups arrive on *different* connections — the host's `start`
|
||||
# must not race ahead of B's (a premature start is the named
|
||||
# `no_ready_players` refusal). Wait for the room to see both.
|
||||
room = supervisor.get(code)
|
||||
assert room is not None
|
||||
await _wait_for(
|
||||
lambda: room.can_start,
|
||||
"the room to see both ready-ups (can_start)",
|
||||
wait_s=10.0,
|
||||
)
|
||||
a.send_model({"type": "start"})
|
||||
await _wait_for(
|
||||
lambda: room.state.value == "playing" and room.sim_state is not None,
|
||||
"the room to reach PLAYING (the 3 s real-time countdown)",
|
||||
wait_s=20.0,
|
||||
)
|
||||
# Drain the lobby re-broadcasts (the `set_level` pick and the two
|
||||
# ready-ups) so a later `expect("lobby")` cannot match a stale one —
|
||||
# they arrived before the countdown frames, so the queue is in order.
|
||||
for client in (a, b):
|
||||
await client.collect("lobby", 3, wait_s=10.0)
|
||||
return code, room, a, b
|
||||
|
||||
|
||||
async def _drive_soup(client: Any, pid: str, seq: _Sequencer) -> None:
|
||||
"""The scripted soup: shelf → board → stove → prep → serve, one
|
||||
fresh press per verb, holds until the view's station reads
|
||||
`ready`."""
|
||||
# 1. The tomato, off the spawn shelf (the room seeded 8 of them).
|
||||
# The detour: straight up column 2 runs into the prep's cell
|
||||
# (blocking); the x = 3.5 corridor is clear of it (body edge
|
||||
# 3.15 > 3.0) and of the board (3.85 < 4.0).
|
||||
await _walk(
|
||||
client,
|
||||
pid,
|
||||
seq,
|
||||
[(3.5, 7.5), (3.5, 1.5), SHELF_STAGE],
|
||||
0.4,
|
||||
)
|
||||
await _press(client, seq)
|
||||
await _wait_for(
|
||||
lambda: _carrying(client, pid, "tomato_raw"),
|
||||
f"{pid} to pick the tomato",
|
||||
wait_s=10.0,
|
||||
)
|
||||
# 2. Chop it (0.9 s of work).
|
||||
await _walk(client, pid, seq, [BOARD_STAGE], 0.4)
|
||||
await _hold_until(client, seq, lambda: _station_ready(client, "chop_board"))
|
||||
await client.send(_input(seq(), None, False)) # the release
|
||||
await asyncio.sleep(0.09)
|
||||
await _press(client, seq)
|
||||
await _wait_for(
|
||||
lambda: _carrying(client, pid, "tomato_chopped"),
|
||||
f"{pid} to pick the chopped tomato",
|
||||
wait_s=10.0,
|
||||
)
|
||||
# 3. Cook it (6 s of work, the pot's turn). The board's cell blocks
|
||||
# the row-1 corridor: the row-2 corridor is clear (body edge
|
||||
# 5.15 > 5.0 clears the board; 7.85 < 8.0 clears the stove).
|
||||
# The last leg comes in *west-to-east* along row 1: the reach
|
||||
# zone's discrete half (the front cell, the two diagonal-front
|
||||
# cells, the tile under the cook) puts the stove's cell (8, 1)
|
||||
# in the candidate set only while the cook faces RIGHT.
|
||||
await _walk(
|
||||
client,
|
||||
pid,
|
||||
seq,
|
||||
[(3.5, 2.5), (5.5, 2.5), (5.5, 1.5), STOVE_STAGE],
|
||||
0.4,
|
||||
)
|
||||
await _hold_until(
|
||||
client, seq, lambda: _station_ready(client, "stove"), wait_s=25.0
|
||||
)
|
||||
await client.send(_input(seq(), None, False))
|
||||
await asyncio.sleep(0.09)
|
||||
await _press(client, seq)
|
||||
await _wait_for(
|
||||
lambda: _carrying(client, pid, "tomato_cooked"),
|
||||
f"{pid} to pick the cooked tomato",
|
||||
wait_s=10.0,
|
||||
)
|
||||
# 4. Plate it (0.6 s of work, a plate out of the clean stock).
|
||||
# The prep's cell blocks its own west side: the staging point is
|
||||
# its *east* neighbour (body edge 3.15 > 3.0), reached along row 3.
|
||||
await _walk(
|
||||
client,
|
||||
pid,
|
||||
seq,
|
||||
[(7.5, 3.5), PREP_STAGE],
|
||||
0.4,
|
||||
)
|
||||
await _hold_until(client, seq, lambda: _station_ready(client, "prep"))
|
||||
await client.send(_input(seq(), None, False))
|
||||
await asyncio.sleep(0.09)
|
||||
await _press(client, seq)
|
||||
await _wait_for(
|
||||
lambda: _carrying(client, pid, "tomato_plated"),
|
||||
f"{pid} to pick the plated dish",
|
||||
wait_s=10.0,
|
||||
)
|
||||
# 5. Serve it (the window is walkable: stand on it, one press).
|
||||
await _walk(client, pid, seq, [SERVE_STAGE], 0.4)
|
||||
await _press(client, seq)
|
||||
|
||||
|
||||
async def _wander(client: Any, pid: str, seq: _Sequencer) -> None:
|
||||
"""B's input: a walking loop in rows 6-7 (A's route is the top
|
||||
rows — the two cooks never share a tile; the loop's body edges
|
||||
stay clear of the row-5 fixtures: at y = 6.5 the body edge is
|
||||
6.15 > 6.0)."""
|
||||
waypoints: list[tuple[float, float]] = [
|
||||
(10.5, 7.5), # east along the bottom row
|
||||
(10.5, 6.5), # north
|
||||
(5.5, 6.5), # west
|
||||
(5.5, 7.5), # south
|
||||
]
|
||||
for target in waypoints:
|
||||
await _walk_to(client, pid, seq, target, 0.3, wait_s=20.0)
|
||||
await client.send(_input(seq(), None, False))
|
||||
|
||||
|
||||
async def _keepalive(clients: tuple[Any, ...]) -> None:
|
||||
"""The documented keep-alive (the 12 s heartbeat dead window): a
|
||||
`ping` from every client every 2 s — a silent socket is a dead
|
||||
client, and the round outlasts the window."""
|
||||
n = 0
|
||||
while True:
|
||||
await asyncio.sleep(2.0)
|
||||
n += 1
|
||||
for client in clients:
|
||||
with contextlib.suppress(Exception):
|
||||
client.send_model({"type": "ping", "t": n})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- the round
|
||||
|
||||
|
||||
def _assert_snap_ticks(name: str, frames: list[dict[str, Any]]) -> None:
|
||||
"""One client's `snap` stream: non-decreasing `t` (a substep-less
|
||||
wall tick re-encodes the same sim tick — "increasing" means
|
||||
non-decreasing here) plus ≥ 20 *distinct* ticks (the stream
|
||||
stalled if it stalled at one tick)."""
|
||||
ticks = [frame["raw"]["t"] for frame in frames]
|
||||
assert all(
|
||||
later >= earlier for earlier, later in itertools.pairwise(ticks)
|
||||
), f"client {name}'s snap ticks went backwards: {ticks}"
|
||||
assert len(set(ticks)) >= 20, (
|
||||
f"client {name} saw {len(set(ticks))} distinct ticks in "
|
||||
f"{len(ticks)} frames (the stream stalled)"
|
||||
)
|
||||
|
||||
|
||||
def _assert_lobby_return(name: str, lobby: dict[str, Any]) -> None:
|
||||
"""One client's `to_lobby` re-broadcast: the state is `lobby`, the
|
||||
seats are preserved, the ready flags reset, the crown stays on A.
|
||||
"""
|
||||
assert lobby["state"] == "lobby", f"client {name} is not back in the lobby"
|
||||
assert len(lobby["seats"]) == 2, (
|
||||
f"client {name}: the seats are not preserved"
|
||||
)
|
||||
assert all(seat["ready"] is False for seat in lobby["seats"]), (
|
||||
f"client {name}: the ready flags must reset on the lobby return"
|
||||
)
|
||||
assert lobby["host_seat"] == 0, (
|
||||
f"client {name}: the crown must stay on A"
|
||||
)
|
||||
|
||||
|
||||
async def test_two_clients_play_a_complete_round(
|
||||
live_server: Any, ws_client: Any
|
||||
) -> None:
|
||||
with live_server(levels_dir=str(ROUND_LEVELS_DIR)) as (url, app):
|
||||
code, room, a, b = await _start_playing(url, app, ws_client)
|
||||
seq_a, seq_b = _Sequencer(), _Sequencer()
|
||||
|
||||
# --- the countdown 3 -> 1, then the snap stream
|
||||
countdown_a = await a.collect("countdown", 3, wait_s=20.0)
|
||||
countdown_b = await b.collect("countdown", 3, wait_s=20.0)
|
||||
assert [frame["n"] for frame in countdown_a] == [3, 2, 1]
|
||||
assert [frame["n"] for frame in countdown_b] == [3, 2, 1]
|
||||
|
||||
# ≥ 20 `snap` frames with increasing `t`: a substep-less wall tick
|
||||
# (a 20 Hz cadence jitters 48-51 ms) re-encodes the *same* sim
|
||||
# tick, so "increasing" is non-decreasing plus ≥ 20 *distinct*
|
||||
# ticks — 30 frames carry the tolerance for the re-encodes.
|
||||
snaps_a = await a.collect("snap", 30, wait_s=30.0)
|
||||
snaps_b = await b.collect("snap", 30, wait_s=30.0)
|
||||
for name, frames in (("a", snaps_a), ("b", snaps_b)):
|
||||
_assert_snap_ticks(name, frames)
|
||||
|
||||
# --- the inputs: A the scripted soup, B the walking loop, both
|
||||
# kept alive past the heartbeat dead window
|
||||
keepalive = asyncio.create_task(_keepalive((a, b)))
|
||||
soup = asyncio.create_task(_drive_soup(a, "tok-a", seq_a))
|
||||
try:
|
||||
await _wander(b, "tok-b", seq_b) # B's inputs, while the soup runs
|
||||
await soup
|
||||
|
||||
# --- the `SERVED` event, both clients, the same payload
|
||||
room = app.state.supervisor.get(code)
|
||||
assert room is not None and room.sim_state is not None
|
||||
await _wait_for(
|
||||
lambda: a.view.event("served") is not None,
|
||||
"A to see the `served` event",
|
||||
wait_s=30.0,
|
||||
)
|
||||
await _wait_for(
|
||||
lambda: b.view.event("served") is not None,
|
||||
"B to see the same `served` event",
|
||||
wait_s=30.0,
|
||||
)
|
||||
served_a = a.view.event("served")
|
||||
served_b = b.view.event("served")
|
||||
assert served_a is not None and served_b is not None
|
||||
for field in ("oi", "pts", "q", "stk"):
|
||||
assert served_a[field] == served_b[field], (
|
||||
f"the `served` events disagree on {field}: "
|
||||
f"{served_a} vs {served_b}"
|
||||
)
|
||||
assert served_a["pts"] >= room.sim_state.target_score, (
|
||||
f"the first serve ({served_a['pts']} pts) must cross the "
|
||||
f"target ({room.sim_state.target_score}) — the round is "
|
||||
"designed to win on this substep"
|
||||
)
|
||||
|
||||
# --- the round ends; both see the same score and payload
|
||||
round_a = await a.expect("round", wait_s=30.0)
|
||||
round_b = await b.expect("round", wait_s=30.0)
|
||||
assert round_a["result"] == "won"
|
||||
assert round_a["score"] >= round_a["target"] >= 20
|
||||
assert round_b["result"] == "won"
|
||||
# The same final score (the `round` frame carries it).
|
||||
assert round_a["score"] == round_b["score"]
|
||||
# JSON equality (the "byte-identical" under the canonical dump:
|
||||
# the only viewer-specific field is `unlocked`, `None` for
|
||||
# both — the win is on the campaign's single level).
|
||||
assert round_a["unlocked"] is None
|
||||
assert round_b["unlocked"] is None
|
||||
assert round_a == round_b
|
||||
assert json.dumps(round_a, sort_keys=True) == json.dumps(
|
||||
round_b, sort_keys=True
|
||||
)
|
||||
# The per-player contribution is there (two seats — A the
|
||||
# soup's cook, B the wanderer).
|
||||
assert len(round_a["players"]) == 2
|
||||
assert {p["id"] for p in round_a["players"]} == {"tok-a", "tok-b"}
|
||||
assert round_a["counters"]["served"] == 1
|
||||
|
||||
# --- the lobby return: `to_lobby`, seats preserved
|
||||
a.send_model({"type": "to_lobby"})
|
||||
# The server-side truth first: the transition happens in the
|
||||
# dispatch, the re-broadcast right after — the frame may trail
|
||||
# the state by a read pass.
|
||||
await _wait_for(
|
||||
lambda: room.state.value == "lobby",
|
||||
"the room to take the to_lobby transition",
|
||||
wait_s=15.0,
|
||||
)
|
||||
lobby_a = await a.expect("lobby", wait_s=15.0)
|
||||
lobby_b = await b.expect("lobby", wait_s=15.0)
|
||||
for name, lobby in (("a", lobby_a), ("b", lobby_b)):
|
||||
_assert_lobby_return(name, lobby)
|
||||
assert room.state.value == "lobby"
|
||||
assert {seat.token for seat in room.seats} == {"tok-a", "tok-b"}
|
||||
finally:
|
||||
keepalive.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await keepalive
|
||||
|
||||
for client in (a, b):
|
||||
await client.close()
|
||||
|
||||
|
||||
async def test_a_third_connection_mid_round_is_a_spectator(
|
||||
live_server: Any, ws_client: Any
|
||||
) -> None:
|
||||
with live_server(levels_dir=str(ROUND_LEVELS_DIR)) as (url, app):
|
||||
code, room, a, b = await _start_playing(url, app, ws_client)
|
||||
seq_a, seq_b = _Sequencer(), _Sequencer()
|
||||
|
||||
await a.collect("countdown", 3, wait_s=20.0)
|
||||
await b.collect("countdown", 3, wait_s=20.0)
|
||||
|
||||
# --- C arrives mid-round (PLAYING — past the LOBBY join rule)
|
||||
c = await ws_client(url, code, "tok-c", name="C")
|
||||
welcome = await c.expect("welcome")
|
||||
assert welcome["state"] == "playing"
|
||||
assert welcome["you"]["host"] is False
|
||||
assert welcome["you"]["seat"] is not None # the spectator index
|
||||
assert len(welcome["seats"]) == 2 # the seats, unchanged
|
||||
room = app.state.supervisor.get(code)
|
||||
assert room is not None
|
||||
assert room.spectator_for_token("tok-c") is not None
|
||||
|
||||
# C receives snapshots (the spectator pass: half the player rate)
|
||||
snap = await c.expect("snap", wait_s=20.0)
|
||||
assert snap["raw"]["t"] >= 0
|
||||
|
||||
# C's input is the documented refusal (a spectator holds no seat)
|
||||
c.send_model(
|
||||
{
|
||||
"type": "input",
|
||||
"seq": 1,
|
||||
"move": {"x": 1.0, "y": 0.0},
|
||||
"act": False,
|
||||
"face": None,
|
||||
}
|
||||
)
|
||||
err = await c.expect("err", wait_s=10.0)
|
||||
assert err["code"] == "not_seated"
|
||||
assert room.stats()["inputs_received"] == 0 # nothing reached the sim
|
||||
|
||||
# A and B keep driving inputs while C watches (the task's "both
|
||||
# drive" — and the keep-alive, so the watchdog stays quiet).
|
||||
for _ in range(10):
|
||||
a.send_model(_input(seq_a(), (0.0, -1.0), False))
|
||||
b.send_model(_input(seq_b(), (0.0, 1.0), False))
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
await c.close()
|
||||
await a.close()
|
||||
await b.close()
|
||||
Reference in New Issue
Block a user