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
+7 -5
View File
@@ -130,9 +130,10 @@
inbound field against the room's state (`server/protocol.py`).
- **Anonymous by design.** No accounts, no emails, no cookies with personal data.
Identity = 24 random bytes per browser tab (`secrets.token_urlsafe(24)` when the
server mints one, `crypto.getRandomValues` + base64url in `app/static/js/app.js`,
which is the same entropy and the only option in a browser) held in
`sessionStorage`. Names are sanitized, length-capped, non-authoritative.
server mints one, `crypto.getRandomValues` + base64url in
`app/static/js/identity.js`, which is the same entropy and the only option
in a browser) held in `sessionStorage`. Names are sanitized, length-capped,
non-authoritative.
- **Movement is swept, never corrected.** Stage 3 (`sim/movement.py`) integrates one axis
at a time and clamps each axis with `sim/collision.sweep_axis()` to the greatest legal
delta, so a body is stopped *before* it intrudes — a cook never ends a tick inside a
@@ -197,10 +198,11 @@
| HTTP app (`/`, `/healthz`, `/static`), lifespan, startup content gate, the `room_close` hook (a closing room closes its own sockets with `err room_gone` + close `4401`) and the test-only seam `POST /api/test/kill_socket/{code}/{token}` (registered only when `UNDERCOOKED_TEST_MODE=1`) | `src/undercooked/server/app.py` |
| Content HTTP API (`/api/levels`, `/api/levels/{id}`, `/api/config`) + strict response models | `src/undercooked/server/routes_content.py` |
| Author CLI `undercooked-levels` (`validate`, `list`, `show`, `new`, `axes`); workflow in `docs/LEVELS.md` | `src/undercooked/tools/levels.py` |
| In-repo SVG art: `assets_src/` (source) served from `app/static/assets/` (copies kept in sync by the generator's check) with `index.json`; the palette (`assets/palette.css` + `js/render/palette.js` — `tests/unit/test_assets.py` keeps the two files' hex values identical in both scopes and pins the SVG `var()` fallbacks to palette entries); the atlas loader (`js/render/atlas.js` — decodes each symbol through a `data:` URL because the page CSP blocks the art's `style` hooks and browsers do not render `#symbol` fragments; throws on unknown id); the index generator (a small stdlib script — `uv run python -m undercooked.tools.gen_art_index` — whose expected ids derive from the sim's enums + the `sim/stations.py` pipeline tables, and which fails naming a duplicated or missing id) | `assets_src/`, `app/static/assets/`, `app/static/js/render/`, `src/undercooked/tools/gen_art_index.py` (guards: `tests/unit/test_assets.py`, `tests/e2e/test_atlas_loads.py`) |
| The replay harness: input scripts, golden hashes, `run_script`/`compare`, the `record`/`check` CLI (contract: `docs/SIMULATION.md` "Replaying and re-blessing goldens") | `src/undercooked/sim/replay.py`, `tests/fixtures/replays/`, `tests/goldens/`, `tests/sim/test_replay.py`, `tests/sim/test_perf_smoke.py` (`slow`) |
| Security headers (CSP `default-src 'self'`, no-sniff, frame-ancestors) | `src/undercooked/server/headers.py` |
| CLI entry point (`undercooked`): flags → `--help` → serve | `src/undercooked/__main__.py` |
| Renderer, HUD, input, interpolation | `app/static/js/`, `app/static/assets/` |
| The client app: the composition root (`app/static/js/main.js` — the page's single entry module: the URL params, the snapshot buffer + event queue + the single render loop, the wire routing, the `window.__uc` census seam; `main-flag.js` sets the auto-boot guard before `app.js`), the screen state machine (`app/static/js/app.js` — `show()` clears `#screen-root` and mounts exactly one screen, the message→screen routing, the test seam `window.__undercooked`, the lobby-state re-broadcasts drive the lobby screen), the snapshot stream's client half (`app/static/js/net/` — `decode.js` the `applyDelta` mirror of `server/encoding.py`, `snapshots.js` the keyed buffer + `lerpState` + the gap/resync/freeze rules (injected clock), `events.js` the per-kind TTL + dedup + tick-reset queue), the enum parity (`app/static/js/shared/enums.js` — the wire's enum sets, set-equal to the Python enums both directions, `tests/unit/test_client_enum_parity.py`), the seven screens (`app/static/js/screens/` — landing / connecting / lobby / countdown / play / round-end / error, each `mount`/`unmount`; the `dom.js` in that directory is the shared no-innerHTML builder, not a screen), the WebSocket layer (`app/static/js/net/connection.js` — the reconnect loop, the stale-close guard), the identity (`identity.js`), the keyboard input + help overlay (task 05: `app/static/js/input/keyboard.js` — WASD/arrows → the 8-way intent, `Space`/`Enter` the action's rising edge, `1`–`4` the emotes, the key-hold pump (~48 ms ≈ 20.8 Hz, above the 20 Hz tick — a tick with no frame brakes the body) + the 10 Hz zero-intent keep-alive (the ≥ 5 Hz protocol rule), the ignore rules (input focus, `document.hidden`, modifiers), the blur/hidden/pagehide forced zero, `?controls=off` binds nothing (the scripted-test mode — the e2e suites drive the wire through the seam) and `getKeyMap()` (the canonical key map the help overlay and the README's `### Controls` render, `tests/e2e/test_us04_move_and_cook.py` keeps them identical), `app/static/js/ui/help.js` — the `?`/`Esc` help dialog, the key-map rows, no external docs mid-game), the single render loop (`app/static/js/render/loop.js` — pauses on `document.hidden`, idle off-`PLAYING`, the self-measured `frameMs` census), the Canvas renderer + HUD (task 04: `app/static/js/render/camera.js` the fit-to-viewport / DPR / world⇄screen, `app/static/js/render/canvas.js` the layer-order `drawFrame` + the sprite mapping + `renderStats` + the target highlight, `app/static/js/render/fx.js` the event→effect particle pool + the reduced-motion fallbacks, `app/static/js/ui/hud.js` the DOM HUD — the order rail, the score/target, the non-colour-cue countdown, the streak, the dirty counter, the connection, the spectator; one diff-write per frame, no rAF of its own) — contract: `docs/CLIENT.md` (the module map, the machine, the message table, the buffer/loop section, the keyboard-input section, the renderer/HUD section; guards: `tests/e2e/test_us01_landing_surface.py`, `test_us01_create_lobby.py`, `test_us02_join_by_code.py`, `test_us03_ready_and_start.py`, `test_atlas_loads.py`, `test_us04_render_smoke.py`, `test_us06_interp_math.py`, `test_us06_smoothness.py`, `tests/unit/test_client_sprite_map.py`) | `app/static/js/`, `app/static/assets/`, `app/static/index.html` |
| Documentation (design / testing / deployment; per-phase docs) | `docs/*.md`, indexed by PLAN §13 and guarded by `tests/unit/test_docs_index.py` |
| The pipeline gate itself | `.agents/validate.sh` |
| User-visible flow contract | `.agents/user_stories/US-XX_*.md` |
@@ -211,7 +213,7 @@ Tests mirror that: `tests/unit/` (pure, no-I/O logic incl. HTTP-route unit tests
the level validator, the seeded RNG's golden vectors and the tick's determinism —
plus the replay harness: committed goldens in `tests/goldens/`, scripts in
`tests/fixtures/replays/`, and the `slow` perf budgets — the random-walk
smoke and the full-pipeline run), `tests/e2e/` (Playwright, named `test_usNN_*.py`, marked `e2e`). `tests/` is
smoke and the full-pipeline run), `tests/e2e/` (Playwright, named `test_usNN_*.py` — plus the non-story asset guard `test_atlas_loads.py` — marked `e2e`). `tests/` is
not a package; shared fixtures live in `tests/conftest.py`, and the autouse fixture there
points `UNDERCOOKED_LEVELS_DIR` at `tests/fixtures/levels/` so no test silently reads the
shipped `levels/`.