Files
undercooked/tests/integration/test_ws_round_two_clients.py
ducoterra 95ca3fd1fa phase: 07_canvas_client_and_hud
**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`.
2026-10-10 18:03:52 -04:00

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()