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`.
This commit is contained in:
2026-10-10 18:03:52 -04:00
parent f26849c1f3
commit 95ca3fd1fa
78 changed files with 13650 additions and 259 deletions
+16 -8
View File
@@ -243,14 +243,23 @@ def _join(url: str, code: str, token: str) -> None:
async def _start_playing(
url: str, app: Any, ws_client: Any
) -> tuple[str, Any, Any, 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)`."""
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"
@@ -286,7 +295,7 @@ async def _start_playing(
# 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
return code, room, a, b, keepalive
async def _drive_soup(client: Any, pid: str, seq: _Sequencer) -> None:
@@ -437,7 +446,7 @@ 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)
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
@@ -455,9 +464,7 @@ async def test_two_clients_play_a_complete_round(
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)))
# --- 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
@@ -542,7 +549,7 @@ 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)
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)
@@ -587,3 +594,4 @@ async def test_a_third_connection_mid_round_is_a_spectator(
await c.close()
await a.close()
await b.close()
keepalive.cancel()