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
@@ -1,59 +0,0 @@
# Phase 07 — Canvas client, art, and HUD
**Story:** `US-01_create_anonymous_lobby`, `US-04_move_and_cook`, `US-05_orders_score_and_result`
**Context:** `.agents/PLAN.md` §4 (client, snapshot contract), A9, A10, A11, A12
## Objective
Build the browser half: the repo's own SVG tileset, a DPR-aware Canvas 2D renderer that
draws interpolated snapshots, the screen state machine (landing → lobby → countdown →
play → result), and the HUD (orders, score, timer). At the end of this phase a person can
create a lobby, join from a second tab, and actually play level 1 — with desktop keyboard
input only.
## Dependencies
- `06_realtime_websocket_protocol` (complete) — `/ws/{code}`, `welcome`/`lobby`/`snap`/
`countdown`/`round`/`ev`, `docs/PROTOCOL.md`
- `02_level_content_and_validation` (complete) — `/api/levels` for the level list
## Tasks
1. `01_svg_tileset_and_atlas.md` — generated in-repo art: floors, counters, stations, items in every form, cooks, order tickets
2. `02_screen_state_machine_and_lobby_ui.md` — landing, create/join wiring, lobby seats/ready/level picker, result screen
3. `03_snapshot_buffer_and_interpolation.md` — snapshot buffer, delta decode, entity diffing, 100 ms interpolation, the single render loop and composition root
4. `04_canvas_renderer_and_hud.md` — camera fit, layer draw order, sprites, HUD (orders/score/timer/streak), event FX hooks
5. `05_keyboard_input_and_first_playable_e2e.md` — desktop input, first end-to-end browser story, renderer smoke assertions
## Testing & Quality
- E2E (Playwright, one suite per story touched): create lobby and see the code; two tabs
in one lobby see each other's seat; countdown → kitchen renders (canvas pixel-variance
check, not a blank frame); a scripted keyboard cook completes one soup and the HUD score
bumps; result screen shows stars and the retry action.
- Renderer unit-level checks run in the browser: the interpolation math and the snapshot
buffer are pure ES modules exercised by a Playwright page that calls them directly
(`tests/e2e/test_us06_interp_math.py`) — no bundler, no jsdom. Suites added here:
`test_atlas_loads.py`, `test_us01_create_lobby.py`, `test_us02_join_by_code.py`,
`test_us03_ready_and_start.py`, `test_us06_interp_math.py`, `test_us06_smoothness.py`,
`test_us04_render_smoke.py`, `test_us04_move_and_cook.py`.
- Asset guards: every `src`/`href` is same-origin (extends the phase-01 no-CDN test to the
real asset set); no SVG file exceeds 40 KB; the atlas loads with zero console errors.
- Coverage: **>90%** on new/modified server code (any route/encoder tweak); client
correctness is demonstrated by the e2e assertions and pure-module tests above.
- The phase is "Complete" only when the **entire** project test suite passes 100%.
## Documentation & Consistency
- The final pass reviews `README.md` and `AGENTS.md` in full against the project as it
stands after this phase — fixing every inconsistency found, **including pre-existing
ones not caused by this phase**.
- Tasks create `docs/CLIENT.md`: module map, render loop, snapshot buffer, asset
conventions (how to add a sprite), and "no bundler" rules. README gains the
"how to play" section it has been promising.
## Completion Criteria
- [ ] two browser tabs: create in A, join in B, both ready, start, play level 1, and serve one dish end to end with keyboard only
- [ ] `uv run pytest tests/e2e -q` → green (all suites added in this phase)
- [ ] the canvas is provably not blank: a pixel-variance assertion passes during play and changes after a move
- [ ] HUD score/timer/orders update within 200 ms of the server event (e2e-asserted)
- [ ] zero console errors and zero non-same-origin network requests during a full round (e2e-asserted)
- [ ] `docs/CLIENT.md` exists and matches the module layout on disk
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases
@@ -1,62 +0,0 @@
# Task 01 — In-repo SVG tileset, sprites, and atlas loader
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-04_move_and_cook`
## Objective
Create the game's whole visual language as small, in-repo SVG files plus a loader, with no
external art, fonts, or CDNs.
## Work
1. `assets_src/` (source art) and `app/static/assets/` (served output) — hand-authored SVG
(simple, readable geometry — this is a vector-cartoon kitchen, not pixel art):
- `tiles.svg` — floor, counter, wall, void (with 4 subtle variants of floor for texture),
- `stations.svg` — spawn shelf (per ingredient tint), chop board, stove (off/working/
ready/burning), pot, pan, prep counter, serve window, sink, trash,
- `items.svg` — every `ItemKind` × `ItemForm` combination that can appear
(raw/chopped/cooked/burnt/plated/dirty), each 32×32 viewBox,
- `cooks.svg` — 4 cook bodies (palette-swappable via CSS `fill` on a `use`-able symbol),
4 facings × 2 walk frames, plus a `carrying` overlay layer and a `stun` spin frame,
- `fx.svg` — chop spark, steam, flame, splash, deny cross, score pop, sparkly serve,
- `ui.svg` — order ticket, star, crown (host), ready check, disconnect ghost, streak flame.
Keep each file ≤ 40 KB (guard test), one `<symbol id>` per drawable, ids namespaced
(`tile-floor`, `item-tomato-chopped`, `cook-2-front-1`).
2. `tools/gen_art_index.py` — a small stdlib script that parses the SVGs, writes
`app/static/assets/index.json` (`{id: {file, w, h}}`), and fails if a symbol id is
duplicated or if an expected id from `EXPECTED_IDS` (in the script, derived from the
sim enums by importing `undercooked.sim.model`) is missing. Run it as part of the task;
commit the generated `index.json`.
3. `app/static/js/render/atlas.js` — fetches `index.json`, loads each SVG into an `Image`,
exposes `atlas.ready()`, `atlas.draw(ctx, id, x, y, w, h, opts)` (with `tint`,
`alpha`, `flipX`); caches by id; surfaces a `Error` on unknown id (never a silent
nothing-drawn).
4. `app/static/assets/palette.css` — the cook colors, colorblind-safe alternate palette
(`[data-palette="cb"]`), and the semantic colors (danger/hot/ready/dirty) used by both
CSS and canvas (a JS module re-exports the same hex values; a test asserts the two
files agree).
5. `tests/unit/test_assets.py` — every served asset exists, ≤ 40 KB, parses as XML with a
`<symbol>` per id in `index.json`, no external `xlink:href`/`http` reference; the
sim-enum coverage check (every `ItemKind`×reachable form has a symbol); palette hex
agreement between `palette.css` and `js/render/palette.js`.
6. `tests/e2e/test_atlas_loads.py` — loads `/`, requests the atlas, asserts
`atlas.ready()` resolves, zero console errors, and `atlas.draw` on a probe canvas
produces non-blank pixels.
7. Fix the phase-01 guard for XML namespaces: `tests/unit/test_no_external_assets.py`
currently rejects any `http` occurrence, but every SVG legitimately contains
`xmlns="http://www.w3.org/2000/svg"`. Teach the guard an explicit allowlist
(`http://www.w3.org/2000/svg`, `…/xlink`, `…/xml`, `…/1999/xhtml` — namespace
declarations only, never `href`/`src`/`url()` targets) and add a test that a planted
`xlink:href="https://evil.example/x.svg"` still fails while the namespace declaration
passes.
## Testing & Quality
- Unit + e2e as listed; art completeness is enforced against the sim enums, not by eye.
- Coverage: **>90%** on `tools/gen_art_index.py`.
## Completion Criteria
- [ ] `uv run python tools/gen_art_index.py` writes `index.json` and exits 0
- [ ] removing a required symbol makes the generator (and `tests/unit/test_assets.py`) fail with the missing id named
- [ ] every asset is ≤ 40 KB and same-origin
- [ ] `uv run pytest tests/e2e/test_atlas_loads.py -q` → green
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,60 +0,0 @@
# Task 02 — Screen state machine, lobby UI, and result screen
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-01_create_anonymous_lobby`, `US-02_join_by_code`, `US-03_ready_up_and_start`, `US-05_orders_score_and_result`
## Objective
Replace the phase-01 shell placeholders with the real DOM screens and a single client-side
state machine that owns them, driven by server messages.
## Work
1. `app/static/js/app.js` — `createApp()` with an explicit screen enum
(`LANDING, CONNECTING, LOBBY, COUNTDOWN, PLAYING, ROUND_END, ERROR`) and
`show(screen, data)`; unknown transitions log once and go to `ERROR`; every screen is a
module exporting `{mount(root, data), unmount()}` — no global DOM spaghetti.
2. Screens (real HTML, keyboard operable, `aria`-labelled, no innerHTML with server data):
- `screens/landing.js` — *Create lobby*, join form with code input
(`inputmode="text"`, `autocapitalize="characters"`, `spellcheck="false"`,
`maxlength="5"`), inline validation, last-code prefill from `sessionStorage`, and the
offline/"server full" messages from `err` codes.
- `screens/lobby.js` — room code with copy button (clipboard API + fallback), seat list
(`index, name, color chip, ready, crown, disconnected`), *Ready* toggle, host-only
*Level* picker fed by `/api/levels` (difficulty pips, target, time, blurb), *Start*
button enabled from `lobby.can_start`, disabled-with-reason otherwise, session panel
(`/api/session`: unlocks + best scores), spectator notice.
- `screens/countdown.js` — big numerals 3/2/1, reduced-motion-safe.
- `screens/play.js` — canvas host element (`#kcanvas`), HUD root, *Leave* control,
reconnecting overlay (populated in phase 08), `emote` button row (whitelist from
`docs/PROTOCOL.md`).
- `screens/round_end.js` — result, stars (1–3, animated unless reduced motion),
score/target, best, per-player list, counters (served/missed/wasted, longest streak),
`Next level` (only when unlocked) / `Retry` / `Back to lobby`, `aria-live="polite"`
announcement of the outcome.
- `screens/error.js` — friendly copy per `ErrorCode` (`room_full`, `room_gone`,
`protocol_mismatch`, `rate_limited`, `evicted`, …) with the right retry affordance and
the code shown for support.
3. `app/static/js/net/connection.js` (thin here, hardened in phase 08) — `connect(code,
token)`, message dispatch to a single `onMessage` reducer, `send(model)`, and a
`protocol_mismatch` path that stops reconnecting and shows `error`.
4. `app/static/css/screens.css` — layout for the screens, 360 px → 4K, 44 px targets,
focus rings, `[data-palette]` aware, `prefers-reduced-motion` respected.
5. `tests/e2e/test_us01_create_lobby.py`, `test_us02_join_by_code.py`,
`test_us03_ready_and_start.py` — the story suites: create/join/ready/start across two
contexts, full-room and bad-code copy, non-host controls disabled, host crown moves when
the host leaves, countdown then kitchen on both, result screen content after a forced
short round (test-only level with `time_limit: 4`), and `aria-live` announcement of the
result.
6. `docs/CLIENT.md` — screen state machine diagram and the message→screen mapping table.
## Testing & Quality
- E2E: the four story suites above; assert visible text, not pixels, for DOM screens.
- Coverage: **>90%** on any server code touched; client behavior covered by e2e.
- Full suite green including phases 01–06 suites (no regressions).
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us01_create_lobby.py tests/e2e/test_us02_join_by_code.py tests/e2e/test_us03_ready_and_start.py -q` → green
- [ ] two contexts: ready state visible on both, non-host start control disabled, countdown identical on both
- [ ] the result screen shows stars, score/target, best, and per-player rows after a forced short round
- [ ] every interactive element is reachable and operable with the keyboard alone (asserted in the e2e suite)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,67 +0,0 @@
# Task 03 — Snapshot buffer and interpolation
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-06_coop_shared_kitchen`, `US-05_orders_score_and_result`
## Objective
Turn the incoming `snap` stream into smooth motion: a jitter-tolerant buffer, entity
diffing, and render-time interpolation at a fixed delay.
## Work
1. `app/static/js/net/snapshots.js` (pure module, no DOM):
- `createSnapshotBuffer({interpDelayMs = 100, maxAgeMs = 1000})` with
`push(frame)`, `sample(now)` → `{a, b, alpha}` (the two bracketing frames plus the
lerp factor), `latest()`, `needsFullResync()`, `stats()`.
- Rules: frames keyed by `t` (server tick), inserted in order, older-than-current
dropped, `t` gaps recorded (`gaps`), a gap longer than `MAX_GAP_TICKS = 6` triggers
`needsFullResync()` (the client asks for a full snapshot; phase 06's cadence guarantees
one arrives), and if no frame arrives for `maxAgeMs` the buffer freezes rather than
extrapolating forever (never animate a ghost cook into a wall).
- `lerpState(a, b, alpha)` → a render-ready interpolated state: positions lerped along
the shorter path, facing snapped at the alpha ≥ 0.5 boundary, `progress` lerped,
entities added/removed between frames handled (appear at `b`, vanish at `b`), station
and order lists diffed by id.
- `applyDelta(frame, prev)` — mirrors the server's delta encoding (task 04 of phase 06)
into a full frame client-side; correctness is asserted against full frames.
2. `app/static/js/net/decode.js` — the client-side counterpart of `server/encoding.py`:
delta application, enums mapped through `app/static/js/shared/enums.js`, and
`tests/unit/test_client_enum_parity.py` asserting every Python value of `TileKind`,
`StationKind`, `ItemKind`, `ItemForm`, `Facing`, `EventKind`, `ErrorCode` appears in the
matching JS module and vice versa (same drift-guard pattern as the movement constants).
3. `app/static/js/render/loop.js` + `app/static/js/main.js` — one `requestAnimationFrame`
loop (`sample → render → hud.update`, self-measured `frameMs` p50/p95 on `window.__uc`,
paused when `document.hidden` or the screen is not `PLAYING`, `?freeze=1` hook for
pixel-diff tests) and the composition root that parses URL params (`room`, `seed`,
`controls`, `freeze`, `motion`), builds the buffer/hud/connection, and routes messages
(snapshots → buffer, everything else → the screen machine).
4. `app/static/js/net/events.js` — event queue with a per-kind `ttlMs`, dedup window, and
`drift(now)` pruning, so a burst of chop events cannot leak listeners or replay after a
resync (`tick` reset detection).
5. `tests/e2e/test_us06_interp_math.py` — Playwright page that imports the pure module and
asserts, via `page.evaluate`: two frames 1 tick apart lerp to the midpoint at
`alpha 0.5`; out-of-order frames are dropped and counted; the gap rule triggers
`needsFullResync()`; a 30-frame synthetic stream yields monotonic sampled positions with
max step ≤ `MAX_SPEED × (1/snapshot_hz) × 1.35`; frozen buffer holds position;
`applyDelta` output equals the full frame the server would have sent (fixture pair).
6. `tests/e2e/test_us06_smoothness.py` — against the live server, two clients: the observed
remote cook's per-frame pixel delta stays within the bound above, no visual stutter
(frame-to-frame position difference never exceeds the bound) with `sim_latency_ms: 60`
injected, and both converge within 1 s of the round ending.
7. `docs/CLIENT.md` — buffer rules, `interpDelayMs` rationale, resync contract, the render
loop's single-owner rule, and the "never extrapolate forever" policy.
## Testing & Quality
- E2E-driven unit tests for pure math (deterministic fixtures) + live smoothness bounds.
- Coverage: **>90%** on any server code touched; the pure client module's paths are each
exercised by at least one `page.evaluate` assertion.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us06_interp_math.py -q` → green
- [ ] `uv run pytest tests/e2e/test_us06_smoothness.py -q` → green with 60 ms injected latency
- [ ] delta-applied frames equal the full-frame fixture (asserted field by field)
- [ ] a synthetic gap > 6 ticks triggers a resync request (asserted, and debounced to one per 500 ms)
- [ ] `uv run pytest tests/unit/test_client_enum_parity.py -q` → green (removing a JS enum value breaks it: verify once, then revert)
- [ ] the render loop pauses when the tab is hidden and resumes on show (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,60 +0,0 @@
# Task 04 — Canvas renderer, camera, and HUD
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-04_move_and_cook`, `US-05_orders_score_and_result`
## Objective
Draw the kitchen and everything on it, and put the game's readouts on screen — the phase
that makes the game legible.
## Work
1. `app/static/js/render/camera.js` — fit-to-viewport with a min/max zoom in tiles-per-pixel,
integer-ish scaling to avoid shimmer, letterboxing, DPR-aware (`devicePixelRatio`,
capped at 2), resize/visibility handling, `worldToScreen`/`screenToWorld`.
2. `app/static/js/render/canvas.js`:
- explicit layer order: floor → floor decals (spills) → station bases → carried-under
items → players (y-sorted) → carried overlays → station progress rings → hazards →
particles → selection/target highlight,
- `drawFrame(interpolated, now)`; sprite selection maps `TileKind`/`StationKind`+state/
`ItemKind`+form/`Facing`+anim-frame to atlas ids; unknown id throws (surfaced to the
console once, then suppressed to avoid spam),
- animation clocks derived from `now`, pausable when `document.hidden`
(`visibilitychange`), fixed-step sprite animation so 120 Hz displays do not animate
faster,
- `renderStats()` — `drawCalls`, `frameMs` moving average, exposed on `window.__uc` for
the perf assertions;
- target highlight: the tile the server would target (client-side approximation of
`interact.candidate_cells` from the local facing/position) drawn faintly, so reaching
for a station is predictable.
3. `app/static/js/render/fx.js` — event→effect mapping (`CHOPPED` spark, `COOKED`/`READY`
steam, `SERVED` pop + score float, `BURNT` smoke, `DENIED` cross + nudge, `STUNNED`
spin, `ORDER_MISSED` flash, `SCORE` float), a small particle pool (no allocation per
event), and `[data-motion="reduced"]` fallbacks (static markers, no shake).
4. `app/static/js/ui/hud.js` — order rail (ticket per order: recipe icon chain, time bar,
points; the oldest highlighted), score + target bar, countdown timer (turns amber < 20 s,
red < 10 s, with a non-color cue), streak indicator, dirty-dish counter, connection
indicator, spectator banner, all DOM (not canvas) so it stays crisp and readable by
assistive tech; updates coalesced to one write per animation frame.
5. `tests/e2e/test_us04_render_smoke.py`:
- canvas not blank during play (variance over sampled pixels) and changes after a move,
- `renderStats().drawCalls` within a sane band (no per-frame explosion: < 4 000),
- frame time ≤ 16 ms p95 over a 10 s scripted round on the CI profile,
- HUD elements exist and update (score text changes after a serve; timer decreases),
- `document.hidden` simulation pauses the loop (frame count stops advancing),
- reduced-motion mode: no shake transform, hazard markers still visible.
6. `docs/CLIENT.md` — layer order table, atlas id conventions, how to add a new drawable
(SVG symbol → `index.json` → mapping entry), HUD DOM structure.
## Testing & Quality
- E2E: pixel-variance and perf budgets, HUD update assertions, reduced-motion check.
- Coverage: **>90%** on server-side changes; renderer covered by the above plus task 03's
math tests.
- Full suite green.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us04_render_smoke.py -q` → green
- [ ] frame time ≤ 16 ms p95 and `drawCalls` within band are both asserted, not printed
- [ ] every station state and item form is reachable by the mapping (a test walks the mapping keys against the sim enums)
- [ ] HUD changes are visible within 200 ms of the server event (asserted)
- [ ] full test suite **100% passing**
- [ ] README.md/AGENTS.md updated for anything user-visible this task changes (the phase's final pass re-checks both project-wide)
- [ ] no behavior change in completed work
@@ -1,57 +0,0 @@
# Task 05 — Keyboard input and the first playable end-to-end story
**Phase:** `07_canvas_client_and_hud` · **Story:** `US-04_move_and_cook`
## Objective
Close the loop: desktop keyboard control wired to the protocol, and a browser test that
plays a real soup from shelf to serve window — the first time the whole stack is proven by
a single test.
## Work
1. `app/static/js/input/keyboard.js`:
- WASD + arrow keys → an 8-way intent vector; opposite keys cancel; the vector is sent
only when it changes, plus a keep-alive empty input at ≥ 5 Hz,
- `Space`/`Enter` = action (press + hold tracked with `action_held_ticks` semantics from
the protocol), `Escape` = leave/confirm-cancel, `1`–`4` = emotes,
- `seq` counter per connection, monotonic, reset only on a new connection,
- ignores key events when focus is in an input field, when `document.hidden`, or when a
modifier is held (browser shortcuts survive: no `preventDefault` on
Ctrl/Cmd/Meta combos),
- `blur`/`visibilitychange`/pagehide → send a zero-intent input and release the action
(no cook running away when you alt-tab),
- key map exposed via `getKeyMap()` and rendered in the help panel (also used by
`README.md`'s controls section — keep them identical; a test asserts the two agree).
2. `app/static/ui/help.js` + a `?`-key help overlay listing the controls (no external
docs needed mid-game).
3. `tests/e2e/test_us04_move_and_cook.py` — the flagship suite, against the live server on
level 1 with a fixed seed (test-only `?seed=` query param honored when
`UNDERCOOKED_TEST_MODE=1`, documented):
- `test_moves_and_faces` — key presses produce the expected position deltas,
- `test_stops_at_walls` — walking into a wall does not pass through and does not jitter,
- `test_serves_one_soup` — the full chain (grab tomato, grab onion, chop both, pot,
cook, plate, serve) with the HUD score bump and the order ticket disappearing,
- `test_denied_out_of_reach` — an early press shows the deny feedback and the item stays,
- `test_two_players_same_target` — two contexts press simultaneously; exactly one gets
the item, the other sees `denied`, and the server's item count is conserved,
- `test_alt_tab_parks_the_cook` — hidden tab sends zero intent.
4. `tests/e2e/README.md` — how to run the browser suites (start the server, install
chromium), what `?seed=` and the test seam do, and why the flagship suite is the
release gate.
5. README "How to play" section: controls, objectives, tips (chop before pot, wash or run
out of plates), and a pointer to the in-game help overlay.
## Testing & Quality
- E2E: the flagship suite above (this is the suite every later phase must keep green).
- Coverage: **>90%** on any server-side change (e.g. the test-only seed seam); keyboard
logic verified through the browser.
- Full suite green, including all prior phases' suites.
## Completion Criteria
- [ ] `uv run pytest tests/e2e/test_us04_move_and_cook.py -q` → green, including the full soup chain
- [ ] a human can create a lobby, start, cook, and serve with the keyboard (record what you saw in the phase report)
- [ ] two contexts on the same target produce exactly one winner and one `denied` (asserted)
- [ ] alt-tab sends zero intent (asserted via the server's observed position freeze)
- [ ] README control list matches `getKeyMap()` exactly (test-enforced)
- [ ] full test suite **100% passing**, coverage >90%
- [ ] README.md and AGENTS.md are accurate and consistent with the whole project (verified in the final pass)
- [ ] no behavior change in completed phases