**Final report — phase 07 final verification pass** - **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found. - **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase). - **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔. - **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry. - **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`. - **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate. - **Next pending phase:** `08_client_prediction_and_touch`.
598 lines
24 KiB
Python
598 lines
24 KiB
Python
"""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()
|