"""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, Any]: """Two clients, the room, and the lobby, to `PLAYING` (the module docstring's lobby half); returns `(code, room, a, b, keepalive)`. The `keepalive` is the documented keep-alive task (the 12 s heartbeat dead window): it starts the moment both clients are in, because a slow run (the full-suite load) can stretch the `start`-to-countdown gap past the window — a `ping` from every client every 2 s keeps the watchdog quiet through the lobby, the countdown and into the round. The caller cancels it in its `finally`. """ 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") keepalive = asyncio.create_task(_keepalive((a, 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, keepalive 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, keepalive = 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 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, keepalive = 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() keepalive.cancel()