diff --git a/.agents/phases/todo/07_canvas_client_and_hud/00_phase.md b/.agents/phases/complete/07_canvas_client_and_hud/00_phase.md similarity index 100% rename from .agents/phases/todo/07_canvas_client_and_hud/00_phase.md rename to .agents/phases/complete/07_canvas_client_and_hud/00_phase.md diff --git a/.agents/phases/todo/07_canvas_client_and_hud/01_svg_tileset_and_atlas.md b/.agents/phases/complete/07_canvas_client_and_hud/01_svg_tileset_and_atlas.md similarity index 100% rename from .agents/phases/todo/07_canvas_client_and_hud/01_svg_tileset_and_atlas.md rename to .agents/phases/complete/07_canvas_client_and_hud/01_svg_tileset_and_atlas.md diff --git a/.agents/phases/todo/07_canvas_client_and_hud/02_screen_state_machine_and_lobby_ui.md b/.agents/phases/complete/07_canvas_client_and_hud/02_screen_state_machine_and_lobby_ui.md similarity index 100% rename from .agents/phases/todo/07_canvas_client_and_hud/02_screen_state_machine_and_lobby_ui.md rename to .agents/phases/complete/07_canvas_client_and_hud/02_screen_state_machine_and_lobby_ui.md diff --git a/.agents/phases/todo/07_canvas_client_and_hud/03_snapshot_buffer_and_interpolation.md b/.agents/phases/complete/07_canvas_client_and_hud/03_snapshot_buffer_and_interpolation.md similarity index 100% rename from .agents/phases/todo/07_canvas_client_and_hud/03_snapshot_buffer_and_interpolation.md rename to .agents/phases/complete/07_canvas_client_and_hud/03_snapshot_buffer_and_interpolation.md diff --git a/.agents/phases/todo/07_canvas_client_and_hud/04_canvas_renderer_and_hud.md b/.agents/phases/complete/07_canvas_client_and_hud/04_canvas_renderer_and_hud.md similarity index 100% rename from .agents/phases/todo/07_canvas_client_and_hud/04_canvas_renderer_and_hud.md rename to .agents/phases/complete/07_canvas_client_and_hud/04_canvas_renderer_and_hud.md diff --git a/.agents/phases/todo/07_canvas_client_and_hud/05_keyboard_input_and_first_playable_e2e.md b/.agents/phases/complete/07_canvas_client_and_hud/05_keyboard_input_and_first_playable_e2e.md similarity index 100% rename from .agents/phases/todo/07_canvas_client_and_hud/05_keyboard_input_and_first_playable_e2e.md rename to .agents/phases/complete/07_canvas_client_and_hud/05_keyboard_input_and_first_playable_e2e.md diff --git a/AGENTS.md b/AGENTS.md index 94d932c..9954ad4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/`. diff --git a/README.md b/README.md index b0105af..4da68e4 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,15 @@ movement and collision, the action button, the hands, the replay goldens), serve scoring, freshness and the dirty loop, the round verdict with stars) and **05** (anonymous lobbies and room lifecycle: the `RoomSupervisor`, the guarded `Room` FSM, the in-memory session store, the lifecycle -integration suite) and **06** (the realtime WebSocket protocol: the +integration suite), **06** (the realtime WebSocket protocol: the `/ws/{code}` transport, the versioned JSON wire contract with inbound guardrails, input ingestion with sequence discipline, the quantized -snapshot stream, heartbeats and seat grace — `docs/PROTOCOL.md`) -are **complete**; the next pending phase is -`07_canvas_client_and_hud`. What -runs today: +snapshot stream, heartbeats and seat grace — `docs/PROTOCOL.md`) and +**07** (the canvas client, art and HUD: the in-repo SVG atlas, the +screen state machine, the snapshot buffer with 100 ms interpolation, +the DPR-aware Canvas renderer, the DOM HUD and the desktop keyboard +input — `docs/CLIENT.md`) are **complete**; the next pending phase is +`08_client_prediction_and_touch`. What runs today: - **The service.** The Python toolchain is locked (`uv.lock`), configuration is a single typed, validated `Settings` object (`DEBUGPY` support included), and @@ -221,9 +223,7 @@ runs today: criterion 4), the seat-grace and lobby-idle expiries through the GC, and one room's crash closing only itself (the test-only `_test_step_override` seam on `Room`; contract: `docs/ROOMS.md` "Failure and expiry" and - "The lifecycle walkthrough"). The client buttons in the page still - answer "Not implemented until phase 07." until the phase-07 client - wiring replaces them. + "The lifecycle walkthrough"). - **The WebSocket transport (phase 06, task 02).** The room core is on the wire: `GET /api/stats` answers the ops census (`rooms` / `players` — the occupied seats / `sockets` — live WebSocket connections / @@ -334,24 +334,56 @@ runs today: merged snapshot view (`expect` / `collect` / `send_model`). The contract, the test seams and the budget table with the measured values are `docs/PROTOCOL.md` ("Testing the protocol"). +- **The canvas client, art, and the HUD (phase 07).** The browser half + is live on top of all of this: the in-repo SVG atlas (`assets_src/` + → the served copies in `app/static/assets/`; `gen_art_index` + regenerates the `index.json`, and a missing or duplicated symbol + fails the build, the expected ids deriving from the sim's own enums + and the `sim/stations.py` pipeline tables), the screen state machine + (the page's landing create/join, the lobby with its code copy, + level picker, ready-up, start and seat chips, the 3-2-1 countdown, + the round-end and the error screens, all on the same + `WS /ws/{code}` socket), the snapshot buffer with the 100 ms + interpolation and the event queue, the single render loop, the + DPR-aware Canvas renderer (the kitchen floor, the stations, the + y-sorted cooks, the carried items, the progress rings, the hazards, + the particles, the target highlight) and the DOM HUD (the order + rail, the score/target, the countdown with its non-colour cue, the + streak, the dirty counter, the connection indicator, the spectator + banner); the desktop keyboard input (WASD/arrows, the action + button, the emotes, the `?` help overlay — the touch surface is + phase 08) closes it out. `docs/CLIENT.md` is the module map and the + contract; the proof is the e2e suites — `test_us01_create_lobby.py`, + `test_us02_join_by_code.py`, `test_us03_ready_and_start.py` (the + lobby flow), `test_us04_move_and_cook.py` (a scripted keyboard cook + serves a soup) and `test_us04_render_smoke.py` (the canvas is + provably not blank and moves after input; the HUD score changes + within 200 ms of the `served` event), `test_us06_interp_math.py` + and `test_us06_smoothness.py` (the interpolation math, exercised + through a real page; per-tick displacement bounded with 60 ms of + injected latency, both clients converge on `ROUND_END`), and + `test_atlas_loads.py` (the atlas decodes with zero console errors; + `test_us01_landing_surface.py` asserts the page's only network + requests are same-origin). - **The rules are tests, not promises.** The no-external-asset rule is `tests/unit/test_no_external_assets.py`, "no level or recipe id in Python" is `tests/unit/test_content_not_hardcoded.py`, the documentation set is guarded by `tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the phase pipeline runs after every task. -What does not exist yet: the hazards (phase 10) and the -in-browser -playable Canvas renderer (07 — the phase-06 WebSocket transport, including -the snapshot stream, is live but the client still answers "Not -implemented until phase 07."). Execute phases with the -`phased-execution` skill -(`auto-phase.sh`); it always takes the lowest-numbered directory still in -`.agents/phases/todo/`. +What does not exist yet: client prediction, reconciliation and the +touch surface (phase 08), the two-browser co-op E2E with +contested-resource arbitration (phase 09), the hazards and their +client FX (phase 10), the rest of the ten-level campaign (phase 11), +reconnect resilience and the 200-room stress run (phase 12), endless +mode with the session scoreboard (phase 13), and the accessibility +polish and hardening pass (phase 14). Execute phases with the +`phased-execution` skill (`auto-phase.sh`); it always takes the +lowest-numbered directory still in `.agents/phases/todo/`. ## How to play -The intended loop, once the room flow ships: open the site, **Create lobby**, read out +The loop, live in the browser today: open the site, **Create lobby**, read out the 5-character code, everyone else **Joins** with it, the host picks a level, the room readies up, and the kitchen starts. Each cook has one movement control and **one action button** that picks up, uses and puts down whatever is in reach — one carried item at a @@ -362,22 +394,41 @@ keeps only a random per-tab token and the last code you typed, both in `sessionS The campaign does remember per token — unlocks and best scores live server-side, in memory, and a server restart clears all progression (A6: no database). -What actually works today is the shell of that plus the server-side room -shell and the phase-06 WebSocket transport: -the page loads, normalises a typed code to five uppercase alphanumerics (codes -themselves are Crockford base32, minted by the room supervisor — `server/rooms.py`, -phase 05, which is live and testable over `POST /api/rooms` / `GET /api/rooms/{code}` / -`POST /api/rooms/{code}/join` / `GET /api/session`), a `WS /ws/{code}` -client can already create a room over HTTP, handshake (`hello` → `welcome` -+ `lobby`), ready up and keep alive — the full contract, including the -guardrails and the close codes, is `docs/PROTOCOL.md` — but the lobby -buttons in the page still answer "Not implemented -until phase 07." until the phase-07 client -wiring replaces them. The ready-up, the 20 Hz tick ownership, the session store -(the unlocks and best scores, in memory — a server restart clears all -progression) and the lifecycle integration suite are live. Movement keys and -touch controls are defined by the phase-07/08 client and documented in -`docs/CLIENT.md` once that phase writes it. +The page does that loop end to end: it loads, +normalises a typed code to five uppercase alphanumerics (codes +themselves are Crockford base32, minted by the room supervisor — +`server/rooms.py`, phase 05, also testable over `POST /api/rooms` / +`GET /api/rooms/{code}` / `POST /api/rooms/{code}/join` / +`GET /api/session`), the landing's **Create lobby** / **Join with code** +open a `WS /ws/{code}` socket (`hello` → `welcome` + `lobby`), the lobby +shows the code, the level picker (fed by `/api/levels`), the ready and +start controls and the seat chips, the 3-2-1 countdown runs, and the +round-end screen reports the verdict — the full contract, including the +guardrails and the close codes, is `docs/PROTOCOL.md`, the client's own +contract is `docs/CLIENT.md`. The playable Canvas renderer, the HUD and +the desktop keyboard controls are live (below; the in-game `?` help +overlay renders the same rows) — the touch controls arrive with phase 08. +The ready-up, the +20 Hz tick ownership, the +session store (the unlocks and best scores, in memory — a server restart +clears all progression) and the lifecycle integration suite are live. + +### Controls + +- **W / A / S / D** — move (or the arrow keys) +- **Space / Enter** — action (pick, work, set down, serve) +- **1 / 2 / 3 / 4** — emote (wave, cheer, thumbs up, thumbs down) +- **?** — help overlay +- **Esc** — close help / leave the room + +The goal is the HUD's target score before the level's deadline: an order +ticket names a dish, and the action button does everything a hand does — +pick the ingredient off a shelf, work it on a station (hold the button +while the station works), plate it and serve it at the window. One +carried item at a time; a set-down marks the item so a partner can +pass it. `?` opens the help overlay (the same rows, rendered in-game); +`Esc` closes it — or leaves the room. The `?controls=off` URL param +disables the bindings (the touch surface's seam, phase 08). ## Quickstart (development) @@ -419,6 +470,7 @@ uv run undercooked-levels show 01_first_kitchen --grid # resolved level + reach uv run undercooked-levels validate # the content gate; --json for scripts uv run undercooked-levels new 03_riverboat # scaffold a level from the recipe book uv run undercooked-levels axes # escalation-axis table (phase 11's curve) +uv run python -m undercooked.tools.gen_art_index # rebuild app/static/assets/index.json from assets_src/ ``` `undercooked-levels` is a thin shell over the loader and the PLAN §8 rules @@ -430,7 +482,13 @@ and never from the current directory, so it works from any path. Exit codes: failure, unknown id, refused overwrite, usage error). The schema, every field, the overlay trick and a "which rule did I just hit" table live in [docs/LEVELS.md](docs/LEVELS.md); `tests/unit/test_content_not_hardcoded.py` -is what keeps levels and recipes out of the Python. +is what keeps levels and recipes out of the Python. The in-repo SVG art +(phase 07 task 01) is guarded the same way: `gen_art_index` fails with the +missing id named when a symbol is absent or duplicated, and its expected set is +derived from the simulation's own enums and pipeline tables, so a new +`ItemKind`/`ItemForm` the sim can produce breaks the build until a symbol is +drawn for it (guards: `tests/unit/test_assets.py`, +`tests/e2e/test_atlas_loads.py`). Run the checks: @@ -543,6 +601,11 @@ src/undercooked/sim/replay.py the replay harness: the input-script format, the src/undercooked/tools/levels.py the `undercooked-levels` author CLI — validate, list, show [--grid], new, axes; same loader, same rules, no hard-coded content list (docs/LEVELS.md "Authoring workflow") +src/undercooked/tools/gen_art_index.py the SVG atlas index generator — rebuilds + app/static/assets/index.json from assets_src/; the expected + ids derive from the sim's enums + the station pipeline + tables, a missing or duplicated id fails (guards: + tests/unit/test_assets.py) src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`, mounts the content API and the room API; installs the supervisor src/undercooked/server/clock.py injectable time: `Clock`, `SystemClock`, `FakeClock` @@ -572,8 +635,25 @@ src/undercooked/server/headers.py the security-header middleware .env.example every configuration variable, with its default levels/ data-driven content (YAML): the recipe book (A8) and the first two campaign levels (01_first_kitchen, 02_double_shift) -app/static/ browser client: the ES-module shell today; Canvas 2D renderer - and this repo's SVG art arrive with phase 07 +assets_src/ the hand-authored SVG art (tiles, stations, items, cooks, + fx, ui) — source for the served copies in + app/static/assets/ (a guard test keeps them in sync) +app/static/ the browser client (ES modules, no bundler): + the composition root (`js/main.js`), the screen + state machine (`js/app.js`) and the seven screens + (`js/screens/`, the no-innerHTML DOM builder + `dom.js`), the snapshot buffer + 100 ms + interpolation (`js/net/`), the single render loop + (`js/render/loop.js`), the DPR-aware Canvas 2D + renderer (`js/render/canvas.js` + `camera.js` + + `fx.js`), the DOM HUD (`js/ui/hud.js`), the + desktop keyboard input (`js/input/keyboard.js`) + and the help overlay (`js/ui/help.js`); the + in-repo SVG atlas with its `index.json`, the + palette (`assets/palette.css` + + `js/render/palette.js`, kept hex-identical by a + test) and the atlas loader (`js/render/atlas.js`) + (contract: docs/CLIENT.md) tests/ unit/, integration/, e2e/ (one Playwright suite per story) and sim/ (the pure kernel: grid, entities, purity guard, validation, the seeded RNG and the tick; plus the replay harness — committed @@ -587,10 +667,11 @@ docs/ DESIGN.md, LEVELS.md (the level & recipe YAML contract), SIMULATION.md (the tick contract), ROOMS.md (rooms & lobby lifecycle), PROTOCOL.md (the v1 wire contract — message tables, error/close codes, limits, the full-round - sequence, the test seams and the budget table), TESTING.md - (how this project - is verified), DEPLOYMENT.md; CLIENT lands with - phase 07 (the index is PLAN.md §13) + sequence, the test seams and the budget table), CLIENT.md + (the client module map, the render loop, the snapshot + buffer, the input, the assets), TESTING.md (how this + project is verified), DEPLOYMENT.md (the index is + PLAN.md §13) .agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan ``` diff --git a/app/static/assets/cooks.svg b/app/static/assets/cooks.svg new file mode 100644 index 0000000..4227619 --- /dev/null +++ b/app/static/assets/cooks.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/static/assets/fx.svg b/app/static/assets/fx.svg new file mode 100644 index 0000000..aef8038 --- /dev/null +++ b/app/static/assets/fx.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/static/assets/index.json b/app/static/assets/index.json new file mode 100644 index 0000000..ebfe45c --- /dev/null +++ b/app/static/assets/index.json @@ -0,0 +1,527 @@ +{ + "cook-1-down-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-1-down-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-1-left-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-1-left-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-1-right-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-1-right-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-1-up-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-1-up-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-2-down-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-2-down-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-2-left-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-2-left-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-2-right-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-2-right-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-2-up-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-2-up-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-3-down-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-3-down-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-3-left-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-3-left-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-3-right-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-3-right-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-3-up-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-3-up-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-4-down-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-4-down-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-4-left-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-4-left-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-4-right-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-4-right-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-4-up-0": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-4-up-1": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-carry": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "cook-stun": { + "file": "cooks.svg", + "h": 32.0, + "w": 32.0 + }, + "fx-chop": { + "file": "fx.svg", + "h": 32.0, + "w": 32.0 + }, + "fx-deny": { + "file": "fx.svg", + "h": 32.0, + "w": 32.0 + }, + "fx-flame": { + "file": "fx.svg", + "h": 32.0, + "w": 32.0 + }, + "fx-score": { + "file": "fx.svg", + "h": 32.0, + "w": 32.0 + }, + "fx-serve": { + "file": "fx.svg", + "h": 32.0, + "w": 32.0 + }, + "fx-splash": { + "file": "fx.svg", + "h": 32.0, + "w": 32.0 + }, + "fx-steam": { + "file": "fx.svg", + "h": 32.0, + "w": 32.0 + }, + "item-dough-burnt": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-dough-chopped": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-dough-cooked": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-dough-cooking": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-dough-raw": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-fish-burnt": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-fish-chopped": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-fish-cooked": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-fish-cooking": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-fish-raw": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-lettuce-burnt": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-lettuce-chopped": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-lettuce-cooked": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-lettuce-cooking": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-lettuce-raw": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-onion-burnt": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-onion-chopped": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-onion-cooked": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-onion-cooking": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-onion-raw": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-patty-burnt": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-patty-chopped": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-patty-cooked": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-patty-cooking": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-patty-raw": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-plate-dirty": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-plate-plated": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-plate-raw": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-rice-burnt": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-rice-cooked": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-rice-cooking": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-rice-raw": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-tomato-burnt": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-tomato-chopped": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-tomato-cooked": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-tomato-cooking": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "item-tomato-raw": { + "file": "items.svg", + "h": 32.0, + "w": 32.0 + }, + "station-chop_board": { + "file": "stations.svg", + "h": 32.0, + "w": 32.0 + }, + "station-pan": { + "file": "stations.svg", + "h": 32.0, + "w": 32.0 + }, + "station-pot": { + "file": "stations.svg", + "h": 32.0, + "w": 32.0 + }, + "station-prep": { + "file": "stations.svg", + "h": 32.0, + "w": 32.0 + }, + "station-serve": { + "file": "stations.svg", + "h": 32.0, + "w": 32.0 + }, + "station-sink": { + "file": "stations.svg", + "h": 32.0, + "w": 32.0 + }, + "station-spawn_shelf": { + "file": "stations.svg", + "h": 32.0, + "w": 32.0 + }, + "station-stove": { + "file": "stations.svg", + "h": 32.0, + "w": 32.0 + }, + "station-stove-burning": { + "file": "stations.svg", + "h": 32.0, + "w": 32.0 + }, + "station-stove-off": { + "file": "stations.svg", + "h": 32.0, + "w": 32.0 + }, + "station-stove-ready": { + "file": "stations.svg", + "h": 32.0, + "w": 32.0 + }, + "station-stove-working": { + "file": "stations.svg", + "h": 32.0, + "w": 32.0 + }, + "station-trash": { + "file": "stations.svg", + "h": 32.0, + "w": 32.0 + }, + "tile-counter": { + "file": "tiles.svg", + "h": 32.0, + "w": 32.0 + }, + "tile-floor": { + "file": "tiles.svg", + "h": 32.0, + "w": 32.0 + }, + "tile-floor-1": { + "file": "tiles.svg", + "h": 32.0, + "w": 32.0 + }, + "tile-floor-2": { + "file": "tiles.svg", + "h": 32.0, + "w": 32.0 + }, + "tile-floor-3": { + "file": "tiles.svg", + "h": 32.0, + "w": 32.0 + }, + "tile-floor-4": { + "file": "tiles.svg", + "h": 32.0, + "w": 32.0 + }, + "tile-void": { + "file": "tiles.svg", + "h": 32.0, + "w": 32.0 + }, + "tile-wall": { + "file": "tiles.svg", + "h": 32.0, + "w": 32.0 + }, + "ui-crown": { + "file": "ui.svg", + "h": 32.0, + "w": 32.0 + }, + "ui-ghost": { + "file": "ui.svg", + "h": 32.0, + "w": 32.0 + }, + "ui-ready": { + "file": "ui.svg", + "h": 32.0, + "w": 32.0 + }, + "ui-star": { + "file": "ui.svg", + "h": 32.0, + "w": 32.0 + }, + "ui-streak": { + "file": "ui.svg", + "h": 32.0, + "w": 32.0 + }, + "ui-ticket": { + "file": "ui.svg", + "h": 32.0, + "w": 32.0 + } +} diff --git a/app/static/assets/items.svg b/app/static/assets/items.svg new file mode 100644 index 0000000..64899c1 --- /dev/null +++ b/app/static/assets/items.svg @@ -0,0 +1,202 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/static/assets/palette.css b/app/static/assets/palette.css new file mode 100644 index 0000000..81ff890 --- /dev/null +++ b/app/static/assets/palette.css @@ -0,0 +1,34 @@ +/* Undercooked palette (phase 07, task 01). + * + * The single source of every colour the DOM and the Canvas 2D renderer + * both use. The canvas half re-exports the same hex values from + * app/static/js/render/palette.js; tests/unit/test_assets.py asserts the + * two files agree, so the CSS and the canvas can never drift apart. + * + * The four cook colours are what a player sees their own and their + * teammates' cooks in; [data-palette="cb"] swaps them for the Okabe-Ito + * set, which stays distinguishable under deuteranopia/protanopia. The + * semantic colours name the game's state vocabulary (a danger reads red, + * a ready reads green, dirt reads mud) and are shared by the CSS, the + * JS module and the SVG var() fallbacks in app/static/assets/. + */ + +:root { + --uc-cook-1: #e0533d; + --uc-cook-2: #3d84a8; + --uc-cook-3: #7da84b; + --uc-cook-4: #b0699a; + --uc-danger: #ff5a4e; + --uc-hot: #ffa63d; + --uc-ready: #67d16c; + --uc-dirty: #9a8f7a; +} + +/* Okabe-Ito cook colours: colourblind-safe (deutan/protan) without + losing the "four different cooks" signal. */ +[data-palette="cb"] { + --uc-cook-1: #d55e00; + --uc-cook-2: #0072b2; + --uc-cook-3: #009e73; + --uc-cook-4: #cc79a7; +} diff --git a/app/static/assets/stations.svg b/app/static/assets/stations.svg new file mode 100644 index 0000000..f958357 --- /dev/null +++ b/app/static/assets/stations.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/static/assets/tiles.svg b/app/static/assets/tiles.svg new file mode 100644 index 0000000..ff4c8ec --- /dev/null +++ b/app/static/assets/tiles.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/static/assets/ui.svg b/app/static/assets/ui.svg new file mode 100644 index 0000000..4c8f054 --- /dev/null +++ b/app/static/assets/ui.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/static/css/screens.css b/app/static/css/screens.css new file mode 100644 index 0000000..779bc32 --- /dev/null +++ b/app/static/css/screens.css @@ -0,0 +1,662 @@ +/* Undercooked — screen styles (phase 07 task 02). + * + * Rules this file must keep (base.css's, extended): + * - every byte is same-origin: no @import, no web fonts, no remote images (A12); + * - the palette, spacing and type scale exist only as base.css custom properties, + * so a `[data-palette]` swap (e.g. `data-palette="cb"` for the colourblind-safe + * alternate in assets/palette.css) recolours the screens automatically; + * - 44x44 minimum hit targets (base.css --uc-hit) on every interactive control; + * - the layout is fluid from a 360px phone to a 4K desktop without breakpoints; + * - motion is decorative: prefers-reduced-motion turns all of it off; + * - visible focus rings: base.css's :focus-visible rule covers every element here. + */ + +.screen-root { + display: grid; + justify-items: center; +} + +/* The screens share the phase-01 panel skin; each owns its content. */ +.panel.lobby, +.panel.result, +.panel.error, +.panel.connecting, +.panel.countdown { + max-width: 40rem; +} + +.section-label { + margin: 0; + font-size: var(--uc-step--1); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--uc-ink-muted); +} + +/* ------------------------------------------------------------- landing */ + +.join-field { + display: grid; + gap: var(--uc-space-1); + flex: 1 1 10rem; +} + +/* ------------------------------------------------------------- connecting */ + +.connecting { + text-align: center; +} + +#connecting-text { + margin: 0; + font-size: var(--uc-step-1); +} + +/* ------------------------------------------------------------- lobby */ + +.lobby-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--uc-space-2); +} + +.room-code { + margin: 0; + font-family: var(--uc-font-ui); + font-size: var(--uc-step-2); + font-weight: 800; + letter-spacing: 0.3em; + color: var(--uc-accent); +} + +.code-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--uc-space-2); +} + +.seat-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: var(--uc-space-1); +} + +.seat { + display: flex; + align-items: center; + gap: var(--uc-space-1); + min-height: calc(var(--uc-hit) - 12px); + padding: 0 var(--uc-space-2); + background: var(--uc-bg); + border: 1px solid var(--uc-line); + border-radius: var(--uc-radius); +} + +.chip { + width: 1rem; + height: 1rem; + border-radius: 50%; + flex: none; + border: 1px solid var(--uc-line); +} + +/* The 4-colour seat palette (server/seats.py `SEAT_PALETTE`, + * docs/PROTOCOL.md "Lobby"). Colours ride on classes, not inline + * `style` attributes — the CSP's `style-src 'self'` blocks the + * latter (a console error). */ +.chip-red { + background: #e5484d; +} + +.chip-blue { + background: #3b9eff; +} + +.chip-green { + background: #3ddc84; +} + +.chip-amber { + background: #ffb224; +} + +.seat-index { + color: var(--uc-ink-muted); + font-size: var(--uc-step--1); +} + +.seat-name { + flex: 1; + overflow-wrap: anywhere; +} + +.crown { + color: var(--uc-accent); + font-size: var(--uc-step-1); +} + +.ready-badge, +.ghost-badge { + font-size: var(--uc-step--1); + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 0.1rem 0.5rem; + border-radius: var(--uc-radius); + border: 1px solid var(--uc-line); +} + +.ready-badge { + color: var(--uc-accent-ink); + background: var(--uc-accent); + border-color: var(--uc-accent); +} + +.ghost-badge { + color: var(--uc-ink-muted); +} + +.lobby-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--uc-space-2); +} + +#ready-btn { + min-width: 12rem; +} + +.level-pick { + display: grid; + gap: var(--uc-space-1); +} + +#level-picker { + min-height: var(--uc-hit); + padding: 0 var(--uc-space-2); + background: var(--uc-bg); + color: var(--uc-ink); + border: 1px solid var(--uc-line); + border-radius: var(--uc-radius); + font: inherit; +} + +#level-picker:disabled { + opacity: 0.6; +} + +#start-btn:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.session { + margin-top: var(--uc-space-2); +} + +.session h3 { + margin: 0 0 var(--uc-space-1); + font-size: var(--uc-step-0); +} + +.session p, +.session li { + margin: 0; +} + +.bests { + margin: var(--uc-space-1) 0 0; + padding-left: 1.25rem; + display: grid; + gap: 0.25rem; +} + +#spectator-note { + margin: var(--uc-space-2) 0 0; +} + +#lobby-banner { + margin: var(--uc-space-2) 0 0; +} + +/* ------------------------------------------------------------- countdown */ + +.countdown { + text-align: center; +} + +.countdown-num { + margin: 0; + font-family: var(--uc-font-ui); + font-size: clamp(5rem, 20vw, 11rem); + font-weight: 800; + line-height: 1; + color: var(--uc-accent); +} + +@media (prefers-reduced-motion: no-preference) { + .countdown-num { + animation: countdown-pulse 1s var(--uc-ease); + } + + @keyframes countdown-pulse { + from { + transform: scale(1.25); + opacity: 0.4; + } + to { + transform: scale(1); + opacity: 1; + } + } +} + +/* ------------------------------------------------------------- play */ + +.play-screen { + display: grid; + gap: var(--uc-space-2); + width: 100%; + max-width: 46rem; +} + +.play-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--uc-space-2); +} + +#play-banner { + margin: 0; +} + +#kcanvas { + width: 100%; + aspect-ratio: 2 / 1; + background: var(--uc-bg); + border: 1px solid var(--uc-line); + border-radius: var(--uc-radius); +} + +.hud { + min-height: calc(var(--uc-hit) - 8px); +} + +/* The HUD (task 04): the order rail, the score bar, the timer, the + status row. The timer's colour bands carry a non-colour cue too: + `low` (amber) and `critical` (red) each add a striped background + and a label word (`hurry` / `now`). */ + +.hud-grid { + display: grid; + grid-template-columns: minmax(12rem, 1fr) minmax(10rem, 1fr) auto; + gap: var(--uc-space-2); + align-items: start; +} + +@media (max-width: 40rem) { + .hud-grid { + grid-template-columns: 1fr; + } +} + +.hud-orders .section-label { + margin: 0 0 var(--uc-space-1); +} + +.ticket-list { + display: grid; + gap: var(--uc-space-1); +} + +.ticket { + display: grid; + grid-template-columns: auto 1fr auto; + grid-template-rows: auto auto; + align-items: center; + gap: 2px var(--uc-space-1); + padding: var(--uc-space-1) var(--uc-space-2); + background: var(--uc-surface-2); + border: 1px solid var(--uc-line); + border-radius: var(--uc-radius); +} + +.ticket.oldest { + border-color: var(--uc-accent); + box-shadow: 0 0 0 1px var(--uc-accent); +} + +.ticket-icon { + width: 1.4rem; + height: 1.4rem; + grid-row: span 2; +} + +.ticket-pts { + font-family: var(--uc-font-ui); + font-weight: 700; +} + +.ticket-bar { + grid-column: 2 / 4; + height: 0.4rem; + background: var(--uc-bg); + border-radius: 999px; + overflow: hidden; +} + +.ticket-bar-fill { + height: 100%; + background: var(--uc-accent); +} + +.hud-score-line { + margin: 0; + font-family: var(--uc-font-ui); +} + +.hud-streak { + display: inline-flex; + align-items: center; + gap: 0.25rem; + margin-left: var(--uc-space-1); + color: var(--uc-accent); +} + +.hud-streak img { + width: 1.2rem; + height: 1.2rem; +} + +.score-bar { + margin-top: var(--uc-space-1); + height: 0.5rem; + background: var(--uc-bg); + border: 1px solid var(--uc-line); + border-radius: 999px; + overflow: hidden; +} + +.score-bar-fill { + height: 100%; + background: var(--uc-accent); +} + +.hud-timer { + padding: var(--uc-space-1) var(--uc-space-2); + border: 1px solid var(--uc-line); + border-radius: var(--uc-radius); + font-family: var(--uc-font-ui); + font-size: var(--uc-step-1); + min-width: 5.5rem; + text-align: center; +} + +/* The non-colour halves: the striped background + the label word. */ + +.hud-timer-low { + color: var(--uc-accent); + border-color: var(--uc-accent); + background: repeating-linear-gradient( + 45deg, + transparent 0 0.5rem, + color-mix(in srgb, var(--uc-accent) 18%, transparent) 0.5rem 1rem + ); +} + +.hud-timer-critical { + color: var(--uc-danger); + border-color: var(--uc-danger); + background: repeating-linear-gradient( + 45deg, + transparent 0 0.5rem, + color-mix(in srgb, var(--uc-danger) 22%, transparent) 0.5rem 1rem + ); +} + +.hud-timer-label { + margin-left: 0.4rem; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + font-size: var(--uc-step--1); +} + +.hud-meta { + display: grid; + gap: 0.35rem; + align-content: start; +} + +.hud-dirty, +.hud-conn { + font-family: var(--uc-font-ui); +} + +.hud-conn.is-live { + color: var(--uc-ink-muted); +} + +.hud-conn.is-down { + color: var(--uc-danger); +} + +#hud-spectator { + margin: 0; + color: var(--uc-accent); +} + +.emote-row { + display: flex; + flex-wrap: wrap; + gap: var(--uc-space-1); +} + +.emote { + min-width: var(--uc-hit); + min-height: var(--uc-hit); + padding: 0 var(--uc-space-2); + font-size: var(--uc-step-1); +} + +.overlay { + position: fixed; + inset: 0; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--uc-bg) 82%, transparent); + z-index: 10; +} + +.overlay p { + margin: 0; + padding: var(--uc-space-3); + background: var(--uc-surface); + border: 1px solid var(--uc-line); + border-radius: var(--uc-radius); + font-size: var(--uc-step-1); +} + +/* ------------------------------------------------------------- result */ + +.result { + text-align: center; +} + +#result-word { + font-size: var(--uc-step-2); + margin: 0; +} + +.stars { + margin: var(--uc-space-1) 0; + font-size: var(--uc-step-2); + letter-spacing: 0.15em; +} + +.star.filled { + color: var(--uc-accent); +} + +.star { + color: var(--uc-line); +} + +@media (prefers-reduced-motion: no-preference) { + .star.filled { + animation: star-pop 600ms var(--uc-ease); + } + + @keyframes star-pop { + from { + transform: scale(0.3); + } + 60% { + transform: scale(1.3); + } + to { + transform: scale(1); + } + } +} + +#score-line, +#best-line, +#counters-line { + margin: var(--uc-space-1) 0; +} + +#result-players { + text-align: left; +} + +.result-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--uc-space-2); + margin-top: var(--uc-space-2); +} + +/* ------------------------------------------------------------- error */ + +.error h2 { + margin: 0 0 var(--uc-space-1); +} + +#error-detail, +#error-code { + margin: 0 0 var(--uc-space-2); +} + +#error-code { + color: var(--uc-ink-muted); + font-family: var(--uc-font-ui); + letter-spacing: 0.15em; +} + +.error-actions { + display: flex; + flex-wrap: wrap; + gap: var(--uc-space-2); +} + +/* ------------------------------------------------------------- shared */ + +/* Screen-reader-only: the result announcement, focusable nowhere. */ +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +/* ------------------------------------------------------------- help (task 05) */ + +/* The `?`-key in-game help: a body-level fixed overlay over the panel + skin, dismissed by its Close button, the backdrop click, or the + keyboard's `?`/`Esc` (the module's `Esc` branch defers to it while + it is open). The rows are `getKeyMap()`'s (the README's controls + section renders the same thing). */ +.help-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--uc-bg) 72%, transparent); +} + +.help-panel { + width: min(30rem, calc(100vw - 2 * var(--uc-space-3))); + margin: 0; + max-width: none; +} + +.help-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--uc-space-2); +} + +.help-heading { + font-size: var(--uc-step-1); +} + +.help-rows { + display: grid; + gap: var(--uc-space-1); +} + +.help-row { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--uc-space-2); + align-items: baseline; +} + +.help-keys { + font-family: var(--uc-font-ui); + font-size: var(--uc-step--1); + color: var(--uc-accent); + background: var(--uc-surface-2); + border: 1px solid var(--uc-line); + border-radius: calc(var(--uc-radius) / 2); + padding: 0.1rem 0.45rem; + white-space: nowrap; +} + +.help-action { + color: var(--uc-ink); +} + +.help-foot { + margin: 0; + color: var(--uc-ink-muted); + font-size: var(--uc-step--1); +} + +@media (prefers-reduced-motion: no-preference) { + .help-overlay { + animation: uc-help-fade 120ms var(--uc-ease); + } +} + +@keyframes uc-help-fade { + from { + opacity: 0; + } +} diff --git a/app/static/index.html b/app/static/index.html index a7c3319..5dad871 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -13,6 +13,7 @@ while keeping every byte same-origin (PLAN.md A12). --> +
@@ -21,47 +22,12 @@

Anonymous co-op kitchen chaos. No accounts, no install, 1–4 cooks.

-
-

Kitchen lobby

-

- Create a kitchen and read its 5-character code out loud, or join a kitchen that is - already open. Nothing is saved and nothing is asked for. -

- - - -
- -
- - -
-
-

- Five characters, letters and digits, never I, L, O - or U. -

- -
- Pick a kitchen to start. -
-
- - - + +
- + diff --git a/app/static/js/app.js b/app/static/js/app.js index 4f5ec1a..28d370e 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1,134 +1,433 @@ -// Undercooked — client shell entry point (phase 01). +// Undercooked — the screen state machine (phase 07 task 02; the +// composition root — URL params, the snapshot buffer, the render +// loop, the wire routing — is `main.js`, phase 07 task 03). // -// There is no game logic here on purpose. Phase 05 shipped the server-side -// room shell (supervisor, room FSM, session store); phase 06 adds the WebSocket -// transport and phase 07 replaces the placeholder button handlers with the lobby -// flow and adds render/ (Canvas 2D). What this file owns today: -// * the anonymous per-tab session token (PLAN.md A16), -// * the last room code typed in this tab, so a reload can rejoin fast, -// * mounting the lobby screen and announcing state into #status. -// It is a plain ES module served from /static/ — no bundler, no framework, no -// remote import (PLAN.md A10/A12). +// `createApp()` owns the one client-side state machine: an explicit +// screen enum (`LANDING, CONNECTING, LOBBY, COUNTDOWN, PLAYING, +// ROUND_END, ERROR`), `show(screen, data)`, and the message reducer +// that maps the wire contract (docs/PROTOCOL.md) onto screens — +// `welcome`/`lobby` by the room's state, `countdown` → COUNTDOWN, +// `snap` → PLAYING, `round` → ROUND_END, fatal `err` codes → ERROR. +// Every screen is a module exporting `{ mount, unmount }` (plus an +// optional same-screen `update`); no global DOM lives outside them. +// +// `identity.js` keeps the per-tab identity (PLAN A16): the anonymous +// session token and the last room code, both in `sessionStorage`. +// The app instance is exposed as `window.__undercooked` — the documented test seam the +// e2e suites drive (and the task-05 keyboard module will feed). +// +// It is a plain ES module served from /static/ — no bundler, no +// framework, no remote import (PLAN A10/A12). -const TOKEN_KEY = "uc.token"; -const LAST_CODE_KEY = "uc.lastCode"; -const CODE_LENGTH = 5; -const NOT_IMPLEMENTED = "Not implemented until phase 07."; -const READY_MESSAGE = "Pick a kitchen to start."; +import { createConnection, FATAL_ERR_CODES } from "./net/connection.js"; +import { getToken, lastCode, rememberCode, randomToken } from "./identity.js"; +import * as landingScreen from "./screens/landing.js"; +import * as connectingScreen from "./screens/connecting.js"; +import * as lobbyScreen from "./screens/lobby.js"; +import * as countdownScreen from "./screens/countdown.js"; +import * as playScreen from "./screens/play.js"; +import * as roundEndScreen from "./screens/round_end.js"; +import * as errorScreen from "./screens/error.js"; -/** Base64url without padding, so the token is safe in a query string or header. */ -function base64url(bytes) { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); -} +export { randomToken, getToken, lastCode, rememberCode }; -/** A random, unguessable id for this browser tab (identity, not an account). */ -export function randomToken() { - const bytes = new Uint8Array(24); - globalThis.crypto.getRandomValues(bytes); - return base64url(bytes); -} +/** The screen enum (the task's list, frozen). */ +export const SCREENS = Object.freeze({ + LANDING: "landing", + CONNECTING: "connecting", + LOBBY: "lobby", + COUNTDOWN: "countdown", + PLAYING: "playing", + ROUND_END: "round_end", + ERROR: "error", +}); -function read(storage, key) { - try { - return storage.getItem(key); - } catch { - return null; // private browsing / storage disabled: stay functional for this tab only - } -} +const SCREEN_MODULES = { + landing: landingScreen, + connecting: connectingScreen, + lobby: lobbyScreen, + countdown: countdownScreen, + playing: playScreen, + round_end: roundEndScreen, + error: errorScreen, +}; -function write(storage, key, value) { - try { - storage.setItem(key, value); - } catch { - /* nothing to persist to; the in-memory value still works */ - } -} +/** The room-state value → screen (the `welcome`/`lobby` mapping half). */ +const SCREEN_BY_ROOM_STATE = { + lobby: SCREENS.LOBBY, + countdown: SCREENS.COUNTDOWN, + playing: SCREENS.PLAYING, + round_end: SCREENS.ROUND_END, + closed: SCREENS.ERROR, +}; -/** The tab's session token, created once per tab and kept in sessionStorage. */ -export function getToken(storage = globalThis.sessionStorage) { - const existing = read(storage, TOKEN_KEY); - if (typeof existing === "string" && existing.length > 0) return existing; - const token = randomToken(); - write(storage, TOKEN_KEY, token); - return token; -} +const LOG_CAP = 2000; -/** Uppercase what the user typed and keep only characters a code can contain. */ -export function normalizeCode(value) { - return String(value ?? "") - .toUpperCase() - .replace(/[^0-9A-Z]/g, "") - .slice(0, CODE_LENGTH); -} +export function createApp(root = document, options = {}) { + const { onWireMessage = null } = options; + const screenRoot = root.getElementById("screen-root"); + const app = { + token: getToken(), + lastCode: lastCode(), + screen: null, // set by the first show() + data: {}, + roomCode: "", + welcome: null, + you: null, // the viewer's seat (YouWire) + spectator: false, // joined mid-round (or the token holds no seat) + lobby: null, // the latest `lobby` frame + round: null, // the latest `round` frame + lastSnapshot: null, // the latest `snap` raw body (the render path is task 03's buffer; this view is the DOM path the screens and the choreography read) + session: null, // the `/api/session` projection + levels: null, // the `/api/levels` cards (null = not fetched yet) + view: makeView(), // the merged snapshot view (full frames reset, deltas patch) + error: null, // the fatal `err` frame, when one caused ERROR + banner: null, // non-fatal `err` text for the mounted screen + messageLog: [], // bounded wire-census for the e2e suites + leaving: false, // we closed the socket on purpose + }; + const connection = createConnection(); + const warnedUnknown = new Set(); + let mounted = null; -export function lastCode(storage = globalThis.sessionStorage) { - return read(storage, LAST_CODE_KEY) || ""; -} - -export function rememberCode(code, storage = globalThis.sessionStorage) { - write(storage, LAST_CODE_KEY, normalizeCode(code)); -} - -/** Write the live region. `kind` is "info" | "error" and only changes styling. */ -export function announce(message, root = document, kind = "info") { - const region = root.getElementById("status"); - if (!region) return; - region.textContent = message; - region.dataset.kind = kind; -} - -/** - * Wire the lobby screen. Returns the tab identity so later phases can reuse it. - */ -export function mount(root = document) { - const token = getToken(); - const createButton = root.getElementById("create-lobby"); - const joinForm = root.getElementById("join-form"); - const joinInput = root.getElementById("join-code"); - - const remembered = lastCode(); - if (joinInput && remembered) joinInput.value = remembered; - - if (createButton) { - createButton.addEventListener("click", () => { - // Phase 07 (on the phase-06 transport): POST to the room supervisor, - // open /ws/{code}. - announce(NOT_IMPLEMENTED, root); - }); + /** + * The merged snapshot view — the task-02 seed of task 03's buffer: a + * full frame resets the entity maps, a delta patches them (an upsert on + * the identity key, a `gone` tombstone drops). The stream's full cadence + * (every 20 encoded frames) plus the fresh-connection full keeps this + * resyncable; a client-side drift is what task 03's decoder handles. + */ + function makeView() { + return { players: {}, stations: {}, orders: {} }; } - if (joinForm) { - joinForm.addEventListener("submit", (event) => { - event.preventDefault(); - const code = normalizeCode(joinInput ? joinInput.value : ""); - if (joinInput) joinInput.value = code; - if (code.length !== CODE_LENGTH) { - announce(`A room code is ${CODE_LENGTH} characters.`, root, "error"); - if (joinInput) joinInput.focus(); - return; + function stationKey(entry) { + return `${entry.k}@${entry.x},${entry.y}`; + } + + function applySnapshot(raw) { + app.lastSnapshot = raw; + const view = app.view; + if (raw.full) { + view.players = {}; + view.stations = {}; + view.orders = {}; + for (const entry of raw.p ?? []) view.players[entry.i] = entry; + for (const entry of raw.st ?? []) view.stations[stationKey(entry)] = entry; + for (const entry of raw.o ?? []) view.orders[entry.id] = entry; + return; + } + const delta = raw.d ?? {}; + for (const entry of delta.p ?? []) { + if (entry.gone) delete view.players[entry.i]; + else view.players[entry.i] = entry; + } + for (const entry of delta.st ?? []) { + const key = stationKey(entry); + if (entry.gone) delete view.stations[key]; + else view.stations[key] = entry; + } + for (const entry of delta.o ?? []) { + if (entry.gone) delete view.orders[entry.id]; + else view.orders[entry.id] = entry; + } + } + + function pushLog(frame) { + app.messageLog.push(frame); + if (app.messageLog.length > LOG_CAP) app.messageLog.shift(); + } + + /** + * Show one screen. An unknown screen name (a bug) logs once and + * becomes ERROR; the same screen re-shown in place calls the + * module's `update` (a re-mount would drop focus off live controls). + */ + function show(screen, data = {}) { + if (!(screen in SCREEN_MODULES)) { + if (!warnedUnknown.has(screen)) { + console.warn( + `undercooked: unknown screen "${String(screen)}" — going to the error screen`, + ); + warnedUnknown.add(screen); } - rememberCode(code); - // Phase 07 (on the phase-06 transport): join the room over the - // WebSocket. - announce(NOT_IMPLEMENTED, root); - }); + screen = SCREENS.ERROR; + } + app.data = data; + if (app.screen === screen && mounted !== null) { + if (typeof mounted.update === "function") mounted.update(data, app); + return screen; + } + if (mounted !== null && typeof mounted.unmount === "function") mounted.unmount(); + mounted = null; + app.screen = screen; + mounted = SCREEN_MODULES[screen]; + // The previous screen's DOM is the previous screen's problem no + // more: a screen module appends, it does not replace, so the + // machine clears the root before mounting (a re-mount would stack + // a second copy of the screen on top of the first). + screenRoot.replaceChildren(); + if (typeof mounted.mount === "function") mounted.mount(screenRoot, data, app); + return screen; } - if (!lastCode() && root.getElementById("status")) { - announce(READY_MESSAGE, root); + function failToLanding(banner) { + app.banner = banner; + show(SCREENS.LANDING, {}); } - return { token, code: lastCode() }; + // ------------------------------------------------------------- verbs + + async function createLobby({ name = null } = {}) { + app.leaving = false; + show(SCREENS.CONNECTING, { what: "creating" }); + let response; + try { + response = await fetch("/api/rooms", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + token: app.token, + ...(name !== null && name !== "" ? { name } : {}), + }), + }); + } catch { + failToLanding("Cannot reach the server — check the address and try again."); + return; + } + if (response.status === 503) { + failToLanding("The server is at capacity — try again in a moment."); + return; + } + if (response.status !== 201) { + failToLanding("The server refused the room. Try again."); + return; + } + const body = await response.json().catch(() => null); + if (body === null || typeof body.room_code !== "string") { + failToLanding("The server answered something unrecognisable. Try again."); + return; + } + app.roomCode = body.room_code; + rememberCode(body.room_code); + connection.connect(body.room_code, { token: app.token }); + } + + function join(code) { + app.leaving = false; + app.roomCode = code; + rememberCode(code); + show(SCREENS.CONNECTING, { what: "joining", code }); + connection.connect(code, { token: app.token }); + } + + function leave() { + app.leaving = true; + connection.close(); + show(SCREENS.LANDING, {}); + } + + function backToLanding() { + app.leaving = true; + connection.close(); + app.banner = null; + show(SCREENS.LANDING, {}); + } + + function retryJoin() { + if (app.roomCode === "") return; + app.leaving = false; + show(SCREENS.CONNECTING, { what: "joining", code: app.roomCode }); + connection.connect(app.roomCode, { token: app.token }); + } + + function ready() { + connection.send({ type: "ready" }); + } + + function start() { + connection.send({ type: "start" }); + } + + function setLevel(level) { + connection.send({ type: "set_level", level }); + } + + function emote(id) { + connection.send({ type: "emote", id }); + } + + function toLobby() { + connection.send({ type: "to_lobby" }); + } + + function nextLevel() { + connection.send({ type: "next_level" }); + } + + function sendInput(move, act) { + connection.sendInput(move, act); + } + + async function fetchSession() { + try { + const response = await fetch("/api/session", { + headers: { "X-Undercooked-Token": app.token }, + }); + if (response.ok) { + app.session = await response.json(); + if (mounted !== null && typeof mounted.renderBest === "function") { + mounted.renderBest(app); + } + } + } catch { + /* offline: the previous session stays */ + } + return app.session; + } + + async function fetchLevels() { + if (app.levels !== null) return app.levels; + try { + const response = await fetch("/api/levels"); + app.levels = response.ok ? await response.json() : []; + } catch { + app.levels = []; + } + return app.levels; + } + + /** The merged view's own half (a `snap` landed, delta or full). */ + function orderOpen() { + return Object.keys(app.view.orders).length > 0; + } + + function showReconnecting(visible) { + if (mounted !== null && typeof mounted.showReconnecting === "function") { + mounted.showReconnecting(visible); + } + } + + // ------------------------------------------------------------- reducer + + connection.onMessage = (frame) => { + // The composition root's half (phase 07 task 03): the `snap` frames + // also feed the render buffer before the reducer's DOM view. + if (onWireMessage !== null) onWireMessage(frame); + pushLog(frame); + switch (frame.type) { + case "welcome": { + app.welcome = frame; + app.roomCode = frame.room; + app.you = frame.you; + // A fresh token in a non-lobby room is a spectator (a rejoin + // reclaims its seat, so `rejoined` is the discriminator). + app.spectator = frame.state !== "lobby" && frame.rejoined !== true; + show(SCREEN_BY_ROOM_STATE[frame.state] ?? SCREENS.ERROR, { code: frame.room }); + break; + } + case "lobby": { + app.lobby = frame; + app.roomCode = frame.code; + show( + frame.state === "lobby" ? SCREENS.LOBBY : SCREEN_BY_ROOM_STATE[frame.state] ?? SCREENS.ERROR, + {}, + ); + break; + } + case "countdown": + show(SCREENS.COUNTDOWN, { n: frame.n }); + break; + case "snap": + if (frame.raw !== undefined) applySnapshot(frame.raw); + show(SCREENS.PLAYING, {}); + break; + case "round": + app.round = frame; + show(SCREENS.ROUND_END, {}); + break; + case "err": { + connection.noteError(frame); + if (FATAL_ERR_CODES.includes(frame.code)) { + app.error = frame; + show(SCREENS.ERROR, { code: frame.code, msg: frame.msg }); + } else if (mounted !== null && typeof mounted.setBanner === "function") { + app.banner = frame.msg; + mounted.setBanner(frame.msg); + } + break; + } + case "pong": + break; // the keep-alive's answer: nothing to render + case "ev": + break; // FX/log (task 04's renderer consumes these) + default: + break; // unknown server types are ignored (patch-compatible) + } + }; + + connection.onStatus = (status) => { + if (status === "mismatch") { + show(SCREENS.ERROR, { code: "protocol_mismatch" }); + return; + } + if (status !== "closed") return; + if (app.leaving) return; // an intentional close: no error screen + const code = connection.lastCloseCode; + if (code === 1000 && app.screen === SCREENS.PLAYING) { + // A drop mid-round: the seat is kept for the grace; phase 08 + // reconnects. This task's half is the overlay. + showReconnecting(true); + return; + } + show(SCREENS.ERROR, { code: code === 1006 ? "unreachable" : "closed" }); + }; + + // The public verbs — assigned onto `app` itself so the screens (which + // hold `app`) and the e2e seam (`window.__undercooked`) see one and + // the same live object: a `show()` later is visible through the seam. + Object.assign(app, { + connection, + show, + createLobby, + join, + leave, + backToLanding, + retryJoin, + ready, + start, + setLevel, + emote, + toLobby, + nextLevel, + sendInput, + fetchSession, + fetchLevels, + orderOpen, + showReconnecting, + }); + + show(SCREENS.LANDING, {}); + + return app; } -// Auto-mount when loaded as the page's module, but stay importable (and -// therefore testable) without a lobby in the document. -if (typeof document !== "undefined" && document.getElementById("screen-lobby")) { +// Auto-mount when loaded directly as the page's module; stay +// importable (and therefore testable) without one. The instance is +// the e2e test seam. `main.js` (the composition root) sets +// `globalThis.__uc_main` before importing this module — when it is +// set, main.js boots the app and this auto-boot stays out of the way. +if ( + typeof document !== "undefined" && + document.getElementById("screen-root") && + !globalThis.__uc_main +) { + const boot = () => { + globalThis.__undercooked = createApp(); + }; if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", () => mount(), { once: true }); + document.addEventListener("DOMContentLoaded", boot, { once: true }); } else { - mount(); + boot(); } } diff --git a/app/static/js/identity.js b/app/static/js/identity.js new file mode 100644 index 0000000..29038c7 --- /dev/null +++ b/app/static/js/identity.js @@ -0,0 +1,70 @@ +// Undercooked — the tab identity (PLAN A16; carried over from the +// phase-01 shell, where these helpers lived in `app.js`). +// +// Identity is a 24-byte random token per browser tab (the browser's +// `crypto.getRandomValues` is the same entropy the server's +// `secrets.token_urlsafe(24)` uses, and the only option a browser has), +// held in `sessionStorage` so a reload re-joins instead of re-minting. +// The last room code is remembered the same way, so a reload can +// rejoin fast. Nothing here is personal: no names, no PII (PLAN §11). + +const TOKEN_KEY = "uc.token"; +const LAST_CODE_KEY = "uc.lastCode"; +const CODE_LENGTH = 5; + +/** Base64url without padding, so the token is safe in a query string or header. */ +function base64url(bytes) { + let binary = ""; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** A random, unguessable id for this browser tab (identity, not an account). */ +export function randomToken() { + const bytes = new Uint8Array(24); + globalThis.crypto.getRandomValues(bytes); + return base64url(bytes); +} + +function read(storage, key) { + try { + return storage.getItem(key); + } catch { + return null; // private browsing / storage disabled: stay functional for this tab only + } +} + +function write(storage, key, value) { + try { + storage.setItem(key, value); + } catch { + /* nothing to persist to; the in-memory value still works */ + } +} + +/** The tab's session token, created once per tab and kept in sessionStorage. */ +export function getToken(storage = globalThis.sessionStorage) { + const existing = read(storage, TOKEN_KEY); + if (typeof existing === "string" && existing.length > 0) return existing; + const token = randomToken(); + write(storage, TOKEN_KEY, token); + return token; +} + +/** Uppercase what the user typed and keep only characters a code can contain. */ +export function normalizeCode(value) { + return String(value ?? "") + .toUpperCase() + .replace(/[^0-9A-Z]/g, "") + .slice(0, CODE_LENGTH); +} + +export function lastCode(storage = globalThis.sessionStorage) { + return read(storage, LAST_CODE_KEY) || ""; +} + +export function rememberCode(code, storage = globalThis.sessionStorage) { + write(storage, LAST_CODE_KEY, normalizeCode(code)); +} + +export { CODE_LENGTH }; diff --git a/app/static/js/input/keyboard.js b/app/static/js/input/keyboard.js new file mode 100644 index 0000000..bb00b49 --- /dev/null +++ b/app/static/js/input/keyboard.js @@ -0,0 +1,317 @@ +// Undercooked — the keyboard input module (phase 07, task 05). +// +// Desktop control for the play screen, wired to the protocol's `input` +// frame (docs/PROTOCOL.md "Input rules"): one frame of intent, sent on +// change plus a keep-alive at ≥ 5 Hz. The `seq` is the connection's +// (`net/connection.js` — a monotonic counter per connection, reset only +// on a new socket); this module never touches it. +// +// * `WASD` + the arrow keys → an 8-way intent vector; opposite keys +// cancel (W+S is a stop, not a nudge); a diagonal is normalised +// (the server clamps the magnitude to 1 — a diagonal must not be +// faster than a cardinal move). +// * `Space` / `Enter` = the one action button. A press is a rising +// edge (`action_held_ticks` semantics from the protocol: the hold +// rides the frame's `act` flag, `sim/interact.py`'s gate turns the +// edge into one event and the hold counters into the hold progress). +// * `1`–`4` = the emote whitelist's first four (`wave`, `cheer`, +// `thumbs_up`, `thumbs_down` — `server/seats.py`); legal in `LOBBY` +// and `PLAYING` (the server's per-state whitelist decides; this +// module only knows the screen, and the lobby screen has no +// competing control). +// * `?` = the help overlay's toggle; `Escape` = close the help, or — +// on the play screen with the help closed — leave the room (the +// Leave button's behaviour). +// * Key events are ignored 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` (hidden) / `pagehide` clear every held +// key and send one zero-intent frame with the action released (no +// cook running away when you alt-tab). +// +// `?controls=off` (and `touch`, until phase 08 ships it) binds nothing: +// the module still exposes `getKeyMap()` and a `stats()` census so the +// suites can tell the mode apart. The send pump holds the intent while +// a key is down (the sim brakes a tick with no frame — a held key must +// ride every tick window, ~48 ms ≈ 20.8 Hz) and, idle, a zero-intent +// keep-alive at 10 Hz (the protocol's ≥ 5 Hz; it is also what asserts +// "no input" after a release). + +import { getKeyMap } from "../ui/help.js"; + +export { getKeyMap }; + +/** The movement key map (key code → axis). WASD and the arrows; the + * `e.code` (layout-independent physical key) is what a game binds — + * a AZERTY user's `Z` is the same physical key as a QWERTY `W`. */ +const MOVE_CODES = { + KeyW: "up", + ArrowUp: "up", + KeyS: "down", + ArrowDown: "down", + KeyA: "left", + ArrowLeft: "left", + KeyD: "right", + ArrowRight: "right", +}; + +/** The action key codes (one button, two ways to reach it). */ +const ACTION_CODES = new Set(["Space", "Enter"]); + +/** `1`–`4` → the emote whitelist's first four (the server's + * `EMOTES` order, `server/seats.py`). */ +const EMOTES_BY_KEY = { + Digit1: "wave", + Numpad1: "wave", + Digit2: "cheer", + Numpad2: "cheer", + Digit3: "thumbs_up", + Numpad3: "thumbs_up", + Digit4: "thumbs_down", + Numpad4: "thumbs_down", +}; + +/** A diagonal's length (both axes held at 1): normalising by + * `1 / √2` keeps the wire's magnitude ≤ 1 — the server's clamp must + * never see a diagonal (it would be faster than a cardinal move). */ +const DIAGONAL = Math.SQRT1_2; + +/** The pump's cadence: 25 ms (40 Hz — it checks every frame the + * `requestAnimationFrame`-free setInterval gives it); a held key + * re-sends at ~48 ms (above the 20 Hz tick, the hold stays + * continuous), an idle one at 100 ms (10 Hz, the ≥ 5 Hz keep-alive). + */ +const PUMP_MS = 25; +const HELD_RESEND_MS = 48; +const IDLE_KEEPALIVE_MS = 100; + +/** Whether a key event is not game input (the ignore rules, one place). */ +function ignored(e) { + if (document.hidden) return true; + if (e.ctrlKey || e.metaKey || e.altKey) return true; + const t = e.target; + if (t === null || t === undefined) return true; + return ( + t instanceof HTMLInputElement || + t instanceof HTMLTextAreaElement || + t instanceof HTMLSelectElement || + (t instanceof Element && t.isContentEditable) + ); +} + +/** + * The keyboard input module. + * + * `app` — the app instance (`app.js`): its `sendInput` is the wire, + * `screen` / `spectator` the gate, `emote` / `leave` the verbs. + * `controls` — the `?controls=` param (`keyboard` binds, anything else + * exposes the seams and does nothing). `help` — the help overlay's + * `{ toggle(), visible() }` (optional; `?` and `Esc` skip it when + * absent). + * + * Returns `{ active, getKeyMap, stats, dispose }`; `stats()` is the + * census (a `sent` count, the last frame sent, what is held). + */ +export function createKeyboardInput({ app, controls = "keyboard", help = null } = {}) { + const active = controls === "keyboard"; + const held = { up: false, down: false, left: false, right: false }; + let actionHeld = false; + let sent = 0; + let lastSent = { x: 0, y: 0, act: false }; + let lastSentAt = 0; + let disposed = false; + let timer = null; + + /** The 8-way intent vector (opposites cancel; a diagonal is normalised). */ + function intentVector() { + const x = (held.right ? 1 : 0) - (held.left ? 1 : 0); + const y = (held.down ? 1 : 0) - (held.up ? 1 : 0); + if (x !== 0 && y !== 0) { + return { x: x * DIAGONAL, y: y * DIAGONAL }; + } + return { x, y }; + } + + /** One frame out (the census + the wire). */ + function sendFrame(v, act) { + lastSent = { x: v.x, y: v.y, act }; + lastSentAt = performance.now(); + sent += 1; + app.sendInput(v, act); + } + + /** A change in intent: send the new frame (the keep-alive pump + * resends while a key is held; this is the "on change" half). */ + function sendOnChange() { + if (app.screen !== "playing" || app.spectator) return; + const v = intentVector(); + if (v.x === lastSent.x && v.y === lastSent.y && actionHeld === lastSent.act) { + return; + } + sendFrame(v, actionHeld); + } + + /** The forced zero (focus loss / tab hidden): clear everything and + * assert one zero-intent frame with the action released. */ + function releaseAll() { + held.up = held.down = held.left = held.right = false; + actionHeld = false; + if (app.screen === "playing" && !app.spectator) { + sendFrame({ x: 0, y: 0 }, false); + } else { + lastSentAt = performance.now(); + } + } + + function anyHeld() { + return held.up || held.down || held.left || held.right || actionHeld; + } + + /** The pump: while a key is held, resend ~every 48 ms (the sim + * brakes a tick with no frame — the hold must ride every tick + * window); idle, a zero-intent keep-alive at 100 ms (10 Hz ≥ 5 Hz — + * it also re-asserts "no input" after a release). */ + function pump() { + const now = performance.now(); + const due = anyHeld() + ? now - lastSentAt >= HELD_RESEND_MS + : now - lastSentAt >= IDLE_KEEPALIVE_MS; + if (!due) return; + if (app.screen === "playing" && !app.spectator) { + sendFrame(intentVector(), actionHeld); + } else { + lastSentAt = now; // off-screen: reset the clock, no burst on return + } + } + + function onKeydown(e) { + if (ignored(e)) return; + const move = MOVE_CODES[e.code]; + if (move !== undefined) { + e.preventDefault(); // the arrows (and some pages' Space) would scroll + if (!e.repeat) { + held[move] = true; + sendOnChange(); + } + return; + } + if (ACTION_CODES.has(e.code)) { + // A focused button/anchor keeps its native activation (the play + // screen's Leave / emote row); a bare page only ever scrolls. + // `HTMLDetailsElement` is a real interface (the `open` attribute) + // but `` has none — there is no `HTMLSummaryElement`, so + // the summary is matched by `tagName` (checking the identifier + // would throw a `ReferenceError` and swallow the press). + const t = e.target; + const interactive = + t instanceof HTMLButtonElement || + t instanceof HTMLAnchorElement || + t instanceof HTMLDetailsElement || + (t instanceof Element && t.tagName === "SUMMARY"); + if (app.screen === "playing" && !interactive) e.preventDefault(); + if (!e.repeat) { + actionHeld = true; + sendOnChange(); + } + return; + } + if (e.repeat) return; + const emote = EMOTES_BY_KEY[e.code]; + if (emote !== undefined) { + if (app.screen === "playing" || app.screen === "lobby") { + e.preventDefault(); + app.emote(emote); + } + return; + } + if (e.key === "?") { + e.preventDefault(); + if (help !== null) help.toggle(); + return; + } + if (e.key === "Escape") { + if (help !== null && help.visible()) { + help.hide(); + } else if (app.screen === "playing") { + app.leave(); + } + } + } + + function onKeyup(e) { + // A keyup always clears (even when the keydown was ignored — a + // held key that was not game input must not leak into the vector + // once the target changes). + const move = MOVE_CODES[e.code]; + if (move !== undefined) { + if (held[move]) { + held[move] = false; + sendOnChange(); + } + return; + } + if (ACTION_CODES.has(e.code)) { + if (actionHeld) { + actionHeld = false; + sendOnChange(); + } + } + } + + function onBlur() { + releaseAll(); + } + + function onVisibility() { + if (document.hidden) releaseAll(); + } + + function onPagehide() { + releaseAll(); + } + + const listeners = []; + if (active) { + const add = (target, type, fn, options) => { + target.addEventListener(type, fn, options); + listeners.push([target, type, fn, options]); + }; + add(window, "keydown", onKeydown); + add(window, "keyup", onKeyup); + add(window, "blur", onBlur); + add(document, "visibilitychange", onVisibility); + add(window, "pagehide", onPagehide); + timer = setInterval(pump, PUMP_MS); + } + + return { + active, + getKeyMap, + /** The census (the e2e suites' seam): what is held, what the last + * frame sent was, how many frames went out. */ + stats() { + return { + active, + sent, + last: { ...lastSent }, + lastSentAt, + held: { ...held }, + actionHeld, + }; + }, + /** Tear down (a page reload's `beforeunload` is the caller; the + * suites call it to reset between scenarios). */ + dispose() { + if (disposed) return; + disposed = true; + if (timer !== null) { + clearInterval(timer); + timer = null; + } + for (const [target, type, fn, options] of listeners) { + target.removeEventListener(type, fn, options); + } + listeners.length = 0; + }, + }; +} diff --git a/app/static/js/main-flag.js b/app/static/js/main-flag.js new file mode 100644 index 0000000..f798497 --- /dev/null +++ b/app/static/js/main-flag.js @@ -0,0 +1,9 @@ +// Undercooked — the `main.js` flag (phase 07 task 03). +// +// Evaluated before `app.js` (import order in `main.js`), so when +// `app.js`'s module top-level runs its auto-boot check, the flag is +// already set: the composition root is bootstrapping the app, and the +// legacy auto-boot (for a direct `app.js` load, e.g. a developer +// opening the file) must not boot a second instance. + +globalThis.__uc_main = true; diff --git a/app/static/js/main.js b/app/static/js/main.js new file mode 100644 index 0000000..4956c7f --- /dev/null +++ b/app/static/js/main.js @@ -0,0 +1,208 @@ +// Undercooked — the composition root (phase 07 task 03). +// +// The page's one entry module (`index.html` loads `/static/js/main.js` +// and nothing else): it parses the URL params, builds the pieces — +// the screen machine (`app.js`), the snapshot buffer, the event queue, +// the single render loop — and routes the wire: +// +// * `snap` → the snapshot buffer (gap / resync / freeze rules; the +// buffer is the *render* path) **and** the app's reducer (whose +// merged view is the *DOM* path the lobby / HUD / choreography +// read); the frame's `e` also feeds the event queue; +// * everything else → the screen machine's reducer, untouched. +// +// The seams: `window.__undercooked` is the app instance (the state +// machine the e2e suites drive — unchanged from task 02, plus the +// `params` / `seed` / `snapshotBuffer` / `eventQueue` fields); +// `window.__uc` is the loop census and the pure modules +// (`frameMs` p50/p95 self-measured, `buffer`, `events`, `loop`, +// `params`, the last interpolated `lastView`). +// +// Still a plain ES module: no bundler, no framework, no remote import +// (PLAN A10/A12). The boot is synchronous at module evaluation +// (static imports only), so by the time the page's module graph has +// settled, `window.__undercooked` is set — no async boot race. + +import "./main-flag.js"; // the auto-boot guard (must precede app.js) +import { createApp } from "./app.js"; +import { createSnapshotBuffer, lerpState } from "./net/snapshots.js"; +import { createEventQueue } from "./net/events.js"; +import { createRenderLoop } from "./render/loop.js"; +import { createCamera } from "./render/camera.js"; +import { createRenderer } from "./render/canvas.js"; +import { createFx } from "./render/fx.js"; +import { createHud } from "./ui/hud.js"; +import { createKeyboardInput } from "./input/keyboard.js"; +import { createHelpOverlay } from "./ui/help.js"; + +/** The URL params (docs/CLIENT.md "The URL params"): + * `room` — join this code on boot (a test harness seam); + * `seed` — the campaign's seed (exposed for the deterministic + * suites; the server is authoritative, it never simulates); + * `controls` — `keyboard` (default) / `touch` / `off` (task 05's + * input module reads it; `off` means no input module binds); + * `freeze` — `1` holds the sample clock at the first playing frame + * (a pixel-diff test sees a still image); + * `motion` — `1` (default) / `0` (the reduced-motion mode; task 04's + * FX and phase 14's a11y read it). + */ +function parseParams(search = new URLSearchParams(location.search)) { + return { + room: search.get("room") ?? "", + seed: search.get("seed") ?? "", + controls: search.get("controls") ?? "keyboard", + freeze: search.get("freeze") === "1" || search.get("freeze") === "true", + motion: search.get("motion") === "0" ? "0" : "1", + }; +} + +/** The one-time boot (synchronous; `index.html`'s module evaluation runs it). */ +export function bootstrap() { + const params = parseParams(); + const buffer = createSnapshotBuffer({ + onResync: () => { + // The resync request (the buffer's debounce is the only gate): + // the client's half is to *hold* — the stream's full cadence + // (phase 06, every 20 encoded frames) is the resync. Nothing to + // send; the hook exists so a phase-08 reconnect can hook in. + }, + }); + const events = createEventQueue(); + + const app = createApp(document, { + onWireMessage: (frame) => { + if (frame.type === "snap" && frame.raw !== undefined) { + buffer.push(frame.raw); // the render path (gap / resync / freeze) + if (Array.isArray(frame.raw.e)) events.push(frame.raw.t, frame.raw.e); + } else if (frame.type === "ev" && Array.isArray(frame.events)) { + events.push(null, frame.events); // the standalone `ev` (no tick) + } + }, + }); + app.params = params; + if (params.seed !== "") app.seed = params.seed; + app.snapshotBuffer = buffer; + app.eventQueue = events; + + // The in-game help (task 05): one body-level overlay, created once, + // persisting across screens; the keyboard's `?`/`Esc` branches call + // into it. + const help = createHelpOverlay(); + // The keyboard input (task 05): `?controls=keyboard` (the default) + // binds the WASD/arrow + Space/Enter + emote + `?`/Esc listeners and + // the keep-alive pump; `touch`/`off` leave the seams, bind nothing. + const input = createKeyboardInput({ + app, + controls: params.controls, + help, + }); + app.help = help; + app.input = input; + + let freezeAt = null; + // The play session's runtime (task 04): the camera, the FX, the + // canvas renderer and the HUD — bound to the *mounted* `#kcanvas` + // (the play screen's DOM is rebuilt per round: a new canvas means a + // fresh runtime, a vanished one means the screen left and the + // runtime tears down). The single-owner rule is untouched: the loop + // is the only thing that drives them. + let runtime = null; + function playRuntime(nowMs) { + const canvas = document.getElementById("kcanvas"); + if (canvas === null) { + if (runtime !== null) { + if (runtime.hud !== null) runtime.hud.unmount(); + runtime.renderer.dispose(); + runtime.camera.detach(); + runtime = null; + } + return null; + } + if (runtime === null || runtime.canvas !== canvas) { + const camera = createCamera({ canvas }); + const fx = createFx({ canvas }); + const renderer = createRenderer({ + canvas, + camera, + fx, + ownSeat: () => (app.you !== null ? app.you.seat : null), + }); + const hudRoot = document.getElementById("hud"); + const hud = hudRoot !== null ? createHud({ root: hudRoot, app }) : null; + if (params.motion === "0") canvas.dataset.motion = "reduced"; + camera.attach(); + renderer.ensureReady(); // the atlas's one-time load (a failed load throws into the promise; the first frames wait for it) + runtime = { canvas, camera, fx, renderer, hud }; + } + if (runtime.renderer.isReady() && canvas.hidden) { + canvas.hidden = false; // the renderer is ready: the canvas may show + } + if (runtime.renderer.isReady()) { + const sample = buffer.sample(params.freeze ? (freezeAt ??= nowMs) : nowMs); + if (sample !== null) { + const state = lerpState(sample.a, sample.b, sample.alpha); + globalThis.__uc.lastView = state; + const due = events.due(nowMs); // one drain per frame (FX + HUD share it) + runtime.fx.update(nowMs); + runtime.fx.consume(due, state, nowMs); + runtime.renderer.drawFrame(state, nowMs); + if (runtime.hud !== null) { + runtime.hud.update(state, buffer.latest(), app, due, nowMs); + } + } + } + return runtime; + } + const loop = createRenderLoop({ + active: () => app.screen === "playing", + render: (nowMs) => { + // Task 04's half: the sample feeds the renderer (the canvas + // layers, docs/CLIENT.md's order) and the HUD (the diff- + // written readouts); `lastView` stays the smoothness suites' + // seam. + playRuntime(nowMs); + }, + }); + loop.start(); + + globalThis.__uc = { + params, + app, + buffer, + events, + loop, + lastView: null, + get frameMs() { + return loop.frameStats(); + }, + /** The renderer's frame census (the perf assertions' seam; the + * loop's own rAF census is `frameMs`). */ + renderStats: () => + runtime !== null ? runtime.renderer.renderStats() : { drawCalls: 0, frames: 0 }, + /** The play session's runtime (the camera / the FX / the + * renderer / the HUD; `null` off the play screen). */ + get runtime() { + return runtime; + }, + /** The FX engine (the reduced-motion + the nudge's census). */ + get fx() { + return runtime !== null ? runtime.fx : null; + }, + /** The keyboard input module (task 05; its `stats()` is the + * held-key / last-frame / sent-count census). */ + input, + /** The help overlay (task 05; `?` toggles it). */ + help, + }; + globalThis.__undercooked = app; + + if (params.room !== "") app.join(params.room); + return app; +} + +// The page loads this module for the side effect: boot once, at +// module evaluation (the module graph settles before the first paint, +// so there is no visible flash and no async boot race). +if (typeof document !== "undefined" && document.getElementById("screen-root")) { + bootstrap(); +} diff --git a/app/static/js/net/connection.js b/app/static/js/net/connection.js new file mode 100644 index 0000000..96e2140 --- /dev/null +++ b/app/static/js/net/connection.js @@ -0,0 +1,189 @@ +// Undercooked — the WebSocket connection (phase 07 task 02). +// +// A thin, protocol-only layer (hardened into the rejoin/backoff machine +// in phase 08): it opens `/ws/{code}`, sends the `hello` (PLAN A14 — +// the version travels in the hello only), dispatches every server frame +// to a single `onMessage` handler, sends client frames, and classifies +// how the socket died so the app's state machine can pick the right +// screen (the close-code decision table, docs/PROTOCOL.md "Heartbeats, +// drops, and seat grace"). +// +// Deliberate gaps in this task: no reconnect/backoff (phase 08), no +// snapshot buffering (task 03), no input prediction (task 08). The +// `protocol_mismatch` path *is* final in this task: a `p` the server +// refuses means this client must not retry — it stops and the app +// shows the error screen without a retry affordance. + +const PROTOCOL_VERSION = 1; + +/** The close codes the client can act on (docs/PROTOCOL.md "Close codes"). */ +export const CLOSE_CODES = Object.freeze({ + normal: 1000, + protocol_mismatch: 4400, + room_gone: 4401, + room_full: 4402, + evicted: 4403, + abuse: 4404, +}); + +/** The `err` codes that end the session on this socket (fatal for the app). */ +export const FATAL_ERR_CODES = Object.freeze([ + "room_gone", + "room_full", + "protocol_mismatch", + "evicted", + "internal", +]); + +/** + * Build a connection. The returned object's surface: + * + * connect(code, { token, name }) — open `/ws/{code}` and `hello` + * send(model) — one JSON frame out (false when closed) + * sendInput(move, act) — one `input` frame, `seq` owned here + * onMessage(frame) — the single reducer the app installs + * onStatus(status) — "idle" | "connecting" | "open" | "closed" | "mismatch" + * lastCloseCode — the close code of the last death (1006 if the + * transport died without a close frame) + * noteError(frame) — feed an `err` frame; `protocol_mismatch` + * latches the mismatch (no retry, ever) + * close() — a clean close (1000) + */ +export function createConnection() { + let socket = null; + let status = "idle"; + let lastCloseCode = 1006; + let mismatchLatched = false; + let seq = 0; + let pingTimer = null; + let pingClock = 0; + let onMessage = null; + let onStatus = null; + + function setStatus(next) { + if (status === next) return; + status = next; + if (onStatus) onStatus(status); + } + + function stopPing() { + if (pingTimer !== null) clearInterval(pingTimer); + pingTimer = null; + } + + function send(model) { + if (socket === null || socket.readyState !== 1 /* OPEN */) return false; + socket.send(JSON.stringify(model)); + return true; + } + + function connect(code, { token = null, name = null } = {}) { + if (socket !== null && socket.readyState !== 3 /* CLOSED */) close(); + lastCloseCode = 1006; + seq = 0; // a fresh socket: the server's `last_seq` starts at 0, so the + // first `input` frame must be seq >= 1 + const scheme = location.protocol === "https:" ? "wss" : "ws"; + const next = new WebSocket( + `${scheme}://${location.host}/ws/${encodeURIComponent(code)}`, + ); + socket = next; + setStatus("connecting"); + next.onopen = () => { + // `extra="forbid"` on the server: only `p`, `token`, `name` may ride + // the hello, and nulls are omitted rather than sent. + const hello = { type: "hello", p: PROTOCOL_VERSION }; + if (token !== null) hello.token = token; + if (name !== null) hello.name = name; + send(hello); + setStatus("open"); + // The keep-alive (docs/PROTOCOL.md "Heartbeats"): any inbound frame + // resets the server's 12 s dead window; a 1 s `ping` keeps a half-open + // TCP honest. Phase 08 raises this to the documented 5 Hz RTT probe. + stopPing(); + pingTimer = setInterval(() => { + send({ type: "ping", t: pingClock += 1 }); + }, 1000); + }; + next.onmessage = (event) => { + let frame = null; + try { + frame = JSON.parse(event.data); + } catch { + return; // the server must not send non-JSON; if it does, ignore + } + if (frame !== null && onMessage !== null) onMessage(frame); + }; + next.onerror = () => { + /* the close event follows; classified there */ + }; + next.onclose = (event) => { + // A replaced socket's close is stale (a `connect()` swapped it + // out): only the current socket's death is the connection's. + if (socket !== next) return; + stopPing(); + lastCloseCode = event.wasClean ? event.code : 1006; + if (mismatchLatched) setStatus("mismatch"); + else setStatus("closed"); + }; + return next; + } + + function sendInput(move, act) { + seq += 1; + const x = move === null ? 0 : move.x; + const y = move === null ? 0 : move.y; + // `face: null` = "derive from move" (docs/PROTOCOL.md "Input rules"); + // an explicit facing is task 05's (the keyboard module). + return send({ + type: "input", + seq, + move: { x, y }, + act: Boolean(act), + face: null, + }); + } + + function noteError(frame) { + if (frame !== null && frame.code === "protocol_mismatch") { + mismatchLatched = true; + if (status !== "mismatch") setStatus("mismatch"); + } + } + + function close() { + stopPing(); + const current = socket; + socket = null; + if (current !== null && current.readyState !== 3) { + try { + current.close(1000); + } catch { + /* already gone */ + } + } + if (status !== "mismatch" && status !== "idle") setStatus("closed"); + } + + return { + connect, + send, + sendInput, + close, + noteError, + get status() { + return status; + }, + get lastCloseCode() { + return lastCloseCode; + }, + get mismatchLatched() { + return mismatchLatched; + }, + set onMessage(handler) { + onMessage = handler; + }, + set onStatus(handler) { + onStatus = handler; + }, + }; +} diff --git a/app/static/js/net/decode.js b/app/static/js/net/decode.js new file mode 100644 index 0000000..d07822e --- /dev/null +++ b/app/static/js/net/decode.js @@ -0,0 +1,169 @@ +// Undercooked — the wire decoder (phase 07 task 03). +// +// The client-side counterpart of `server/encoding.py`. `applyDelta(frame, +// prev)` reconstructs the full frame the server would have sent from a +// `full: false` frame and the previous frame (a full one or a delta one +// — the mirror of the encoder's `_prev_entries`: a delta frame's entity +// lists live under `d`, and its `gone` tombstones are *not* part of the +// previous state). The enum mapping goes through `shared/enums.js` (the +// wire's string values and the facing index, parity-guarded by +// `tests/unit/test_client_enum_parity.py`). +// +// The delta contract (docs/PROTOCOL.md "The `snap` frame"): `d` carries +// *only the changed entities* — new, moved, or state-changed since +// `prev` (an entity back at its `prev` values is out) — plus a +// `{"gone": true}` tombstone for each entity that vanished. The scalars +// (`t`, `s`, `tgt`, `tl`) and the tick's events (`e`) ride every frame, +// full or delta, so a decoded frame takes them from the frame itself. +// +// The identity keys mirror the encoder's: the player's `i`, the station's +// `k` + coordinates (its internal id never appears on the wire), the +// order's `id`, the hazard's `t` + coordinates. + +import { FACING, EVENT_KIND, STATION_KIND, ITEM_KIND, ITEM_FORM } from "../shared/enums.js"; + +const COLLECTIONS = [ + ["p", "player"], + ["st", "station"], + ["o", "order"], + ["hz", "hazard"], +]; + +/** One entity's identity key (the encoder's `_key_of_*`, as a string). */ +export function keyOf(entity, kind) { + switch (kind) { + case "player": + return `p:${entity.i}`; + case "station": + return `st:${entity.k}@${entity.x},${entity.y}`; + case "order": + return `o:${entity.id}`; + case "hazard": + return `hz:${entity.t}@${entity.x},${entity.y}`; + default: + throw new Error(`unknown entity kind ${String(kind)}`); + } +} + +function isEntry(value) { + return typeof value === "object" && value !== null; +} + +/** + * The frame's entity entries for one collection (the mirror of + * `_prev_entries`): top-level on a full frame, under `d` on a delta + * frame; `gone` tombstones filtered out — they are not part of the + * state the delta is computed against. + */ +export function effectiveEntries(frame, key) { + if (!isEntry(frame)) return []; + const delta = frame.d; + const raw = + typeof delta === "object" && delta !== null ? delta[key] : frame[key]; + if (!Array.isArray(raw)) return []; + return raw.filter((entry) => isEntry(entry) && entry.gone !== true); +} + +/** + * Upsert / tombstone one collection: start from `previous` (in its + * order), replace a changed entry in place, append a new one, drop a + * tombstoned one. Deterministic — the same (prev, delta) pair always + * yields the same list. + */ +function applyCollection(previous, entries, kind) { + const out = previous.slice(); + for (const entry of entries) { + if (!isEntry(entry)) continue; + const k = keyOf(entry, kind); + const idx = out.findIndex((existing) => keyOf(existing, kind) === k); + if (entry.gone === true) { + if (idx !== -1) out.splice(idx, 1); // the tombstone: gone + continue; + } + if (idx === -1) out.push(entry); // new: appended + else out[idx] = entry; // changed: replaced in place + } + return out; +} + +function normalizeFull(frame) { + const list = (key) => (Array.isArray(frame[key]) ? frame[key] : []); + return { + full: true, + t: frame.t, + s: frame.s, + tgt: frame.tgt, + tl: frame.tl, + p: list("p"), + st: list("st"), + o: list("o"), + hz: list("hz"), + e: Array.isArray(frame.e) ? frame.e : [], + }; +} + +/** + * One raw `snap` frame in, one decoded full frame out. + * + * A `full: true` frame passes through (normalized: the entity arrays and + * `e` always present). A delta is applied against `prev` (the previous + * decoded full — or a raw full/delta frame, the encoder's own rule; + * `null` applies the delta's `d` against the empty state, best effort). + * The result is a full frame: `full: true`, the entity arrays top-level, + * the scalars and events from the frame itself. + */ +export function applyDelta(frame, prev = null) { + if (!isEntry(frame)) throw new Error("applyDelta: the frame is not an object"); + if (frame.full === true) return normalizeFull(frame); + const d = typeof frame.d === "object" && frame.d !== null ? frame.d : {}; + const base = isEntry(prev) ? prev : {}; + const out = { + full: true, + t: frame.t, + s: frame.s, + tgt: frame.tgt, + tl: frame.tl, + e: Array.isArray(frame.e) ? frame.e : [], + }; + for (const [key, kind] of COLLECTIONS) { + out[key] = applyCollection( + effectiveEntries(base, key), + Array.isArray(d[key]) ? d[key] : [], + kind, + ); + } + return out; +} + +// ------------------------------------------------------------ enum mapping + +/** The wire's `f` (an index) → the facing's string value, or undefined. */ +export function facingName(f) { + return Number.isInteger(f) && f >= 0 && f < FACING.length ? FACING[f] : undefined; +} + +/** Whether a string is a known event kind (the FX queue's admission gate). */ +export function isEventKind(k) { + return typeof k === "string" && EVENT_KIND.includes(k); +} + +/** Whether a string is a known station kind (the renderer's sprite gate). */ +export function isStationKind(k) { + return typeof k === "string" && STATION_KIND.includes(k); +} + +/** + * A short item form (`"tomato_chopped"`, the wire's `h` / `g`) → + * `{kind, form}`; null when the shape is not a `_`-joined pair of known + * values (a carried `plate_plated` is the plated dish; a null short is a + * nothing-in-hand, which the caller handles before calling this). + */ +export function splitItemShort(short) { + if (typeof short !== "string" || short === "") return null; + const idx = short.lastIndexOf("_"); + if (idx <= 0 || idx === short.length - 1) return null; + const kind = short.slice(0, idx); + const form = short.slice(idx + 1); + if (!ITEM_KIND.includes(kind) || !ITEM_FORM.includes(form)) return null; + return { kind, form }; +} diff --git a/app/static/js/net/events.js b/app/static/js/net/events.js new file mode 100644 index 0000000..1e922d7 --- /dev/null +++ b/app/static/js/net/events.js @@ -0,0 +1,161 @@ +// Undercooked — the event queue (phase 07 task 03). +// +// A pure module (no DOM, injected clock) that turns the wire's +// transient events — the `e[]` of a `snap` frame and the standalone +// `ev` message — into FX work with a bounded lifetime, so a burst of +// `chopped` events cannot leak listeners or replay after a resync. +// +// The rules: +// +// * **per-kind `ttlMs`** — each queued event lives until +// `now + TTL_MS[kind]` (a short table, a default for the rest); the +// renderer drains `due(now)` and the event's `until` is its lifetime; +// * **the dedup window** — the same event (kind + position + payload, +// the dedup key) pushed again within `dedupWindowMs` (100 ms) of the +// last accepted push is counted (`deduped`) and not queued again: +// the 20 Hz cadence can echo one tick's events, and the FX for a +// chop plays once; +// * **`drift(now)`** — prunes the expired (events past their `until`) +// and the dedup keys past their window; the renderer calls it each +// frame, so a queue left unattended shrinks to nothing on its own; +// * **the tick-reset detection** — a `push(t, ...)` whose `t` is +// *below* the last seen tick is a resync (a new round, a rejoin): +// the queue is cleared first, so a `served` from the previous round +// cannot replay over the new one. (`t` omitted — the standalone `ev` +// message has no tick — never resets.) + +import { isEventKind } from "./decode.js"; + +/** The FX lifetime per event kind (ms); anything unlisted gets the default. */ +const TTL_MS = Object.freeze({ + served: 1200, + score: 1200, + round_end: 3000, + order_missed: 1000, + denied: 600, + ghost_on: 2000, + ghost_off: 1500, + ghost_park: 2000, + resync: 400, +}); + +const DEFAULT_TTL_MS = 800; +const DEFAULT_DEDUP_WINDOW_MS = 100; + +/** + * One event queue. `push(t, entries, nowMs?)` — `t` the frame's tick + * (or null/undefined), `entries` the raw event entries (the frame's + * short-keyed `e` shape or the `ev` message's `{kind, payload}` shape), + * returns how many were queued. `due(nowMs?)` — the live events (in + * arrival order, raw entries). `drift(nowMs?)` — the prune; returns the + * number of entries dropped. `stats()` — `pushed`, `deduped`, + * `resyncs`, `pruned`, `size`. + */ +export function createEventQueue({ + now = null, + dedupWindowMs = DEFAULT_DEDUP_WINDOW_MS, +} = {}) { + const clock = typeof now === "function" ? now : () => performance.now(); + let entries = []; // { k, x, y, payload, raw, until } + let dedup = new Map(); // dedup key -> the last accepted push's time + let lastT = null; + const stats = { pushed: 0, deduped: 0, resyncs: 0, pruned: 0 }; + + function at(nowMs) { + return typeof nowMs === "number" ? nowMs : clock(); + } + + function reset() { + entries = []; + dedup = new Map(); + stats.resyncs += 1; + } + + /** The one wire shape the queue admits (both message shapes map here). */ + function normalize(entry) { + if (entry === null || typeof entry !== "object") return null; + const k = + typeof entry.k === "string" + ? entry.k + : typeof entry.kind === "string" + ? entry.kind + : null; + if (k === null || !isEventKind(k)) return null; // unknown kinds: no FX + return { + k, + x: typeof entry.x === "number" ? entry.x : 0, + y: typeof entry.y === "number" ? entry.y : 0, + payload: + entry.payload !== undefined && entry.payload !== null + ? entry.payload + : undefined, + }; + } + + function dedupKey(entry) { + const payload = + entry.payload !== undefined + ? JSON.stringify(entry.payload, Object.keys(entry.payload).sort()) + : ""; + return `${entry.k}|${Math.round(entry.x * 100)}|${Math.round(entry.y * 100)}|${payload}`; + } + + function push(t, eventEntries, nowMs) { + const nowAt = at(nowMs); + if (typeof t === "number") { + if (lastT !== null && t < lastT) { + reset(); // the tick went backwards: a new round (or a resync) + } + lastT = t; // monotonicity resumes from the new tick + } + let added = 0; + for (const raw of Array.isArray(eventEntries) ? eventEntries : []) { + const entry = normalize(raw); + if (entry === null) continue; + const key = dedupKey(entry); + const lastAccepted = dedup.get(key); + if (lastAccepted !== undefined && nowAt - lastAccepted < dedupWindowMs) { + stats.deduped += 1; // the echo: the FX already plays + continue; + } + const ttl = TTL_MS[entry.k] ?? DEFAULT_TTL_MS; + entries.push({ ...entry, until: nowAt + ttl }); + dedup.set(key, nowAt); + stats.pushed += 1; + added += 1; + } + return added; + } + + /** The live events (their `until` is at least `now`), in arrival order. */ + function due(nowMs) { + const nowAt = at(nowMs); + return entries + .filter((entry) => entry.until >= nowAt) + .map(({ k, x, y, payload }) => ({ k, x, y, payload })); + } + + /** The prune: events past their `until`, dedup keys past their window. */ + function drift(nowMs) { + const nowAt = at(nowMs); + const kept = entries.filter((entry) => entry.until >= nowAt); + const dropped = entries.length - kept.length; + entries = kept; + let dedupDropped = 0; + for (const [key, seen] of [...dedup]) { + if (nowAt - seen >= dedupWindowMs) { + dedup.delete(key); + dedupDropped += 1; + } + } + stats.pruned += dropped + dedupDropped; + return dropped + dedupDropped; + } + + return { + push, + due, + drift, + stats: () => ({ ...stats, size: entries.length }), + }; +} diff --git a/app/static/js/net/snapshots.js b/app/static/js/net/snapshots.js new file mode 100644 index 0000000..f9beeea --- /dev/null +++ b/app/static/js/net/snapshots.js @@ -0,0 +1,317 @@ +// Undercooked — the snapshot buffer and render-time interpolation +// (phase 07 task 03). +// +// A pure module: no DOM, no network, no wall clock — the clock is +// injected (`now`), which is what the e2e suite +// (`tests/e2e/test_us06_interp_math.py`) drives with synthetic streams. +// +// The buffer turns the `snap` stream into render-time frames: +// +// * frames are keyed by `t` (the server tick) and arrive in order; +// an older-or-duplicate `t` is **dropped** (counted), a `t` jump is +// a **gap** (recorded with its size); +// * a gap *longer than* `MAX_GAP_TICKS` (6) sets the resync latch and +// fires `onResync` — **debounced to one request per +// `RESYNC_DEBOUNCE_MS` (500 ms)**. While the latch is set, delta +// frames are *skipped* (their changes were partly missed; applying +// them against a stale `prev` would freeze half-changed state in the +// view): the stream's full cadence (`FULL_SNAPSHOT_EVERY_TICKS`, +// phase 06) guarantees a full frame arrives, and it clears the +// latch. That is the whole resync contract: never ask, just hold — +// the next full frame is the resync. +// * if no frame arrives for `maxAgeMs`, `sample()` **freezes** on the +// latest frame instead of extrapolating: the buffer never animates a +// ghost cook into a wall. +// +// `sample(now)` renders at `now - interpDelayMs` (the fixed 100 ms +// delay, docs/CLIENT.md "why 100 ms"): it returns the two bracketing +// decoded frames `{a, b}` plus the lerp factor `alpha` (0..1, by the +// frames' arrival times), or the frozen hold (`a === b`, `alpha 0`). +// `lerpState(a, b, alpha)` turns the bracket into a render-ready state: +// positions lerp along the straight line (the shorter path — a cook +// never passes through a wall, so the chord is the path), the facing +// snaps at the `alpha >= 0.5` boundary, progress / time-left / hazard +// life lerp, and entities added or removed between the frames appear at +// `b` (visible from `alpha 0`, at `b`'s position) and vanish at the +// `b` boundary (held at `a`'s position until `alpha >= 0.5`, then gone). +// Stations and orders diff by their wire identity (`k` + coordinates / +// `id`), players and hazards the same. +// +// `applyDelta` (imported from `net/decode.js`, re-exported here for the +// buffer's surface) decodes a raw frame; the buffer only ever stores +// decoded full frames. + +import { applyDelta as decodeApplyDelta } from "./decode.js"; + +export { decodeApplyDelta as applyDelta }; + +/** A `t` jump longer than this is a resync (6 = three 200 ms + * snapshot windows: beyond that the missing frames have changed too + * much to bridge by interpolation). */ +export const MAX_GAP_TICKS = 6; + +/** A resync request fires at most once per this window (debounce). */ +export const RESYNC_DEBOUNCE_MS = 500; + +/** + * One jitter-tolerant snapshot buffer. + * + * `push(raw)` — a raw `snap` frame (`full: true`, or a delta applied + * against the last decoded full); returns true when it was applied. + * `sample(nowMs?)` — `{a, b, alpha}` (or the frozen hold), null while + * empty. `latest()` — the last decoded full frame (the delta's `prev`), + * or null. `needsFullResync()` — the latch. `stats()` — the census + * (`pushed`, `dropped`, `gaps`, `maxGapTicks`, `skippedUntilFull`, + * `resyncs`, `lastT`). + */ +export function createSnapshotBuffer({ + interpDelayMs = 100, + maxAgeMs = 1000, + now = null, + onResync = null, +} = {}) { + const clock = typeof now === "function" ? now : () => performance.now(); + const frames = new Map(); // t -> { frame (decoded full), at (clock ms) } + let lastT = null; // the greatest tick applied + let lastPushAt = null; // the clock time of the last applied frame + let resyncPending = false; + let lastResyncAt = null; // the debounce window's start + const stats = { + pushed: 0, + dropped: 0, + gaps: 0, + maxGapTicks: 0, + skippedUntilFull: 0, + resyncs: 0, + }; + + function requestResync() { + const at = clock(); + if (lastResyncAt !== null && at - lastResyncAt < RESYNC_DEBOUNCE_MS) { + return; // debounced: one request per window + } + lastResyncAt = at; + stats.resyncs += 1; + if (typeof onResync === "function") onResync(); + } + + function prune() { + // Keep the interpolation window (a 100 ms delay is two 50 ms ticks, + // plus margin) and the most recent frames; anything older than + // `maxAgeMs + interpDelayMs` and at least 4 ticks back is dropped. + for (const [t, entry] of frames) { + if ( + lastT - t >= 4 && + lastPushAt !== null && + lastPushAt - entry.at > maxAgeMs + interpDelayMs + ) { + frames.delete(t); + } + } + } + + function push(raw) { + if (raw === null || typeof raw !== "object" || typeof raw.t !== "number") { + stats.dropped += 1; // malformed: counted, never fatal + return false; + } + if (lastT !== null && raw.t <= lastT) { + stats.dropped += 1; // older (or duplicate): the stream is ordered + return false; + } + if (lastT !== null && raw.t > lastT + 1) { + const gap = raw.t - lastT; + stats.gaps += 1; + if (gap > stats.maxGapTicks) stats.maxGapTicks = gap; + if (gap > MAX_GAP_TICKS) { + resyncPending = true; + requestResync(); + } + } + if (resyncPending && raw.full !== true) { + // The chain is broken (a delta whose `prev` we do not have): + // hold until the next full frame clears the latch. + stats.skippedUntilFull += 1; + return false; + } + const at = clock(); + const decoded = + raw.full === true ? raw : decodeApplyDelta(raw, latest()); + frames.set(raw.t, { frame: decoded, at }); + lastT = raw.t; + lastPushAt = at; + stats.pushed += 1; + if (raw.full === true) resyncPending = false; + prune(); + return true; + } + + function latest() { + if (lastT === null) return null; + const entry = frames.get(lastT); + return entry === undefined ? null : entry.frame; + } + + function isFrozen(nowMs) { + return lastPushAt !== null && nowMs - lastPushAt > maxAgeMs; + } + + /** + * The two bracketing decoded frames plus the lerp factor, at + * `nowMs - interpDelayMs` (an explicit `nowMs` wins over the clock — + * the synthetic-stream seam). Null while empty; the frozen hold + * (`a === b`, `alpha 0`, `frozen: true`) when the stream went quiet + * or when the render time outran every frame (a latency spike: + * hold, never extrapolate). + */ + function sample(nowMs) { + const at = typeof nowMs === "number" ? nowMs : clock(); + if (frames.size === 0) return null; + const ordered = [...frames.values()].sort((x, y) => x.at - y.at); + const newest = ordered[ordered.length - 1]; + if (isFrozen(at) || at - interpDelayMs >= newest.at) { + return { a: newest.frame, b: newest.frame, alpha: 0, frozen: true }; + } + const renderAt = at - interpDelayMs; + let bIdx = -1; + for (let i = 0; i < ordered.length; i += 1) { + if (ordered[i].at >= renderAt) { + bIdx = i; + break; + } + } + if (bIdx === -1) { + // `renderAt` is before the first frame (a late start): hold. + const oldest = ordered[0]; + return { a: oldest.frame, b: oldest.frame, alpha: 0 }; + } + if (bIdx === 0) { + const b = ordered[0]; + return { a: b.frame, b: b.frame, alpha: 0 }; + } + const aEntry = ordered[bIdx - 1]; + const bEntry = ordered[bIdx]; + const span = bEntry.at - aEntry.at; + const alpha = + span > 0 + ? Math.min(1, Math.max(0, (renderAt - aEntry.at) / span)) + : 0; + return { a: aEntry.frame, b: bEntry.frame, alpha }; + } + + return { + push, + sample, + latest, + needsFullResync: () => resyncPending, + stats: () => ({ ...stats, lastT, frozen: isFrozen(clock()) }), + }; +} + +// ------------------------------------------------------------ lerpState + +function lerp(v0, v1, alpha) { + return v0 + (v1 - v0) * alpha; +} + +function num(value, fallback = 0) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback; +} + +/** + * One collection between two frames: the union of the identity keys, in + * `left`'s order then the new ones; an entity in both is lerped (the + * per-kind fields below), one only in `right` appears at `b` (its own + * values, visible for the whole interval), one only in `left` vanishes + * at the `b` boundary (held until `alpha >= 0.5`, then dropped). + */ +function lerpCollection(left, right, key, kind, alpha, atHalf) { + const rightKeys = new Set(right.map((e) => key(e))); + const out = []; + for (const a of left) { + const k = key(a); + if (!rightKeys.has(k)) { + if (!atHalf) out.push(a); // still visible: held at a's values + continue; + } + const b = right.find((e) => key(e) === k); + out.push(lerpEntry(a, b, kind, alpha, atHalf)); + } + for (const b of right) { + if (!left.some((a) => key(a) === key(b))) out.push(b); // appears at b + } + return out; +} + +function lerpEntry(a, b, kind, alpha, atHalf) { + if (kind === "player") { + return { + ...b, + x: lerp(num(a.x), num(b.x), alpha), + y: lerp(num(a.y), num(b.y), alpha), + f: atHalf ? b.f : a.f, // the facing snaps at the boundary + h: atHalf ? b.h : a.h, // the carried item snaps with it + s: atHalf ? b.s : a.s, // the state snaps with it + }; + } + if (kind === "station") { + return { + ...b, + p: lerp(num(a.p), num(b.p), alpha), // the progress lerps + q: lerp(num(a.q, 1), num(b.q, 1), alpha), // the content quality + g: atHalf ? b.g : a.g, + s: atHalf ? b.s : a.s, + }; + } + if (kind === "order") { + return { + ...b, + l: lerp(num(a.l), num(b.l), alpha), // the time-left share lerps + }; + } + // hazard: the life lerps; a `moving_obstacle`'s position does too + return { + ...b, + x: lerp(num(a.x), num(b.x), alpha), + y: lerp(num(a.y), num(b.y), alpha), + l: lerp(num(a.l), num(b.l), alpha), + }; +} + +const PLAYERS = (f) => (Array.isArray(f.p) ? f.p : []); +const STATIONS = (f) => (Array.isArray(f.st) ? f.st : []); +const ORDERS = (f) => (Array.isArray(f.o) ? f.o : []); +const HAZARDS = (f) => (Array.isArray(f.hz) ? f.hz : []); + +const P_KEY = (e) => `p:${e.i}`; +const S_KEY = (e) => `st:${e.k}@${e.x},${e.y}`; +const O_KEY = (e) => `o:${e.id}`; +const H_KEY = (e) => `hz:${e.t}@${e.x},${e.y}`; + +/** + * A render-ready state from the bracketing frames: `lerpState(a, b, + * alpha)` with `a === b` (the frozen hold) returns `b`'s values + * verbatim for the discrete fields. The scalars: `t`/`s`/`tgt` from + * `b` (discrete), `tl` lerped; `e` rides with `b` (the renderer + * consumes events through the event queue, not the lerp). + */ +export function lerpState(a, b, alpha) { + if (b === null || typeof b !== "object") { + throw new Error("lerpState: `b` is not a frame"); + } + const left = a === null || typeof a !== "object" ? b : a; + const factor = typeof alpha === "number" ? Math.min(1, Math.max(0, alpha)) : 0; + const atHalf = factor >= 0.5; + return { + full: true, + t: b.t, + s: b.s, + tgt: b.tgt, + tl: lerp(num(left.tl), num(b.tl), factor), + p: lerpCollection(PLAYERS(left), PLAYERS(b), P_KEY, "player", factor, atHalf), + st: lerpCollection(STATIONS(left), STATIONS(b), S_KEY, "station", factor, atHalf), + o: lerpCollection(ORDERS(left), ORDERS(b), O_KEY, "order", factor, atHalf), + hz: lerpCollection(HAZARDS(left), HAZARDS(b), H_KEY, "hazard", factor, atHalf), + e: Array.isArray(b.e) ? b.e : [], + }; +} diff --git a/app/static/js/render/atlas.js b/app/static/js/render/atlas.js new file mode 100644 index 0000000..fabf6d3 --- /dev/null +++ b/app/static/js/render/atlas.js @@ -0,0 +1,312 @@ +// Undercooked — the SVG atlas (phase 07, task 01). +// +// Loads `app/static/assets/index.json` (written by +// `undercooked.tools.gen_art_index`) and turns every symbol into an +// `` the renderer can draw. Browsers do not render a +// `file.svg#symbol-id` fragment as an image (the symbol paints +// nothing), and the page CSP (`style-src 'self'`) blocks the +// `style`-attribute colour hooks the art uses — both in the decoded +// image and while `DOMParser` parses the file in the page context — +// so each symbol is decoded the way the engine actually supports: the +// file is fetched as text, the `style` hooks are pulled into neutral +// attributes, the wanted symbol is spliced into a standalone document +// (its `` references expanded to plain geometry, the hooks +// resolved to literal `fill`s), and that document is decoded through a +// `data:` URL — the CSP already allows it (`img-src data:`), and the +// bytes are the app's own. +// +// Contract: +// * `atlas.ready()` resolves when every symbol has decoded; it rejects +// (naming every sprite that failed) when the index or a sprite does +// not load, and a failed load clears the cache so a later `ready()` +// retries from scratch. +// * `atlas.draw(ctx, id, x, y, w, h, opts)` throws an `Error` for an +// unknown id or a sprite that decoded without pixels — it never +// draws nothing silently. +// * `opts.tint` is a CSS colour painted over the non-transparent part +// of the sprite (`source-atop`), `opts.alpha` a 0..1 opacity, +// `opts.flipX` mirrors the sprite in the target rect. + +const INDEX_URL = "/static/assets/index.json"; +const SVG_BASE = "/static/assets/"; + +const index = new Map(); // id -> { file, w, h } +const images = new Map(); // id -> HTMLImageElement +const svgTexts = new Map(); // file -> the fetched file text +let readyPromise = null; + +/** + * Resolve one `color: var(--uc-*, #hex)` (or literal `color: #hex`) + * style into a hex colour, or `null` when the style has no colour. + * An image document cannot resolve custom properties — the page's + * styles are CSP-blocked there and the decode fails with them — so + * the art's cook-colour hooks (a `style` on the ``, a + * `currentColor` on the shape it pulls in) are resolved here, before + * the document is serialised. + */ +function colourFromStyle(style) { + if (!style) return null; + const varMatch = style.match( + /color\s*:\s*var\(\s*(--[\w-]+)\s*,\s*(#[0-9a-fA-F]{3,8})\s*\)/i, + ); + if (varMatch) return varMatch[2]; + const literal = style.match(/color\s*:\s*(#[0-9a-fA-F]{3,8})(\s*[;,]|\s*$)/i); + return literal ? literal[1] : null; +} + +/** + * Expand every `` in a (cloned) subtree into a clone of its + * target, then paint each `fill="currentColor"` with `color`. The + * expansion happens because the colour hooks ride on a `style` + * attribute the image context forbids; once expanded, the shapes are + * plain geometry and the colour is a plain `fill`. + */ +function paintColour(container, color, document) { + const walk = (node) => { + for (const element of Array.from(node.children)) { + const isUse = element.tagName === "use"; + const isLink = element.tagName === "a"; + if (isUse || isLink) { + const href = element.getAttribute("href"); + const target = href ? document.getElementById(href.slice(1)) : null; + if (target) { + const expansion = target.cloneNode(true); + expansion.removeAttribute("id"); + element.replaceWith(expansion); + walk(expansion); + } else { + element.remove(); + } + continue; + } + if (element.getAttribute("fill") === "currentColor") { + element.setAttribute("fill", color); + } + walk(element); + } + }; + walk(container); +} + +/** + * Pull the `style` hooks out of the file text before parsing: a + * `DOMParser` of `image/svg+xml` applies inline `style` attributes in + * the embedding document's context, where the page CSP + * (`style-src 'self'`) blocks the `var()` colours before they can be + * read. The hooks are carried on a neutral attribute instead, in + * document order, so the parsed tree can be walked and each hook's + * colour resolved from the extracted value. + */ +function extractStyleHooks(fileText) { + const hooks = []; + const sanitized = fileText.replace(/\sstyle="([^"]*)"/g, (_match, value) => { + hooks.push(value); + return ` ucstyle="${hooks.length - 1}"`; + }); + return { hooks, sanitized }; +} + +/** + * The standalone document one symbol decodes into: the file's root with + * every other symbol removed and the wanted one replaced by a plain + * `` (a `` renders nothing on its own). `` references + * are expanded to plain geometry and the cook-colour `style` hooks are + * resolved to literal fills, because the image context forbids the + * `style` attribute (and any `var()`) the hooks would otherwise need. + * The symbol's `viewBox` (or `width`/`height`) sizes the root's + * viewport, so the geometry lands 1:1. + */ +function buildSvgDocument(fileText, file, id) { + const { hooks, sanitized } = extractStyleHooks(fileText); + const document = new DOMParser().parseFromString(sanitized, "image/svg+xml"); + if (document.querySelector("parsererror")) { + throw new Error(`atlas file failed to parse: ${file}`); + } + const root = document.documentElement.cloneNode(true); + const symbol = root.querySelector(`symbol#${CSS.escape(id)}`); + if (!symbol) throw new Error(`atlas symbol not found in ${file}: ${id}`); + for (const other of root.querySelectorAll("symbol")) { + if (other !== symbol) other.remove(); + } + const viewBox = symbol.getAttribute("viewBox"); + const group = document.createElementNS(document.documentElement.namespaceURI, "g"); + while (symbol.firstChild) group.appendChild(symbol.firstChild); + symbol.replaceWith(group); + // The colour hooks: a `ucstyle` on the that pulls in the shape. + for (const use of Array.from(group.children)) { + if (use.tagName !== "use") continue; + const hook = use.getAttribute("ucstyle"); + const color = + hook === null ? null : colourFromStyle(hooks[Number(hook)]); + use.removeAttribute("ucstyle"); + if (color === null) continue; + const href = use.getAttribute("href"); + const target = href ? document.getElementById(href.slice(1)) : null; + if (!target) { + use.remove(); + continue; + } + const expansion = target.cloneNode(true); + expansion.removeAttribute("id"); + use.replaceWith(expansion); + paintColour(expansion, color, document); + } + if (viewBox) { + root.setAttribute("viewBox", viewBox); + } else { + const width = symbol.getAttribute("width"); + const height = symbol.getAttribute("height"); + if (width) root.setAttribute("width", width); + if (height) root.setAttribute("height", height); + } + return "data:image/svg+xml;charset=utf-8," + encodeURIComponent( + new XMLSerializer().serializeToString(root), + ); +} + +function decodeImage(file, id) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`atlas image failed to decode: ${file}#${id}`)); + const text = svgTexts.get(file); + if (text === undefined) { + reject(new Error(`atlas has no text for ${file} (id ${id})`)); + return; + } + img.src = buildSvgDocument(text, file, id); + dataUrls.set(id, img.src); // the `data:` URL the DOM icons use (dataUrl) + }); +} + +async function load() { + const response = await fetch(INDEX_URL); + if (!response.ok) { + throw new Error(`atlas index fetch failed: HTTP ${response.status}`); + } + const table = await response.json(); + const entries = Object.entries(table); + if (entries.length === 0) throw new Error("atlas index is empty"); + const files = [...new Set(entries.map(([, entry]) => entry.file))]; + await Promise.all( + files.map(async (file) => { + const fileResponse = await fetch(`${SVG_BASE}${file}`); + if (!fileResponse.ok) { + throw new Error(`atlas fetch failed for ${file}: HTTP ${fileResponse.status}`); + } + svgTexts.set(file, await fileResponse.text()); + }), + ); + const settled = await Promise.allSettled( + entries.map(async ([id, entry]) => { + images.set(id, await decodeImage(entry.file, id)); + }), + ); + const failures = settled + .filter((outcome) => outcome.status === "rejected") + .map((outcome) => outcome.reason.message); + if (failures.length > 0) throw new Error(`atlas load failed: ${failures.join("; ")}`); + for (const [id, entry] of entries) index.set(id, entry); +} + +/** Resolve when every symbol has decoded; reject naming the failures. */ +export function ready() { + if (readyPromise === null) { + readyPromise = load().catch((error) => { + readyPromise = null; // a failed load may be retried from scratch + images.clear(); + index.clear(); + svgTexts.clear(); + dataUrls.clear(); + throw error; + }); + } + return readyPromise; +} + +/** The number of symbols the index names (0 before `ready()` resolves). */ +export function size() { + return index.size; +} + +/** The `{ file, w, h }` entry for one id, or `null` when unknown. */ +export function meta(id) { + return index.get(id) ?? null; +} + +/** + * The decoded `data:` URL for one id (or `null` when unknown / not + * ready) — the DOM half of the atlas: the HUD's recipe-ticket and + * streak icons are `` elements on these URLs (the CSP allows + * `img-src data:`; the bytes are the app's own, the same decode the + * canvas uses). `#symbol` fragments never render from a cross-file + * ``, so the `data:` URL is the only same-origin way to put a + * symbol in the DOM. + */ +export function dataUrl(id) { + const entry = index.get(id); + if (entry === undefined) return null; + return dataUrls.get(id) ?? null; +} + +const dataUrls = new Map(); // id -> the decoded data: URL (the decode's cache) + +/** + * Draw one symbol, scaled to the `(x, y, w, h)` rect, onto `ctx`. + * + * @param {CanvasRenderingContext2D} ctx the target 2D context + * @param {string} id a symbol id from index.json + * @param {number} x the rect's left edge + * @param {number} y the rect's top edge + * @param {number} w the rect's width + * @param {number} h the rect's height + * @param {{tint?: string, alpha?: number, flipX?: boolean}} [opts] + * @throws {Error} when the id is unknown or the sprite has no pixels — + * the atlas never draws nothing silently. + */ +export async function draw(ctx, id, x, y, w, h, opts = {}) { + await ready(); + paint(ctx, id, x, y, w, h, opts); +} + +/** + * The synchronous half of `draw` (the renderer's hot path: after + * `ready()` has resolved, every symbol is decoded and this draws + * without a promise round-trip). Throws like `draw` on an unknown id + * or a pixel-less sprite; throws `atlas not ready` before the load has + * settled (the renderer awaits `ready()` first). + */ +export function drawSync(ctx, id, x, y, w, h, opts = {}) { + if (readyPromise === null) { + throw new Error("atlas not ready (call ready() first)"); + } + paint(ctx, id, x, y, w, h, opts); +} + +function paint(ctx, id, x, y, w, h, opts = {}) { + const entry = index.get(id); + if (!entry) throw new Error(`unknown atlas id: ${id}`); + const img = images.get(id); + if (!img || !img.complete || img.naturalWidth === 0 || img.naturalHeight === 0) { + throw new Error(`atlas image has no pixels: ${id}`); + } + ctx.save(); + if (opts.flipX) { + // Mirror in the target rect: flip about its centre, draw as usual. + ctx.translate(x + w / 2, y + h / 2); + ctx.scale(-1, 1); + ctx.translate(-(x + w / 2), -(y + h / 2)); + } + if (typeof opts.alpha === "number") { + ctx.globalAlpha = Math.max(0, Math.min(1, opts.alpha)); + } + ctx.drawImage(img, x, y, w, h); + if (opts.tint) { + // `source-atop` paints the tint over the sprite's non-transparent + // pixels only, so the background stays untouched. + ctx.globalCompositeOperation = "source-atop"; + ctx.fillStyle = opts.tint; + ctx.fillRect(x, y, w, h); + } + ctx.restore(); +} diff --git a/app/static/js/render/camera.js b/app/static/js/render/camera.js new file mode 100644 index 0000000..020477d --- /dev/null +++ b/app/static/js/render/camera.js @@ -0,0 +1,197 @@ +// Undercooked — the camera (phase 07, task 04). +// +// Fits the (approximated) kitchen into the canvas viewport: one scale +// (pixels per tile) plus a centred letterbox offset. The scale is the +// fit-to-viewport value, snapped *down* to an integer-ish step +// (`snapScale`) so tile boundaries land on whole device pixels (a +// sub-pixel drift that changes every frame is shimmer), then clamped to +// the zoom bounds, which the task states in tiles-per-pixel +// (`minTilesPerPx` = the most zoomed-in, `maxTilesPerPx` = the most +// zoomed-out; `scale = 1 / tilesPerPx`). +// +// DPR-aware: the backing store is `cssSize × dpr` with +// `dpr = min(devicePixelRatio, 2)` (a 4K 3× display is not worth the +// fill-rate); `snapScale`'s step is `1 / (2 × dpr)`, so the snap is +// integer at the device-pixel level. `resize()` re-reads the CSS size +// (and re-applies the backing store), so a window resize or a DPR +// change (a drag between monitors) refits. +// +// A pure core (`computeFit`, `snapScale`) for the e2e suites plus a +// `createCamera` wrapper that owns the DOM plumbing (the canvas +// backing store, the `resize` / `visibilitychange` listeners). The +// world bounds are the kitchen's: the renderer derives them from the +// snapshot (the grid never leaves the server, PLAN §8 — the floor is +// the entity bounding box + a one-tile margin, the client-side +// approximation documented in `docs/CLIENT.md`), `setWorld(w, h)` +// refits only when the (rounded) bounds change, so a cook walking does +// not re-snap the scale mid-step. + +/** The `devicePixelRatio` cap (a 3× display renders at 2×). */ +export const MAX_DPR = 2; + +/** + * Snap a scale down to the integer-ish step. `step = 1 / (2 × dpr)` + * (dpr 1 → 0.5 px/tile, dpr 2 → 0.25): the largest multiple of the + * step at or below `scale`, at least one step (a zero-sized viewport + * must not produce a zero scale). `scale ≤ 0` is `0` (the caller's + * letterbox handles it — nothing to fit). + */ +export function snapScale(scale, dpr) { + const ratio = Math.max(1, Number.isFinite(dpr) ? dpr : 1); + if (!(scale > 0)) return 0; + const step = 1 / (2 * ratio); + const whole = Math.floor(scale / step - 1e-9); + return Math.max(1, whole) * step; +} + +/** + * One fit: `{scale, offX, offY}` in CSS pixels. The raw fit is + * `min(view / world)` on each axis; it is snapped down (`snapScale`) + * and clamped to `[1 / maxTilesPerPx, 1 / minTilesPerPx]`; the offset + * centres the world on the remainder (the letterbox, negative when the + * clamp zoomed in past the fit — the kitchen is bigger than the view, + * and the centre of both coincide). + */ +export function computeFit({ + viewW, + viewH, + worldW, + worldH, + dpr = 1, + minTilesPerPx = 0.02, + maxTilesPerPx = 0.5, +} = {}) { + const vw = Math.max(0, Number(viewW) || 0); + const vh = Math.max(0, Number(viewH) || 0); + const ww = Math.max(1e-6, Number(worldW) || 0); + const wh = Math.max(1e-6, Number(worldH) || 0); + if (vw === 0 || vh === 0) return { scale: 0, offX: 0, offY: 0 }; + const minScale = 1 / Math.max(1e-9, Number(maxTilesPerPx) || 0.5); + const maxScale = 1 / Math.max(1e-9, Number(minTilesPerPx) || 0.02); + const raw = Math.min(vw / ww, vh / wh); + const scale = Math.min(maxScale, Math.max(minScale, snapScale(raw, dpr))); + return { + scale, + offX: (vw - ww * scale) / 2, + offY: (vh - wh * scale) / 2, + }; +} + +/** + * One camera. + * + * `resize()` — re-reads the CSS size (`getSize()`), applies the backing + * store at the capped DPR and refits. `setWorld(w, h)` — the kitchen's + * bounds; a change (integer-rounded, so sub-pixel drift does not + * refit) refits. `worldToScreen(x, y)` / `screenToWorld(x, y)` — the + * transforms in CSS pixels (the renderer scales the context by the DPR + * itself, so the math stays in CSS units). `attach()` / `detach()` — + * the `resize` and `visibilitychange` listeners (the DPR can change + * with the window). + */ +export function createCamera({ + canvas = null, + minTilesPerPx = 0.02, + maxTilesPerPx = 0.5, + getSize = null, + dprOf = null, + onResize = null, +} = {}) { + const sizeOf = + typeof getSize === "function" + ? getSize + : () => ({ + w: canvas !== null ? canvas.clientWidth : 0, + h: canvas !== null ? canvas.clientHeight : 0, + }); + const dprGet = + typeof dprOf === "function" + ? dprOf + : () => + typeof window !== "undefined" && + Number.isFinite(window.devicePixelRatio) + ? Math.min(MAX_DPR, window.devicePixelRatio) + : 1; + + let worldW = 0; + let worldH = 0; + let key = null; // the rounded world + size + dpr the fit was computed for + let fit = { scale: 0, offX: 0, offY: 0 }; + let dpr = 1; + let cssW = 0; + let cssH = 0; + const listeners = []; + + function applySize() { + const size = sizeOf(); + cssW = size.w; + cssH = size.h; + dpr = dprGet(); + if (canvas !== null) { + canvas.width = Math.max(0, Math.round(cssW * dpr)); + canvas.height = Math.max(0, Math.round(cssH * dpr)); + } + refit(); + } + + function refit() { + const k = `${Math.round(worldW)}x${Math.round(worldH)}@${cssW}x${cssH}×${dpr}`; + if (k === key) return; + key = k; + fit = computeFit({ + viewW: cssW, + viewH: cssH, + worldW, + worldH, + dpr, + minTilesPerPx, + maxTilesPerPx, + }); + if (typeof onResize === "function") onResize(); + } + + function attach() { + if (typeof window !== "undefined") { + for (const name of ["resize", "visibilitychange"]) { + window.addEventListener(name, applySize); + listeners.push(name); + } + } + applySize(); + } + + function detach() { + if (typeof window !== "undefined") { + for (const name of listeners.splice(0)) window.removeEventListener(name, applySize); + } + } + + return { + attach, + detach, + resize: applySize, + setWorld: (w, h) => { + worldW = w; + worldH = h; + refit(); + }, + worldToScreen: (x, y) => ({ + x: fit.offX + x * fit.scale, + y: fit.offY + y * fit.scale, + }), + screenToWorld: (x, y) => ({ + x: (x - fit.offX) / (fit.scale || 1), + y: (y - fit.offY) / (fit.scale || 1), + }), + stats: () => ({ + scale: fit.scale, + offX: fit.offX, + offY: fit.offY, + worldW, + worldH, + cssW, + cssH, + dpr, + }), + }; +} diff --git a/app/static/js/render/canvas.js b/app/static/js/render/canvas.js new file mode 100644 index 0000000..5bc81d5 --- /dev/null +++ b/app/static/js/render/canvas.js @@ -0,0 +1,648 @@ +// Undercooked — the canvas renderer (phase 07, task 04). +// +// `drawFrame(view, nowMs)` draws one render-ready state (the loop's +// `lerpState` output, docs/CLIENT.md "The snapshot buffer and the +// render loop") in a fixed layer order — `LAYERS`, in draw order: +// +// floor → floor decals (spills) → station bases → carried-under +// items (the content a station holds) → players (y-sorted) → carried +// overlays (the held item) → station progress rings → hazards → +// particles (the FX) → selection / target highlight. +// +// Sprite selection maps the wire's vocabulary onto atlas ids: +// `stationSpriteId(kind, state)` (the stove has a sprite per state — +// `STATION_STATE_VARIANT`; the other fixtures are state-blind), +// `itemSpriteId(kind, form)` (the `REACHABLE_ITEM_FORMS` table — +// `tests/unit/test_client_sprite_map.py` keeps it equal to the sim's +// `reachable_item_forms()`, so a pair the simulation cannot produce +// falls back to the kind's `raw` sprite instead of a blank), +// `cookSpriteId(seat, facing, frame)` (4 bodies × 4 facings × 2 walk +// frames) and `tileSpriteId(kind, x, y)` (the floor's 4 texture +// variants, chosen by a deterministic cell hash). An atlas id that +// throws (an unknown id, a sprite with no pixels) is logged **once** +// (console, naming the id) and drawn as a plain fallback rect — +// never silently blank, never spammed. +// +// Animation is fixed-step: the walk cycle is `floor(now / 125) % 2` +// (8 steps/s, a 120 Hz display does not animate faster), the stun spin +// and the hazard pulses step the same way, and every clock is derived +// from the loop's `now` — the loop itself pauses on `document.hidden` +// (`render/loop.js`), so the sprites pause with it; a large `now` jump +// (a resume) cannot jump the animation, only land on the next step. +// +// The kitchen's floor is a client-side approximation: the grid never +// leaves the server (PLAN §8), so the floor is the stations' + the +// players' bounding box plus a one-tile margin, with the wall as the +// rectangle's edge. The target highlight is the same kind of +// approximation: `candidateCells(x, y, facing)` mirrors +// `sim/interact.py`'s `candidate_cells` (the front tile, the two +// diagonal-front, the tile under the cook) so reaching for a station +// reads the way the server will resolve it. +// +// `renderStats()` — the frame census the e2e perf assertions read off +// `window.__uc`: `drawCalls` (this frame), `frameMs` (moving average +// of the measured draw time), `frameMsP95` (over every frame since the +// renderer was created), `frames`, `warnedSprites`. + +import * as atlas from "./atlas.js"; +import { facingName, splitItemShort } from "../net/decode.js"; + +/** The layer order (the task's list, frozen — docs/CLIENT.md's table). */ +export const LAYERS = Object.freeze([ + "floor", + "floorDecals", + "stationBases", + "itemsUnder", + "players", + "carryOverlays", + "progressRings", + "hazards", + "particles", + "targetHighlight", +]); + +/** The walk cycle's step (ms per half-cycle: 8 steps/s, fixed-step). */ +export const WALK_STEP_MS = 125; + +/** One sprite of the stove per state (`burning` is a hazard, drawn in + * the hazard layer — a `fire` on the stove's cell switches the base). */ +export const STATION_STATE_VARIANT = Object.freeze({ + idle: "off", + working: "working", + busy: "working", + ready: "ready", + dirty: "off", + blocked: "off", +}); + +/** + * The (kind, form) pairs the simulation can produce — the client's copy + * of `undercooked.tools.gen_art_index.reachable_item_forms()`. + * `tests/unit/test_client_sprite_map.py` keeps it equal to the sim's + * fixpoint walk (a sim change that orphans a pair is a build + * failure, naming the pair). An unreachable pair maps to the kind's + * `raw` sprite (every kind has one): a protocol bug then draws a + * *something*, and the atlas-throw path stays reserved for art that + * is genuinely missing. + */ +export const REACHABLE_ITEM_FORMS = Object.freeze({ + tomato: ["raw", "chopped", "cooking", "cooked", "burnt"], + onion: ["raw", "chopped", "cooking", "cooked", "burnt"], + lettuce: ["raw", "chopped", "cooking", "cooked", "burnt"], + dough: ["raw", "chopped", "cooking", "cooked", "burnt"], + patty: ["raw", "chopped", "cooking", "cooked", "burnt"], + fish: ["raw", "chopped", "cooking", "cooked", "burnt"], + rice: ["raw", "cooking", "cooked", "burnt"], + plate: ["raw", "dirty", "plated"], +}); + +/** One station's atlas id (the stove's state, the others state-blind). */ +export function stationSpriteId(kind, state) { + if (kind === "stove") { + const variant = STATION_STATE_VARIANT[state] ?? "off"; + return `station-stove-${variant}`; + } + return `station-${kind}`; +} + +/** + * One (kind, form) pair's atlas id. A reachable pair is direct + * (`item-tomato-chopped`); an unreachable one (the sim cannot produce + * it — `rice_chopped`, a non-plate `plated`) falls back to the kind's + * `raw` sprite. + */ +export function itemSpriteId(kind, form) { + const forms = REACHABLE_ITEM_FORMS[kind]; + if (forms !== undefined && forms.includes(form)) { + return `item-${kind}-${form}`; + } + return `item-${kind}-raw`; +} + +/** One cook's atlas id (seat 0-3, facing index 0-3, frame 0-1). */ +export function cookSpriteId(seat, facing, frame) { + const name = facingName(facing) ?? "down"; + const body = (Math.max(0, Math.trunc(seat)) % 4) + 1; + const step = Number.isInteger(frame) ? Math.abs(frame) % 2 : 0; + return `cook-${body}-${name}-${step}`; +} + +/** The floor's variant for a cell (deterministic: ~1/3 are textured). */ +export function tileSpriteId(kind, x, y) { + if (kind !== "floor") return `tile-${kind}`; + const n = (Math.floor(x) * 3 + Math.floor(y) * 5) % 5; + return n < 2 ? `tile-floor-${n + 1}` : "tile-floor"; +} + +/** The stove's cookware for a kind (the sim's `STOVE_RAW_SECONDS` half: + * the pan at 5 s for patty/fish, the pot for the rest, rice included). */ +export function cookwareSpriteId(kind) { + return kind === "patty" || kind === "fish" ? "station-pan" : "station-pot"; +} + +/** + * The reach zone per facing (the client-side mirror of + * `sim/interact.py`'s `candidate_cells`: the front tile, the two + * diagonal-front tiles, the tile under the cook — in priority order, + * deduplicated). The facing is the wire's *name* (`"up"` / …). + */ +const CANDIDATE_OFFSETS = Object.freeze({ + up: [ + [0, -1], + [-1, -1], + [1, -1], + [0, 0], + ], + right: [ + [1, 0], + [1, -1], + [1, 1], + [0, 0], + ], + down: [ + [0, 1], + [1, 1], + [-1, 1], + [0, 0], + ], + left: [ + [-1, 0], + [-1, 1], + [-1, -1], + [0, 0], + ], +}); + +/** + * The cells the reaching hand walks, in priority order — the + * `interact.candidate_cells` mirror. `(x, y)` is the cook's continuous + * position; the cells are the (possibly out-of-bounds) integer pairs. + */ +export function candidateCells(x, y, facing) { + const base = [Math.floor(x), Math.floor(y)]; + const offsets = CANDIDATE_OFFSETS[facing] ?? CANDIDATE_OFFSETS.down; + const cells = []; + const seen = new Set(); + for (const [dx, dy] of offsets) { + const key = `${base[0] + dx},${base[1] + dy}`; + if (!seen.has(key)) { + seen.add(key); + cells.push([base[0] + dx, base[1] + dy]); + } + } + return cells; +} + +const FLOOR_MARGIN = 1; // the bounding box's padding (tiles) +const FALLBACK_WORLD = { minX: 0, minY: 0, w: 12, h: 8 }; + +/** The kitchen's bounds: the stations' + players' cells, padded. */ +function kitchenBounds(view) { + const cells = []; + for (const entry of view.st ?? []) { + if (Number.isFinite(entry.x) && Number.isFinite(entry.y)) { + cells.push([Math.trunc(entry.x), Math.trunc(entry.y)]); + } + } + for (const entry of view.p ?? []) { + if (Number.isFinite(entry.x) && Number.isFinite(entry.y)) { + cells.push([Math.floor(entry.x), Math.floor(entry.y)]); + } + } + if (cells.length === 0) return { ...FALLBACK_WORLD }; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const [cx, cy] of cells) { + minX = Math.min(minX, cx); + minY = Math.min(minY, cy); + maxX = Math.max(maxX, cx); + maxY = Math.max(maxY, cy); + } + minX -= FLOOR_MARGIN; + minY -= FLOOR_MARGIN; + return { + minX, + minY, + w: maxX - minX + 1 + FLOOR_MARGIN, + h: maxY - minY + 1 + FLOOR_MARGIN, + }; +} + +/** + * One renderer. `ensureReady()` awaits the atlas once (a failed load + * throws — the composition surfaces it on the error path). + * `drawFrame(view, nowMs)` draws the frame (and counts the draw + * calls). `renderStats()` is the census. `dispose()` drops the + * canvas (a new round mounts a fresh play screen — the composition + * rebuilds the renderer). + */ +export function createRenderer({ + canvas = null, + camera = null, + fx = null, + ownSeat = null, + reducedMotion = () => false, + now = null, +} = {}) { + const ctx = canvas !== null ? canvas.getContext("2d") : null; + const warned = new Set(); + const frameTimes = []; // every measured draw time (ms), since creation + const frameAvgWindow = 30; + let drawCalls = 0; + let frames = 0; + let lastNow = null; + let readyPromise = null; + let ready = false; + let boundsCache = null; // the kitchen's absolute corners, grow-only per runtime + + /** The reduced-motion half (the fx.js rule, mirrored): the canvas's + * `data-motion` attribute first (the composition sets it from + * `?motion=0`), a forced override, then the media query. */ + function reducedNow() { + if (canvas !== null && canvas.dataset !== null) { + if (canvas.dataset.motion === "reduced") return true; + } + if (typeof reducedMotion === "function") { + const forced = reducedMotion(); + if (forced === true) return true; + if (forced === false) return false; + } + return typeof window !== "undefined" && typeof window.matchMedia === "function" + ? window.matchMedia("(prefers-reduced-motion: reduce)").matches + : false; + } + + /** Await the atlas once (the first draw's one-time cost). */ + function ensureReady() { + if (ready) return Promise.resolve(); + if (readyPromise === null) { + readyPromise = atlas.ready().then(() => { + ready = true; + return true; + }); + } + return readyPromise; + } + + function statClock() { + return typeof now === "function" ? now() : performance.now(); + } + + /** + * Draw one sprite (a counted draw call). A throw — an unknown id, a + * sprite with no pixels — is logged once (naming the id), suppressed + * afterwards (no spam), and drawn as a fallback rect: the frame never + * loses a tile to a missing symbol. + */ + function sprite(id, x, y, w, h, opts = {}) { + drawCalls += 1; + try { + atlas.drawSync(ctx, id, x, y, w, h, opts); + } catch (error) { + if (!warned.has(id)) { + warned.add(id); + console.error(`undercooked: sprite ${id} failed (${error.message})`); + } + ctx.save(); + ctx.globalAlpha = (typeof opts.alpha === "number" ? opts.alpha : 1) * 0.55; + ctx.fillStyle = "#857259"; + ctx.fillRect(x, y, w, h); + ctx.restore(); + } + } + + function stepFrame(nowMs) { + // The fixed-step walk frame (8 steps/s — a 120 Hz display lands on + // the same frame sequence a 60 Hz one does). + return Math.floor(nowMs / WALK_STEP_MS) % 2; + } + + function stepIndex(nowMs, divisorMs) { + return Math.floor(nowMs / divisorMs); + } + + /** Draw one player (the layer 5 body + overlays are drawn here). */ + function drawPlayer(p, nowMs, toLocal, shake, s) { + const [lx, ly] = toLocal(p.x, p.y); + const cx = lx + (shake !== null ? shake.dx : 0) * s; + const cy = ly + (shake !== null ? shake.dy : 0) * s; + const body = s * 0.95; + const walk = + p.s === "moving" ? stepFrame(nowMs) : 0; + sprite(cookSpriteId(p.pi ?? 0, p.f ?? 2, walk), cx - body / 2, cy - body * 0.92, body, body); + if (p.s === "disconnected") { + sprite("ui-ghost", cx - s * 0.3, cy - s * 1.05, s * 0.6, s * 0.6, { alpha: 0.85 }); + } + if (p.s === "stunned") { + const spin = reducedNow() + ? 0 + : (stepIndex(nowMs, WALK_STEP_MS) % 4) * Math.PI * 0.5; + ctx.save(); + ctx.translate(cx, cy - s * 0.5); + ctx.rotate(spin); + sprite("cook-stun", -s * 0.42, -s * 0.42, s * 0.84, s * 0.84, { alpha: 0.9 }); + ctx.restore(); + } + // The carried item (layer 6) with its overlay: the held item above + // the head, the carry layer at the hand. + if (p.h !== null && p.h !== undefined && p.h !== "") { + const item = splitItemShort(p.h); + if (item !== null) { + sprite("cook-carry", cx - s * 0.3, cy - s * 0.72, s * 0.6, s * 0.6, { alpha: 0.95 }); + sprite( + itemSpriteId(item.kind, item.form), + cx - s * 0.25, + cy - s * 1.32, + s * 0.5, + s * 0.5, + ); + } + } + } + + /** + * One frame. `view` is the `lerpState` output (the entity lists + * already interpolated); `nowMs` the loop's clock (the animation's + * only time source — the loop's pause on `document.hidden` is the + * pause, and a resume's delta is dropped, not measured). + */ + function drawFrame(view, nowMs) { + if (ctx === null) return; + const t0 = statClock(); + lastNow = nowMs; + drawCalls = 0; + frames += 1; + + const bounds = growBounds(kitchenBounds(view)); + // The size reconciliation: the canvas can grow after the fit (the + // `hidden` un-hide, a window resize the camera's listeners miss on + // a canvas that was detached) — re-read the CSS size only when it + // changed, so the backing store and the fit track the element. + if (camera !== null && canvas !== null && canvas.isConnected) { + const css = camera.stats(); + if (canvas.clientWidth !== css.cssW || canvas.clientHeight !== css.cssH) { + camera.resize(); + } + } + if (camera !== null) camera.setWorld(bounds.w, bounds.h); + const stats = + camera !== null + ? camera.stats() + : { scale: 32, offX: 0, offY: 0, cssW: 1024, cssH: 640, dpr: 1 }; + const s = stats.scale; + const toLocal = (x, y) => { + const p = camera !== null ? camera.worldToScreen(x - bounds.minX, y - bounds.minY) : { x: 0, y: 0 }; + return [p.x, p.y]; + }; + + // The backing store's transform (DPR-aware; the math stays in CSS px). + const dpr = stats.dpr || 1; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, stats.cssW, stats.cssH); + drawCalls += 1; + + // 1 — the floor (the approximated kitchen, its edge the wall). + for (let cy = 0; cy < bounds.h; cy += 1) { + for (let cx = 0; cx < bounds.w; cx += 1) { + const [lx, ly] = toLocal(cx, cy); + sprite(tileSpriteId("floor", cx + bounds.minX, cy + bounds.minY), lx, ly, s, s); + } + } + const [ex, ey] = toLocal(0, 0); + ctx.save(); + ctx.strokeStyle = "#4a4036"; + ctx.lineWidth = Math.max(2, s * 0.08); + ctx.globalAlpha = 0.9; + ctx.strokeRect(ex, ey, bounds.w * s, bounds.h * s); + ctx.restore(); + drawCalls += 1; + + // 2 — floor decals (the spills: a splashed stain under everything). + for (const hz of view.hz ?? []) { + if (hz.t !== "spill") continue; + const [lx, ly] = toLocal(hz.x, hz.y); + sprite("fx-splash", lx + s * 0.05, ly + s * 0.2, s * 0.9, s * 0.55, { alpha: 0.5 }); + } + + // 3 — the station bases (a fire on a stove's cell is the burning + // variant — the hazard's visual half; the flame itself is layer 8). + const fireCells = new Set( + (view.hz ?? []).filter((hz) => hz.t === "fire").map((hz) => `${Math.trunc(hz.x)},${Math.trunc(hz.y)}`), + ); + for (const st of view.st ?? []) { + const [lx, ly] = toLocal(st.x, st.y); + let id = stationSpriteId(st.k, st.s); + if (st.k === "stove" && fireCells.has(`${Math.trunc(st.x)},${Math.trunc(st.y)}`)) { + id = "station-stove-burning"; + } + sprite(id, lx + s * 0.05, ly + s * 0.1, s * 0.9, s * 0.9); + } + + // 4 — the carried-under items (a station's content, on the fixture). + for (const st of view.st ?? []) { + if (st.g === null || st.g === undefined || st.g === "") continue; + const item = splitItemShort(st.g); + if (item === null) continue; + const [lx, ly] = toLocal(st.x, st.y); + if (st.k === "stove") { + sprite(cookwareSpriteId(item.kind), lx + s * 0.18, ly + s * 0.3, s * 0.64, s * 0.64); + } + sprite( + itemSpriteId(item.kind, item.form), + lx + s * 0.28, + ly + s * 0.22, + s * 0.44, + s * 0.44, + ); + } + + // 5 — the players, y-sorted (a deeper cook draws over a shallower). + const players = [...(view.p ?? [])].sort((a, b) => a.y - b.y); + for (const p of players) { + let shake = null; + if (fx !== null && p.i !== undefined) shake = fx.shakeOffset(p.i, nowMs); + drawPlayer(p, nowMs, toLocal, shake, s); + } + + // 7 — the progress rings (a working station's arc; the states' full + // rings). Drawn before the hazards (layer 8) so a flame sits over + // the ring it is burning. + for (const st of view.st ?? []) { + const [lx, ly] = toLocal(st.x, st.y); + const cx = lx + s * 0.5; + const cy = ly + s * 0.5; + const r = s * 0.46; + let color = null; + let fraction = null; + const progress = Number.isFinite(st.p) ? st.p : 0; + if (st.s === "working" && progress > 0 && progress < 1) { + color = "#ffa63d"; + fraction = progress; + } else if (st.s === "ready") { + color = "#67d16c"; + fraction = 1; + } else if (st.s === "busy") { + color = "#ffa63d"; + fraction = 1; + } else if (st.s === "blocked") { + color = "#ff5a4e"; + fraction = 1; + } else if (st.s === "dirty") { + color = "#9a8f7a"; + fraction = 1; + } + if (color !== null) { + ctx.save(); + ctx.strokeStyle = color; + ctx.lineWidth = Math.max(1.5, s * 0.07); + ctx.beginPath(); + if (fraction === 1) { + ctx.arc(cx, cy, r, 0, Math.PI * 2); + } else { + ctx.arc(cx, cy, r, -Math.PI / 2, -Math.PI / 2 + fraction * Math.PI * 2); + } + ctx.stroke(); + ctx.restore(); + drawCalls += 1; + } + } + + // 8 — the hazards (fires the animated flame, the moving obstacle a + // solid block, the rush a pulsing border — the reduced-motion + // fallbacks are static: same markers, no pulse). + for (const hz of view.hz ?? []) { + const [lx, ly] = toLocal(hz.x, hz.y); + if (hz.t === "fire") { + const pulse = reducedNow() + ? 1 + : 1 + 0.12 * Math.sin((stepIndex(nowMs, WALK_STEP_MS) / 4) * Math.PI * 2); + sprite("fx-flame", lx + s * 0.12, ly + s * 0.05 * pulse, s * 0.76, s * 0.8 * pulse, { alpha: 0.95 }); + } else if (hz.t === "moving_obstacle") { + ctx.save(); + ctx.globalAlpha = 0.9; + ctx.fillStyle = "#5c5248"; + ctx.fillRect(lx + s * 0.1, ly + s * 0.1, s * 0.8, s * 0.8); + ctx.strokeStyle = "#ff5a4e"; + ctx.lineWidth = Math.max(1.5, s * 0.06); + ctx.strokeRect(lx + s * 0.1, ly + s * 0.1, s * 0.8, s * 0.8); + ctx.restore(); + drawCalls += 1; + } else if (hz.t === "rush") { + const [rx, ry] = toLocal(0, 0); + const alpha = reducedNow() ? 0.35 : 0.25 + 0.2 * Math.abs(Math.sin((stepIndex(nowMs, 125) / 2) * Math.PI * 2)); + ctx.save(); + ctx.strokeStyle = "#ff5a4e"; + ctx.globalAlpha = alpha; + ctx.lineWidth = Math.max(2, s * 0.1); + ctx.strokeRect(rx + s * 0.05, ry + s * 0.05, bounds.w * s - s * 0.1, bounds.h * s - s * 0.1); + ctx.restore(); + drawCalls += 1; + } + // a `spill` is a floor decal (layer 2) — no hazard-layer mark. + } + + // 9 — the FX's particles (the event effects; `update` advances the + // pool, this draws it). + if (fx !== null) fx.draw(ctx, toLocal, s, nowMs); + + // 10 — the target highlight: the viewer's own cook's reach zone, + // the `candidate_cells` mirror, faint (the server's own target, + // approximated — reaching reads predictably). + const own = + typeof ownSeat === "function" + ? ownSeat() + : ownSeat !== null && ownSeat !== undefined + ? ownSeat + : null; + const me = + own !== null + ? (view.p ?? []).find((p) => p.pi === own) + : undefined; + if (me !== undefined) { + const facing = facingName(me.f) ?? "down"; + ctx.save(); + ctx.strokeStyle = "#67d16c"; + ctx.globalAlpha = 0.28; + ctx.lineWidth = Math.max(1.5, s * 0.06); + for (const [cx, cy] of candidateCells(me.x, me.y, facing)) { + // The candidate cells are absolute grid cells; `toLocal` wants + // bounding-box-local coordinates — subtract the box's origin. + const px = camera !== null + ? camera.worldToScreen(cx - bounds.minX, cy - bounds.minY) + : { x: 0, y: 0 }; + ctx.strokeRect(px.x + s * 0.08, px.y + s * 0.08, s * 0.84, s * 0.84); + } + ctx.restore(); + drawCalls += 1; + } + + const dtMeasured = statClock() - t0; + frameTimes.push(dtMeasured); + return { drawCalls, bounds }; + } + + /** The grow-only bounds cache: a wandering cook can extend the box + * (a new cell) but never shrink it — the camera refits only when the + * kitchen is genuinely discovered further, not on every sub-cell + * crossing (a mid-walk re-snap is the shimmer the task forbids). */ + function growBounds(bounds) { + const box = { + minX: bounds.minX, + minY: bounds.minY, + maxX: bounds.minX + bounds.w - 1, + maxY: bounds.minY + bounds.h - 1, + }; + if (boundsCache === null) { + boundsCache = box; + } else { + boundsCache.minX = Math.min(boundsCache.minX, box.minX); + boundsCache.minY = Math.min(boundsCache.minY, box.minY); + boundsCache.maxX = Math.max(boundsCache.maxX, box.maxX); + boundsCache.maxY = Math.max(boundsCache.maxY, box.maxY); + } + return { + minX: boundsCache.minX, + minY: boundsCache.minY, + w: boundsCache.maxX - boundsCache.minX + 1, + h: boundsCache.maxY - boundsCache.minY + 1, + }; + } + + function dispose() { + ready = false; + readyPromise = null; + boundsCache = null; + } + + /** The frame census (the e2e perf assertions' seam, on `window.__uc`). */ + function renderStats() { + const recent = frameTimes.slice(-frameAvgWindow); + const avg = + recent.length === 0 + ? 0 + : recent.reduce((sum, v) => sum + v, 0) / recent.length; + const sorted = [...frameTimes].sort((a, b) => a - b); + const p95 = + sorted.length === 0 + ? 0 + : sorted[Math.min(sorted.length - 1, Math.ceil(0.95 * sorted.length) - 1)]; + return { + drawCalls, + frames, + frameMs: avg, + frameMsP95: p95, + frameMsMax: sorted.length === 0 ? 0 : sorted[sorted.length - 1], + warnedSprites: [...warned], + }; + } + + return { + ensureReady, + drawFrame, + renderStats, + dispose, + isReady: () => ready, + }; +} diff --git a/app/static/js/render/fx.js b/app/static/js/render/fx.js new file mode 100644 index 0000000..e396970 --- /dev/null +++ b/app/static/js/render/fx.js @@ -0,0 +1,361 @@ +// Undercooked — the event FX (phase 07, task 04). +// +// The wire's transient events (the snapshot's `e[]`, the standalone +// `ev`) become on-screen effects here: `chopped`'s spark, `cooked` / +// `ready`'s steam, `served`'s pop + the score float, `burnt`'s smoke, +// `denied`'s cross + the nudge, `stunned`'s spin, `order_missed`'s +// flash, `score`'s float, plus `washed` / `trashed` / `picked` / +// `handed_off`'s small marks. +// +// The renderer drains the event queue (`net/events.js`'s `due(now)`) +// each frame and hands the live entries to `consume`; `consume` +// spawns each entry's effect **once** — its own 100 ms dedup window +// (the same key the queue uses) suppresses the frames the same queued +// entry is repeated over, and a genuinely new event (more than the +// window later, or the queue's tick-reset cleared the old one) spawns +// again. The queue's TTL is the effect's lifetime source: a `served` +// lives 1200 ms, a `chopped` 800. +// +// **A small particle pool, no allocation per event.** `PARTICLES` is a +// fixed array of pre-built particle objects; `spawn` reuses them by a +// cursor (a full pool wraps and steals the oldest), and the one-shot +// effects (the sprite marks: the steam, the deny cross, the flash) +// live in a second fixed `MARKS` array the same way. Nothing is +// `new`-ed after `createFx` — a chop burst cannot grow the heap. +// +// `[data-motion="reduced"]` (or `prefers-reduced-motion`) is the +// fallback: the markers stay (the deny cross, the spin mark, the +// flash's border — the *static* versions), the particles do not +// launch, the nudge's shake never applies (`shakeApplied` stays 0), +// and nothing moves. `reduced()` reads the canvas's `data-motion` +// attribute first (the composition sets it from the `?motion=0` +// param, `docs/CLIENT.md` "The URL params"), then the media query. + +import * as atlas from "./atlas.js"; + +const PARTICLE_COUNT = 96; // the pool size (a full burst wraps, never grows) +const MARK_COUNT = 24; // the one-shot effect slots +const DEDUP_WINDOW_MS = 100; // the queue's window — the same dedup rule + +/** + * The event → effect table. `sprite` is the atlas id of the one-shot + * mark (the `reduced` fallback's static marker); `particle` the pool + * particle's colour and shape (the non-reduced launch); `lifeMs` the + * effect's own lifetime (within the queue kind's TTL). + */ +const EFFECTS = Object.freeze({ + chopped: { sprite: "fx-chop", particle: { color: "#ffd166" }, lifeMs: 500 }, + cooked: { sprite: "fx-steam", particle: { color: "#d9d4c8" }, lifeMs: 800 }, + ready: { sprite: "fx-steam", particle: { color: "#d9d4c8" }, lifeMs: 600 }, + washed: { sprite: "fx-steam", particle: { color: "#bfe3ff" }, lifeMs: 600 }, + served: { sprite: "fx-serve", particle: { color: "#ffe066" }, lifeMs: 1000 }, + burnt: { sprite: "fx-flame", particle: { color: "#8a8a8a" }, lifeMs: 900 }, + denied: { sprite: "fx-deny", particle: null, lifeMs: 600, nudge: true }, + stunned: { sprite: "fx-steam", particle: null, lifeMs: 800, spin: true }, + order_missed: { sprite: "fx-deny", particle: null, lifeMs: 1000, flash: true }, + score: { sprite: "fx-score", particle: { color: "#ffe066" }, lifeMs: 1000, float: true }, + trashed: { sprite: "fx-splash", particle: { color: "#9a8f7a" }, lifeMs: 500 }, + picked: { sprite: "fx-chop", particle: { color: "#d9d4c8" }, lifeMs: 350 }, + dropped: { sprite: "fx-splash", particle: { color: "#9a8f7a" }, lifeMs: 350 }, + handed_off: { sprite: "fx-score", particle: { color: "#d9d4c8" }, lifeMs: 350 }, + fire_started: { sprite: "fx-flame", particle: { color: "#ffa63d" }, lifeMs: 800 }, + ghost_on: { sprite: "ui-ghost", particle: null, lifeMs: 1200 }, + ghost_off: { sprite: "ui-ghost", particle: null, lifeMs: 800 }, +}); + +function keyOf(entry) { + const payload = + entry.payload !== undefined && entry.payload !== null + ? JSON.stringify(entry.payload, Object.keys(entry.payload).sort()) + : ""; + return `${entry.k}|${Math.round((entry.x ?? 0) * 100)}|${Math.round((entry.y ?? 0) * 100)}|${payload}`; +} + +/** + * One FX engine. `consume(entries, view, nowMs)` — the queued entries + * (the queue's `due(now)`), the frame (for the nudge's player lookup), + * the clock. `update(nowMs)` — advances the particle pool (a clamped + * `dt`: a resume's jump cannot fling the particles). `draw(ctx, + * toLocal, tile, nowMs)` — draws the live marks + particles (the + * renderer's layer 9). `shakeOffset(playerId, nowMs)` — the nudge's + * decaying offset in *tiles* (the renderer adds it to the player's + * draw position). `stats()` — the census (the e2e's reduced-motion + * seam: `processed`, `shakeApplied`, `particlesLaunched`). + */ +export function createFx({ + canvas = null, + reduced = null, + media = null, +} = {}) { + const query = + typeof media === "function" + ? media + : () => + typeof window !== "undefined" && + typeof window.matchMedia === "function" + ? window.matchMedia("(prefers-reduced-motion: reduce)").matches + : false; + + const particles = Array.from({ length: PARTICLE_COUNT }, () => ({ + active: false, + x: 0, + y: 0, + vx: 0, + vy: 0, + born: 0, + life: 0, + color: "#ffffff", + size: 1, + })); + let particleCursor = 0; + const marks = Array.from({ length: MARK_COUNT }, () => ({ + active: false, + x: 0, + y: 0, + born: 0, + life: 0, + sprite: "", + text: "", + color: "#ffffff", + alpha: 1, + })); + let markCursor = 0; + const seen = new Map(); // dedup key -> the last accepted spawn (nowMs) + const shakes = new Map(); // player id -> { born, life } (the nudge's window) + let lastNow = null; + const stats = { + processed: {}, + shakeApplied: 0, + particlesLaunched: 0, + deduped: 0, + }; + + /** The reduced-motion half: the `data-motion` attribute first, then + * the media query (either one is enough). */ + function reducedMotion() { + if (typeof reduced === "function") { + const forced = reduced(); + if (forced !== null && forced !== undefined) { + return Boolean(forced); + } + } + if (canvas !== null && canvas.dataset !== null) { + const attr = canvas.dataset.motion; + if (attr === "reduced") return true; + } + return query(); + } + + function stealMark() { + const slot = marks[markCursor]; + markCursor = (markCursor + 1) % MARK_COUNT; + return slot; + } + + function stealParticle() { + const slot = particles[particleCursor]; + particleCursor = (particleCursor + 1) % PARTICLE_COUNT; + return slot; + } + + function launchParticles(x, y, color, nowMs, count) { + for (let i = 0; i < count; i += 1) { + const p = stealParticle(); + p.active = true; + p.x = x; + p.y = y; + const angle = (i / count) * Math.PI * 2 + (nowMs % 100) * 0.01; + p.vx = Math.cos(angle) * 0.6; + p.vy = Math.sin(angle) * 0.6 - 0.4; + p.born = nowMs; + p.life = 500 + (i % 4) * 80; + p.color = color; + p.size = 0.08 + (i % 3) * 0.03; + stats.particlesLaunched += 1; + } + } + + /** + * One queued entry (its `k` is the event kind, `x`/`y` tile units, + * `payload` the short-keyed map). Spawns the effect once; the 100 ms + * window suppresses the same entry's repeat frames. + */ + function consumeOne(entry, view, nowMs) { + const spec = EFFECTS[entry.k]; + if (spec === undefined) return; // an unlisted kind (the queue's admission gate already skipped it) + const key = keyOf(entry); + const last = seen.get(key); + if (last !== undefined && nowMs - last < DEDUP_WINDOW_MS) { + stats.deduped += 1; // the echo: the effect already plays + return; + } + seen.set(key, nowMs); + stats.processed[entry.k] = (stats.processed[entry.k] ?? 0) + 1; + const reducedNow = reducedMotion(); + + if (spec.sprite !== "") { + const mark = stealMark(); + mark.active = true; + mark.x = entry.x ?? 0; + mark.y = entry.y ?? 0; + mark.born = nowMs; + mark.life = spec.lifeMs; + mark.sprite = spec.sprite; + mark.alpha = 1; + // The float text (the score's `+pts`, the served's `pts`). + const pts = + entry.payload !== null && entry.payload !== undefined + ? entry.payload.pts + : undefined; + mark.text = + spec.float && Number.isFinite(pts) ? `+${pts}` : ""; + mark.color = spec.particle !== null ? spec.particle.color : "#ffffff"; + } + if (spec.particle !== null && !reducedNow) { + launchParticles(entry.x ?? 0, entry.y ?? 0, spec.particle.color, nowMs, 5); + } + if (spec.nudge && view !== null && view !== undefined) { + const pid = + entry.payload !== null && entry.payload !== undefined + ? entry.payload.i + : undefined; + if (pid !== undefined) { + const existing = shakes.get(pid); + if (existing === undefined || nowMs - existing.born > DEDUP_WINDOW_MS) { + shakes.set(pid, { born: nowMs, life: 250 }); + } + } + } + } + + /** + * The queued entries (the queue's `due(now)` — a list; a single + * entry is tolerated) — each is spawned once. + */ + function consume(entries, view, nowMs) { + const list = Array.isArray(entries) ? entries : [entries]; + for (const entry of list) consumeOne(entry, view, nowMs); + } + + /** The queue's prune's mirror: drop the dedup keys and the nudge + * windows past their lifetime (the queue's `drift` does its half). */ + function prune(nowMs) { + for (const [key, at] of [...seen]) { + if (nowMs - at >= DEDUP_WINDOW_MS) seen.delete(key); + } + for (const [pid, win] of [...shakes]) { + if (nowMs - win.born >= win.life) shakes.delete(pid); + } + } + + function update(nowMs) { + const dt = + lastNow === null ? 0 : Math.min(100, Math.max(0, nowMs - lastNow)); + lastNow = nowMs; + if (dt > 0) { + const seconds = dt / 1000; + for (const p of particles) { + if (!p.active) continue; + if (nowMs - p.born >= p.life) { + p.active = false; + continue; + } + p.x += p.vx * seconds; + p.y += p.vy * seconds; + p.vy += 1.4 * seconds; // the gravity (tiles/s²) + } + } + prune(nowMs); + } + + /** + * The nudge's offset in tiles (the denied's push-back): a damped + * 250 ms wobble along the event's arrival — zero when reduced motion + * (the task's "no shake" half) or outside the window. + */ + function shakeOffset(playerId, nowMs) { + const win = shakes.get(playerId); + if (win === undefined || reducedMotion() || nowMs - win.born >= win.life) { + return null; + } + const t = (nowMs - win.born) / win.life; // 0..1 + const wobble = Math.sin(t * Math.PI * 3) * 0.06 * (1 - t); + stats.shakeApplied += 1; + return { dx: wobble, dy: -Math.abs(wobble) * 0.5 }; + } + + /** Draw the live marks + particles (the renderer's layer 9). */ + function draw(ctx, toLocal, tile, nowMs) { + for (const mark of marks) { + if (!mark.active) continue; + const age = nowMs - mark.born; + if (age >= mark.life) { + mark.active = false; + continue; + } + const [x, y] = toLocal(mark.x, mark.y); + const alpha = 1 - age / mark.life; + if (mark.sprite === "fx-flame" && mark.text === "" && mark.color === "#8a8a8a") { + // The burnt's smoke: the flame sprite, grey, rising. + ctx.save(); + ctx.globalAlpha = alpha * 0.55; + ctx.translate(0, -age * 0.0012 * tile); + // `fx-flame` is the art's only smoke-able symbol: greyed, it + // reads as smoke. + drawSprite(ctx, "fx-flame", x - tile * 0.2, y - tile * 0.6, tile * 0.55, tile * 0.7, { alpha: 1 }); + ctx.restore(); + continue; + } + drawSprite(ctx, mark.sprite, x - tile * 0.3, y - tile * 0.55, tile * 0.6, tile * 0.55, { alpha }); + if (mark.text !== "") { + ctx.save(); + ctx.globalAlpha = alpha; + ctx.fillStyle = "#ffe066"; + ctx.font = `bold ${Math.max(12, tile * 0.34)}px ui-sans-serif, system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.fillText(mark.text, x, y - tile * 0.7 - age * 0.0015 * tile); + ctx.restore(); + } + } + for (const p of particles) { + if (!p.active) continue; + const age = nowMs - p.born; + if (age >= p.life) continue; + const [x, y] = toLocal(p.x, p.y); + ctx.save(); + ctx.globalAlpha = (1 - age / p.life) * 0.9; + ctx.fillStyle = p.color; + ctx.beginPath(); + ctx.arc(x, y - tile * 0.4, Math.max(1.5, p.size * tile), 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + } + + // The marks draw through the atlas directly; a throw (a missing + // symbol) cannot blank the frame — the mark's slot just does not + // draw this frame (the renderer's own sprite helper owns the + // once-logged fallback for the *layer* sprites). + function drawSprite(ctx, id, x, y, w, h, opts) { + try { + atlas.drawSync(ctx, id, x, y, w, h, opts); + } catch { + /* a missing symbol: the mark's slot stays, the frame goes on */ + } + } + + return { + consume, + update, + draw, + shakeOffset, + reduced: reducedMotion, + stats: () => ({ + processed: { ...stats.processed }, + shakeApplied: stats.shakeApplied, + particlesLaunched: stats.particlesLaunched, + deduped: stats.deduped, + activeParticles: particles.filter((p) => p.active).length, + }), + }; +} diff --git a/app/static/js/render/loop.js b/app/static/js/render/loop.js new file mode 100644 index 0000000..36e6038 --- /dev/null +++ b/app/static/js/render/loop.js @@ -0,0 +1,144 @@ +// Undercooked — the single render loop (phase 07 task 03). +// +// One `requestAnimationFrame` loop per page — the single-owner rule: +// exactly one loop instance exists (the composition root, `main.js`, +// creates it), and it is the only thing that samples the buffer, +// calls the renderer and updates the HUD +// (`sample → render → hud.update` per frame, docs/CLIENT.md "The +// render loop"). No screen, no module, no test spawns a second loop: +// two owners of the clock is how the frame-time accounting goes wrong. +// +// The loop's lifecycle: +// +// * **paused when `document.hidden`** — the rAF chain stops entirely +// (a pending frame that finds `hidden` renders nothing and does not +// reschedule; the `visibilitychange` back to visible restarts it). +// The delta spanning the pause is *dropped*, not measured, so a +// resume cannot pollute the p50/p95 window. +// * **idle when the screen is not `PLAYING`** — `active()` is the +// composition's predicate (the screen's name); a non-active frame +// renders nothing but keeps the chain alive, so the loop resumes on +// the very next frame after the screen turns `PLAYING` (no event +// needed). +// * **`?freeze=1`** — the composition's business (it holds the *sample +// time* at the first playing frame); the loop itself just renders +// whatever `render(nowMs)` asks for, so a frozen render is a still +// image without the loop pausing. +// +// Self-measured: every rendered frame's time (the `now` deltas) lands +// in a sliding 120-sample window; `frameStats()` reports `frames`, +// `running`, `hidden`, `p50` / `p95` (the `frameMs` the spec wants on +// `window.__uc`) and `max`. The window resets on `start()`. + +export function createRenderLoop({ + active = () => true, + render = null, + now = null, + raf = null, + unraf = null, + owner = null, +} = {}) { + const clock = typeof now === "function" ? now : () => performance.now(); + const schedule = typeof raf === "function" ? raf : (fn) => requestAnimationFrame(fn); + const cancel = typeof unraf === "function" ? unraf : (id) => cancelAnimationFrame(id); + const visOwner = owner !== null ? owner : typeof document !== "undefined" ? document : null; + + let running = false; + let scheduled = null; + let hidden = visOwner !== null ? Boolean(visOwner.hidden) : false; + let frames = 0; + let lastNow = null; + let pausedAt = null; // the pause boundary (the resume's delta drops) + const sampleWindow = 120; + const deltas = []; + + const onVisibilityChange = () => { + const wasHidden = hidden; + hidden = visOwner !== null ? Boolean(visOwner.hidden) : false; + if (hidden && !wasHidden) pausedAt = clock(); // mark the pause + if (running && !hidden && scheduled === null) scheduleFrame(); // resume + }; + + function frame() { + scheduled = null; + if (!running || hidden) return; // stopped or paused: wait for a resume + const t = clock(); + if (active()) { + if (lastNow !== null) { + const dt = t - lastNow; + const resumed = pausedAt !== null; // a pause just ended + pausedAt = null; // consumed once, by the first resumed frame + if (!resumed) { + deltas.push(dt); + if (deltas.length > sampleWindow) deltas.shift(); + } + } + lastNow = t; + frames += 1; + if (typeof render === "function") render(t); + if (running && !hidden) scheduleFrame(); + } else { + // Idle (the screen is not PLAYING): keep the chain alive so the + // active() flip is picked up on the next frame; a fresh round + // starts with a fresh delta, and a pending pause boundary cannot + // survive an idle period (it would drop a real delta later). + pausedAt = null; + lastNow = null; + scheduleFrame(); + } + } + + function scheduleFrame() { + scheduled = schedule(frame); + } + + function start() { + if (running) return; + running = true; + frames = 0; + lastNow = null; + pausedAt = null; + deltas.length = 0; + if (visOwner !== null) { + visOwner.addEventListener("visibilitychange", onVisibilityChange); + } + if (!hidden) scheduleFrame(); + } + + function stop() { + if (!running) return; + running = false; + if (scheduled !== null) { + cancel(scheduled); + scheduled = null; + } + if (visOwner !== null) { + visOwner.removeEventListener("visibilitychange", onVisibilityChange); + } + } + + /** The self-measured frame census (the `frameMs` p50/p95 on `window.__uc`). */ + function frameStats() { + const sorted = [...deltas].sort((a, b) => a - b); + const quantile = (q) => + sorted.length === 0 + ? 0 + : sorted[Math.min(sorted.length - 1, Math.ceil(q * sorted.length) - 1)]; + return { + frames, + running, + hidden, + p50: quantile(0.5), + p95: quantile(0.95), + max: sorted.length === 0 ? 0 : sorted[sorted.length - 1], + }; + } + + return { + start, + stop, + frameStats, + isRunning: () => running, + isHidden: () => hidden, + }; +} diff --git a/app/static/js/render/palette.js b/app/static/js/render/palette.js new file mode 100644 index 0000000..1292951 --- /dev/null +++ b/app/static/js/render/palette.js @@ -0,0 +1,52 @@ +// Undercooked palette (phase 07, task 01). +// +// The canvas half of app/static/assets/palette.css: the same hex values, +// re-exported for the 2D renderer, so a tint in the HUD and a tint on +// the floor can never disagree. tests/unit/test_assets.py asserts the two +// files agree in both directions; edit one and the build fails until the +// other follows. No bundler, no framework, no remote import (A10/A12). + +export const DEFAULT_PALETTE = Object.freeze({ + cook1: "#e0533d", + cook2: "#3d84a8", + cook3: "#7da84b", + cook4: "#b0699a", + danger: "#ff5a4e", + hot: "#ffa63d", + ready: "#67d16c", + dirty: "#9a8f7a", +}); + +// Okabe-Ito (colourblind-safe, deutan/protan) cook colours: the +// [data-palette="cb"] block of the CSS, the same semantics. +export const COLORBLIND_PALETTE = Object.freeze({ + ...DEFAULT_PALETTE, + cook1: "#d55e00", + cook2: "#0072b2", + cook3: "#009e73", + cook4: "#cc79a7", +}); + +/** The four seat colours, in seat order (index 0 is seat 1). */ +export const COOK_COLORS = Object.freeze([ + DEFAULT_PALETTE.cook1, + DEFAULT_PALETTE.cook2, + DEFAULT_PALETTE.cook3, + DEFAULT_PALETTE.cook4, +]); + +/** The seat colours under [data-palette="cb"], in seat order. */ +export const COOK_COLORS_COLORBLIND = Object.freeze([ + COLORBLIND_PALETTE.cook1, + COLORBLIND_PALETTE.cook2, + COLORBLIND_PALETTE.cook3, + COLORBLIND_PALETTE.cook4, +]); + +/** The semantic state colours shared by the HUD and the canvas. */ +export const SEMANTIC = Object.freeze({ + danger: DEFAULT_PALETTE.danger, + hot: DEFAULT_PALETTE.hot, + ready: DEFAULT_PALETTE.ready, + dirty: DEFAULT_PALETTE.dirty, +}); diff --git a/app/static/js/screens/connecting.js b/app/static/js/screens/connecting.js new file mode 100644 index 0000000..3c0d6b5 --- /dev/null +++ b/app/static/js/screens/connecting.js @@ -0,0 +1,32 @@ +// Undercooked — the connecting screen (phase 07 task 02). +// +// The brief in-between: a room is being created (or a code is being +// joined) and the `welcome` has not landed yet. *Cancel* is a clean +// close of the socket — the room, once created, lives on server-side +// (the 30 s seat grace / the GC decide its fate), so cancelling never +// deletes anything. + +import { el } from "./dom.js"; + +export function mount(target, data, app) { + const text = + data.what === "creating" + ? "Creating your kitchen…" + : `Connecting to ${data.code ?? "…"}…`; + target.append( + el("div", { class: "panel connecting" }, + el("h2", {}, "Connecting"), + el("p", { id: "connecting-text" }, text), + el("button", { + id: "cancel-connect", + class: "btn", + type: "button", + onClick: () => app.backToLanding(), + }, "Cancel"), + ), + ); +} + +export function unmount() { + /* static */ +} diff --git a/app/static/js/screens/countdown.js b/app/static/js/screens/countdown.js new file mode 100644 index 0000000..a9ed692 --- /dev/null +++ b/app/static/js/screens/countdown.js @@ -0,0 +1,38 @@ +// Undercooked — the countdown screen (phase 07 task 02). +// +// The 3-second `COUNTDOWN` in big numerals (one `countdown` frame per +// whole second: 3, 2, 1). The pulse is decorative: under +// `prefers-reduced-motion` the numeral just changes (the CSS in +// `screens.css` gates the animation), and a re-mount per numeral is the +// only motion machinery. + +import { el } from "./dom.js"; + +let numEl = null; + +export function mount(target, data, app) { + numEl = el("p", { id: "countdown-num", class: "countdown-num" }, String(data.n ?? 3)); + target.append( + el("div", { class: "panel countdown" }, + el("h2", {}, "Get ready"), + numEl, + ), + ); +} + +/** A `countdown` frame landed (same-screen re-show): the numeral + * replaces itself so the (motion-gated) pulse restarts. */ +export function update(data, _app) { + if (numEl === null) return; + numEl.textContent = String(data.n ?? ""); + const parent = numEl.parentElement; + if (parent !== null) { + const fresh = el("p", { id: "countdown-num", class: "countdown-num" }, numEl.textContent); + parent.replaceChildren(fresh); + numEl = fresh; + } +} + +export function unmount() { + numEl = null; +} diff --git a/app/static/js/screens/dom.js b/app/static/js/screens/dom.js new file mode 100644 index 0000000..b5f60dc --- /dev/null +++ b/app/static/js/screens/dom.js @@ -0,0 +1,72 @@ +// Undercooked — the screens' tiny DOM helper (phase 07 task 02). +// +// The screens are built with `document.createElement` and `textContent` +// only — never `innerHTML` with server data (names, messages and level +// ids arrive from the server and must not parse as HTML). This module +// is that rule made cheap: `el()` builds a subtree from a declarative +// description, and every string lands in a text node or an attribute. + +/** + * Build a DOM node. + * + * `el("button", { class: "btn", type: "button", "aria-label": "Copy" }, + * el("span", {}, "Copy"), "Copy")` + * + * Children may be nodes, strings (become text nodes) or null (skipped). + * Attribute values that are boolean are set/unset as flags; numbers are + * converted; `value`/`textContent` shortcuts are not needed — `text` in + * the attributes object sets `textContent` (and forbids children). + */ +export function el(tag, attrs = {}, ...children) { + const node = document.createElement(tag); + for (const [name, value] of Object.entries(attrs)) { + if (value === null || value === undefined || value === false) continue; + if (name === "text") { + node.textContent = String(value); + } else if (name === "value") { + node.value = value; + } else if (name === "dataset") { + for (const [key, item] of Object.entries(value)) { + node.dataset[key] = item; + } + } else if (name.startsWith("on") && typeof value === "function") { + // `onClick` → the DOM event `click` (listeners are case-sensitive). + node.addEventListener(name.slice(2).toLowerCase(), value); + } else if (value === true) { + node.setAttribute(name, ""); + } else { + node.setAttribute(name, String(value)); + } + } + if ("text" in attrs) return node; + for (const child of children) { + if (child === null || child === undefined) continue; + node.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return node; +} + +/** A `document.createDocumentFragment` of the given children. */ +export function frag(...children) { + const node = document.createDocumentFragment(); + for (const child of children) { + if (child === null || child === undefined) continue; + node.append(child instanceof Node ? child : document.createTextNode(String(child))); + } + return node; +} + +/** + * The 5-character code the user typed, the way the server normalises it + * (docs/PROTOCOL.md: Crockford base32 — the confusables `I L O U` are + * the server's to map; the client only uppercases and keeps what a code + * can contain). Returns "" when the input is not a full code. + */ +export function normalizeCode(value) { + return String(value ?? "") + .toUpperCase() + .replace(/[^0-9A-Z]/g, "") + .slice(0, 5); +} + +export const CODE_LENGTH = 5; diff --git a/app/static/js/screens/error.js b/app/static/js/screens/error.js new file mode 100644 index 0000000..d99b525 --- /dev/null +++ b/app/static/js/screens/error.js @@ -0,0 +1,87 @@ +// Undercooked — the error screen (phase 07 task 02). +// +// Friendly copy per failure (the `err` codes of docs/PROTOCOL.md and +// the close codes the connection classifies), with the *right* retry +// affordance for each: a `4400` protocol mismatch must not auto-retry +// (the client is out of date), a `4401` room-gone must not retry the +// dead code, a `4402` full room points at another room — and the room +// code is shown for support. + +import { el } from "./dom.js"; + +/** code → { title, detail, retry } (retry = "a retry is offered"). */ +const COPY = { + room_gone: { + title: "That kitchen is gone", + detail: "It may have closed, or the code was mistyped.", + retry: false, + }, + room_full: { + title: "That kitchen is full", + detail: "Every seat is taken. Start your own room, or try another code.", + retry: false, + }, + protocol_mismatch: { + title: "Protocol mismatch", + detail: + "This server speaks a protocol version this client does not. Retrying will not help — the client needs an update.", + retry: false, + }, + evicted: { + title: "You are on another tab", + detail: "A second tab took this seat. Use the other tab, or come back later.", + retry: false, + }, + internal: { + title: "The kitchen hit an internal error", + detail: "The round was aborted for everyone. Start a new room.", + retry: false, + }, + closed: { + title: "The connection closed", + detail: "The server closed the socket. You can try the same room again.", + retry: true, + }, + unreachable: { + title: "Cannot reach the server", + detail: "The socket could not be opened. Check the address and try again.", + retry: true, + }, +}; + +export function mount(target, data, app) { + const code = data.code ?? "closed"; + const copy = COPY[code] ?? COPY.closed; + const buttonRow = el( + "div", + { class: "error-actions" }, + el("button", { + id: "error-back", + class: "btn btn-primary", + type: "button", + onClick: () => app.backToLanding(), + }, "Back to landing"), + ); + if (copy.retry && app.roomCode !== "") { + buttonRow.prepend( + el("button", { + id: "error-retry", + class: "btn", + type: "button", + onClick: () => app.retryJoin(), + }, "Try again"), + ); + } + const children = [ + el("h2", { id: "error-title" }, copy.title), + el("p", { id: "error-detail" }, copy.detail), + ]; + if (app.roomCode !== "") { + children.push(el("p", { id: "error-code" }, `Room code: ${app.roomCode}`)); + } + target.append(el("div", { class: "panel error" }, el("div", { role: "alert" }, ...children), buttonRow)); +} + +export function unmount() { + /* static */ +} diff --git a/app/static/js/screens/landing.js b/app/static/js/screens/landing.js new file mode 100644 index 0000000..c06f705 --- /dev/null +++ b/app/static/js/screens/landing.js @@ -0,0 +1,84 @@ +// Undercooked — the landing screen (phase 07 task 02). +// +// The door of the app: *Create lobby* (a `POST /api/rooms`, the token in +// the body — the session is minted server-side and returned once) and the +// join form (code normalised to five uppercase characters, remembered in +// `sessionStorage` so a reload rejoins fast). Server refusals that land +// here — the process's `room_full` on create, an unreachable server — +// render in the status region, which stays on the screen (the app only +// leaves for a real room). + +import { el, frag, normalizeCode, CODE_LENGTH } from "./dom.js"; + +let statusEl = null; +let joinInput = null; +let bannerText = ""; + +export function mount(target, _data, app) { + bannerText = app.banner ?? ""; + statusEl = el( + "p", + { id: "status", class: "status", role: "status", "aria-live": "polite" }, + bannerText || "Pick a kitchen to start.", + ); + joinInput = el("input", { + id: "join-code", + name: "code", + type: "text", + inputmode: "text", + autocapitalize: "characters", + spellcheck: "false", + maxlength: String(CODE_LENGTH), + size: String(CODE_LENGTH), + placeholder: "ABCDE", + "aria-describedby": "join-hint", + required: true, + value: app.lastCode || "", + }); + const form = el("form", { id: "join-form", class: "panel", autocomplete: "off" }, + el("h2", { id: "lobby-heading" }, "Kitchen lobby"), + el("p", { id: "lobby-help", class: "tagline" }, + "Create a kitchen and read its 5-character code out loud, or join one that is already open. Nothing is saved and nothing is asked for."), + el("button", { + id: "create-lobby", + class: "btn btn-primary", + type: "button", + onClick: () => { + app.banner = null; + app.createLobby(); + }, + }, "Create lobby"), + el("label", { for: "join-code" }, "Room code"), + el("div", { class: "join-row" }, joinInput, el("button", { class: "btn", type: "submit" }, "Join")), + el("p", { id: "join-hint", class: "hint" }, + "Five characters, letters and digits, never ", + el("code", {}, "I"), ", ", + el("code", {}, "L"), ", ", + el("code", {}, "O"), " or ", + el("code", {}, "U"), "."), + statusEl, + ); + form.addEventListener("submit", (event) => { + event.preventDefault(); + const code = normalizeCode(joinInput.value); + joinInput.value = code; + if (code.length !== CODE_LENGTH) { + setBanner(`A room code is ${CODE_LENGTH} characters.`); + joinInput.focus(); + return; + } + app.banner = null; + app.join(code); + }); + target.append(frag(form)); +} + +export function unmount() { + /* no timers; the app's show() clears the root */ +} + +export function setBanner(message) { + if (statusEl === null) return; + bannerText = message; + statusEl.textContent = message || "Pick a kitchen to start."; +} diff --git a/app/static/js/screens/lobby.js b/app/static/js/screens/lobby.js new file mode 100644 index 0000000..4c63640 --- /dev/null +++ b/app/static/js/screens/lobby.js @@ -0,0 +1,292 @@ +// Undercooked — the lobby screen (phase 07 task 02). +// +// The room, as the server broadcasts it: the code with a copy button +// (the clipboard API, with a select-and-copy fallback), the seat list +// (index, name, colour chip, ready, the crown, disconnected), the +// *Ready* toggle, the host-only level picker (fed by `/api/levels` — +// difficulty pips, target, time, blurb in the option's tooltip), the +// *Start* button (enabled from `lobby.can_start`, disabled with a +// reason otherwise — the client's mirror of `Room.can_start`), the +// session panel (`/api/session`'s public projection) and a +// spectator notice. +// +// The DOM is built once on mount; every `lobby` re-broadcast (a join, +// a ready-up, a host change, a level pick) calls `update()`, which +// patches the dynamic parts in place — a re-mount would drop focus off +// the very control the player just touched. + +import { el, frag } from "./dom.js"; + +let built = false; +let seatsUl = null; +let readyBtn = null; +let readyLabel = null; +let levelSelect = null; +let levelLabel = null; +let startBtn = null; +let startReason = null; +let sessionPanel = null; +let specNote = null; +let bannerEl = null; +let codeEl = null; + +/** The level a `lobby` frame points at, with the `welcome`'s as fallback. */ +function currentLobby(app) { + if (app.lobby !== null) return app.lobby; + const welcome = app.welcome; + return { + code: app.roomCode, + state: "lobby", + level: welcome !== null ? welcome.level : null, + host_seat: null, + can_start: false, + seats: welcome !== null ? welcome.seats : [], + spectators: [], + unlocked: null, + round_no: 0, + }; +} + +/** + * The server's 4-colour seat palette (server/seats.py `SEAT_PALETTE`, + * docs/PROTOCOL.md "Lobby") as CSS classes — an inline `style` + * attribute would trip the CSP's `style-src 'self'` (a console error), + * so the colour rides on a class. An unknown colour (the palette ever + * grows) falls back to the neutral `.chip` skin. + */ +const CHIP_CLASS = { + "#e5484d": "chip-red", + "#3b9eff": "chip-blue", + "#3ddc84": "chip-green", + "#ffb224": "chip-amber", +}; + +function seatRow(seat, ownSeatIndex) { + const children = [ + el("span", { + class: `chip ${CHIP_CLASS[seat.color ?? ""] ?? ""}`.trim(), + "aria-hidden": "true", + }), + el("span", { class: "seat-index", "aria-hidden": "true" }, `#${seat.index + 1}`), + el("span", { class: "seat-name" }, seat.name), + ]; + if (seat.host) children.push(el("span", { class: "crown", "aria-label": "host" }, "♛")); + if (seat.ready) children.push(el("span", { class: "ready-badge" }, "ready")); + if (seat.connected === false) children.push(el("span", { class: "ghost-badge" }, "disconnected")); + const row = el("li", { class: "seat" }, frag(...children)); + if (seat.index === ownSeatIndex) row.dataset.you = ""; + return row; +} + +function renderSeats(app) { + const lobby = currentLobby(app); + const rows = lobby.seats.map((seat) => seatRow(seat, app.you !== null ? app.you.seat : null)); + seatsUl.replaceChildren(frag(...rows)); +} + +/** The client's mirror of `Room.can_start`'s refusal prose. */ +function startReasonFor(app) { + const lobby = currentLobby(app); + const ownHost = app.you !== null && app.you.host; + if (!ownHost) return "Only the host can start"; + if (lobby.level === null) return "Pick a level first"; + return lobby.can_start ? "" : "Waiting for ready cooks"; +} + +function renderControls(app) { + const lobby = currentLobby(app); + const own = app.you !== null ? lobby.seats.find((seat) => seat.index === app.you.seat) : undefined; + // The ready toggle is a toggle on the wire: the label shows the state. + readyLabel.textContent = own !== undefined && own.ready ? "Unready" : "Ready up"; + readyBtn.dataset.ready = own !== undefined && own.ready ? "true" : "false"; + // The picker: host-only; the selected level is the server's truth. + const ownHost = app.you !== null && app.you.host; + levelSelect.disabled = !ownHost; + levelLabel.textContent = ownHost ? "Level" : "Level (host only)"; + if (ownHost && lobby.level !== null) { + const options = levelSelect.querySelectorAll("option"); + const match = [...options].find((option) => option.value === lobby.level); + if (match !== undefined) levelSelect.value = match.value; + } + // Start: enabled from `can_start`, disabled with a reason otherwise. + const reason = startReasonFor(app); + startBtn.disabled = reason !== ""; + startReason.textContent = reason; + startReason.hidden = reason === ""; +} + +function renderLevelOptions(app) { + const levels = app.levels ?? []; + const loading = app.levels === null || app.levels === undefined; + levelSelect.replaceChildren( + frag( + el("option", { value: "" }, loading ? "Loading levels…" : "Pick a level"), + ...levels.map((level) => { + const pips = Math.ceil(level.difficulty / 2); + const pipText = "●".repeat(pips) + "○".repeat(5 - pips); + return el( + "option", + { value: level.id, title: level.blurb }, + `#${level.order} ${level.name} — ${level.target_score} pts / ${Math.round(level.time_limit)} s ${pipText}`, + ); + }), + ), + ); + const lobby = currentLobby(app); + if (lobby.level !== null) { + const match = [...levelSelect.querySelectorAll("option")].find((option) => option.value === lobby.level); + if (match !== undefined) levelSelect.value = match.value; + } +} + +function renderSession(app) { + const session = app.session; + if (session === null) { + sessionPanel.replaceChildren(el("p", {}, "Your session: loading…")); + return; + } + const bests = Object.entries(session.best ?? {}).map(([levelId, run]) => + el("li", {}, `${levelId} — ${run.score} pts, ${"★".repeat(run.stars)}${"☆".repeat(3 - run.stars)}`), + ); + sessionPanel.replaceChildren( + frag( + el("h3", {}, "Your session"), + el("p", {}, `Unlocked: level ${session.unlocked}`), + el("p", {}, `Rounds: ${session.rounds_played} · Dishes served: ${session.dishes_served}`), + el("p", {}, "Best:"), + bests.length === 0 + ? el("p", { class: "hint" }, "no best runs yet") + : el("ul", { class: "bests" }, frag(...bests)), + ), + ); +} + +function renderSpectators(app) { + const spectators = currentLobby(app).spectators; + specNote.hidden = spectators.length === 0; + specNote.textContent = + spectators.length === 0 ? "" : `Watching: ${spectators.map((spec) => spec.name).join(", ")}`; +} + +export function mount(target, _data, app) { + const copyButton = el("button", { + id: "copy-code", + class: "btn", + type: "button", + "aria-label": "Copy the room code", + }, "Copy code"); + copyButton.addEventListener("click", () => void copyCode(app, copyButton)); + + codeEl = el("p", { id: "room-code", class: "room-code" }, app.roomCode || "…"); + seatsUl = el("ul", { id: "seat-list", class: "seat-list" }); + readyBtn = el("button", { + id: "ready-btn", + class: "btn", + type: "button", + onClick: () => app.ready(), + }); + readyLabel = el("span", { class: "ready-label" }, "Ready"); + readyBtn.append(readyLabel); + levelSelect = el("select", { + id: "level-picker", + "aria-labelledby": "level-label", + }, el("option", { value: "" }, "Loading levels…")); + levelSelect.addEventListener("change", () => { + if (levelSelect.value !== "") app.setLevel(levelSelect.value); + }); + levelLabel = el("label", { id: "level-label", for: "level-picker" }, "Level"); + startBtn = el("button", { + id: "start-btn", + class: "btn btn-primary", + type: "button", + disabled: true, + onClick: () => app.start(), + }, "Start"); + startReason = el("p", { id: "start-reason", class: "hint", role: "status" }, ""); + sessionPanel = el("div", { id: "session-panel", class: "panel session" }); + specNote = el("p", { id: "spectator-note", class: "hint", hidden: true }); + bannerEl = el("p", { id: "lobby-banner", class: "status", role: "status", "aria-live": "polite", hidden: true }); + + target.append( + el("div", { class: "panel lobby" }, + el("div", { class: "lobby-head" }, + el("h2", {}, "Kitchen lobby"), + el("div", { class: "code-row" }, codeEl, copyButton), + ), + el("h3", { class: "section-label" }, "Cooks"), + seatsUl, + el("div", { class: "lobby-controls" }, readyBtn), + el("div", { class: "level-pick" }, levelLabel, levelSelect), + el("div", { class: "lobby-controls" }, startBtn, startReason), + specNote, + sessionPanel, + bannerEl, + ), + ); + built = true; + renderAll(app); + void app.fetchLevels().then(() => { + if (built) { + renderLevelOptions(app); + renderControls(app); + } + }); + void app.fetchSession().then(() => { + if (built) renderSession(app); + }); +} + +function renderAll(app) { + renderSeats(app); + renderLevelOptions(app); + renderControls(app); + renderSession(app); + renderSpectators(app); +} + +/** The app's same-screen re-show: a `lobby` re-broadcast while mounted + * (the reducer keeps `app.lobby` fresh before calling this). */ +export function update(_data, app) { + if (!built || app === undefined) return; + codeEl.textContent = app.roomCode || "…"; + renderAll(app); +} + +export function unmount() { + built = false; + /* the app's show() clears the root */ +} + +export function setBanner(message) { + if (bannerEl === null) return; + bannerEl.textContent = message ?? ""; + bannerEl.hidden = message === null || message === ""; +} + +async function copyCode(app, button) { + const code = app.roomCode; + let copied = false; + try { + await navigator.clipboard.writeText(code); + copied = true; + } catch { + // The clipboard API needs a secure context and a gesture fallback: + // select a throwaway input and copy from it. The hidden class, not + // an inline `style` (the CSP's `style-src 'self'` blocks the + // latter and the console would log it). + const input = document.createElement("input"); + input.value = code; + input.setAttribute("readonly", ""); + input.classList.add("visually-hidden"); + document.body.append(input); + input.select(); + try { + copied = document.execCommand("copy"); + } catch { + copied = false; + } + input.remove(); + } + setBanner(copied ? "Code copied — read it out loud." : `Copy failed — the code is ${code}.`); + button.setAttribute("data-copied", copied ? "true" : "false"); +} diff --git a/app/static/js/screens/play.js b/app/static/js/screens/play.js new file mode 100644 index 0000000..bec008f --- /dev/null +++ b/app/static/js/screens/play.js @@ -0,0 +1,94 @@ +// Undercooked — the play screen (phase 07 task 02). +// +// The kitchen's frame: the canvas host (`#kcanvas` — hidden until the +// task-04 renderer sizes and unhides it), the HUD root (`#hud`, filled +// by task 04), the *Leave* control, the reconnecting overlay (its +// *logic* lands in phase 08; this task shows the sign), the emote row +// (the `docs/PROTOCOL.md` whitelist: `wave`, `cheer`, `thumbs_up`, +// `thumbs_down`, `angry`, `star`), the spectator notice (a join during +// `COUNTDOWN`/`PLAYING`/`ROUND_END` watches, per the wire contract) and +// the banner region (non-fatal `err` codes, e.g. a `start` refusal). + +import { el, frag } from "./dom.js"; + +/** The emote whitelist (docs/PROTOCOL.md "EmoteMsg"): id → label. */ +const EMOTES = [ + ["wave", "Wave", "👋"], + ["cheer", "Cheer", "🎉"], + ["thumbs_up", "Thumbs up", "👍"], + ["thumbs_down", "Thumbs down", "👎"], + ["angry", "Angry", "😠"], + ["star", "Star", "⭐"], +]; + +let overlay = null; +let bannerEl = null; + +export function mount(target, _data, app) { + overlay = el( + "div", + { id: "reconnect-overlay", class: "overlay", hidden: true, role: "status" }, + el("p", {}, "Connection lost — reconnecting… (phase 08)"), + ); + bannerEl = el("p", { + id: "play-banner", + class: "status", + role: "status", + "aria-live": "polite", + hidden: true, + }); + target.append( + el("div", { class: "play-screen" }, + el("div", { class: "play-head" }, + el("p", { + id: "spectator-note", + class: "hint", + hidden: !app.spectator, + }, "You are watching — the room is full."), + el("button", { + id: "leave-btn", + class: "btn", + type: "button", + onClick: () => app.leave(), + }, "Leave"), + ), + bannerEl, + el("canvas", { id: "kcanvas", hidden: true, "aria-hidden": "true" }), + el("div", { id: "hud", class: "hud" }), + el("div", { class: "emote-row", role: "group", "aria-label": "Emotes" }, + frag( + ...EMOTES.map(([id, label, glyph]) => + el( + "button", + { + class: "btn emote", + type: "button", + "aria-label": label, + "data-emote": id, + onClick: () => app.emote(id), + }, + el("span", { "aria-hidden": "true" }, glyph), + ), + ), + ), + ), + overlay, + ), + ); +} + +/** The reconnecting overlay (populated in phase 08). */ +export function showReconnecting(visible) { + if (overlay !== null) overlay.hidden = !visible; +} + +export function setBanner(message) { + if (bannerEl === null) return; + bannerEl.textContent = message ?? ""; + bannerEl.hidden = message === null || message === ""; +} + +export function unmount() { + overlay = null; + bannerEl = null; +} diff --git a/app/static/js/screens/round_end.js b/app/static/js/screens/round_end.js new file mode 100644 index 0000000..cf8e3be --- /dev/null +++ b/app/static/js/screens/round_end.js @@ -0,0 +1,143 @@ +// Undercooked — the result screen (phase 07 task 02). +// +// The verdict, as the `round` frame carries it: the result word, the +// stars (1–3, the pop is decorative and `prefers-reduced-motion`-gated), +// score against target, the session's best for the level that was +// played, the per-player contribution list (display only — PLAN §5: +// never competitive gatekeeping), the counters (`served` / `missed` / +// `wasted`, the longest streak), and the three exits — *Next level* +// (only when `round.unlocked` names a level), *Retry* (`next_level`) +// and *Back to lobby* (`to_lobby`). The outcome is announced to screen +// readers through an `aria-live="polite"` region. + +import { el, frag } from "./dom.js"; + +let bestEl = null; +let bannerEl = null; + +function starsFor(stars) { + const count = Math.max(0, Math.min(3, stars)); + const spans = []; + for (let i = 0; i < 3; i += 1) { + spans.push( + el("span", { class: i < count ? "star filled" : "star", "aria-hidden": "true" }, + i < count ? "★" : "☆"), + ); + } + return el("p", { + id: "stars", + class: "stars", + "aria-label": `${count} of 3 stars`, + }, frag(...spans)); +} + +function resultWord(result) { + if (result === "won") return "Won"; + if (result === "lost") return "Lost"; + if (result === "aborted") return "Aborted"; + return "Round over"; +} + +function announcement(result, score, target, stars) { + const word = result === "won" ? "won" : result === "lost" ? "lost" : "ended"; + return `You ${word} ${score} of ${target} points with ${stars} star${stars === 1 ? "" : "s"}.`; +} + +export function mount(target, _data, app) { + const round = app.round ?? {}; + const result = round.result ?? null; + const stars = round.stars ?? 0; + const score = round.score ?? 0; + const targetScore = round.target ?? 0; + + const players = (round.players ?? []).map((player) => + el("li", { class: "result-player" }, + el("span", { class: "seat-name" }, player.name), + el("span", {}, `${player.score} pts · ${player.dishes_served} dish${player.dishes_served === 1 ? "" : "es"}`), + ), + ); + const counters = round.counters ?? {}; + bestEl = el("p", { id: "best-line" }, "Best: loading…"); + bannerEl = el("p", { class: "hint", id: "result-banner", hidden: true, role: "status", "aria-live": "polite" }); + + const actions = el("div", { class: "result-actions" }, + el("button", { + id: "retry-btn", + class: "btn", + type: "button", + onClick: () => app.nextLevel(), + }, "Retry"), + el("button", { + id: "to-lobby-btn", + class: "btn btn-primary", + type: "button", + onClick: () => app.toLobby(), + }, "Back to lobby"), + ); + if (round.unlocked !== null && round.unlocked !== undefined) { + actions.prepend( + el("button", { + id: "next-level-btn", + class: "btn btn-primary", + type: "button", + onClick: () => app.nextLevel(), + }, "Next level"), + ); + } + + target.append( + el("div", { class: "panel result" }, + el("h2", { id: "result-word" }, resultWord(result)), + starsFor(stars), + el("p", { id: "score-line" }, `Score ${score} of ${targetScore}`), + bestEl, + el("h3", { class: "section-label" }, "Cooks"), + players.length === 0 + ? el("p", { class: "hint" }, "no players") + : el("ul", { id: "result-players", class: "seat-list" }, frag(...players)), + el("p", { id: "counters-line" }, + `Served ${counters.served ?? 0} · Missed ${counters.missed ?? 0} · Wasted ${counters.wasted ?? 0} · Longest streak ${round.longest_streak ?? 0}`), + actions, + bannerEl, + ), + el("p", { + id: "result-announce", + class: "visually-hidden", + role: "status", + "aria-live": "polite", + }, announcement(result, score, targetScore, stars)), + ); + // The best for the level that was played (the session update lands + // with the `round` frame — `round_over` records before it broadcasts). + void app.fetchSession().then(() => { + if (bestEl !== null) renderBest(app); + }); +} + +export function renderBest(app) { + if (bestEl === null) return; + const round = app.round ?? {}; + const session = app.session; + const best = session !== null && round.level_id !== undefined ? session.best?.[round.level_id] : undefined; + bestEl.textContent = + best !== undefined + ? `Best: ${best.score} pts, ${"★".repeat(best.stars)}${"☆".repeat(3 - best.stars)}` + : "Best: —"; +} + +/** Same-screen re-show: re-render the best line (a `round` re-broadcast + * or a session refresh). */ +export function update(_data, app) { + if (app !== undefined) renderBest(app); +} + +export function setBanner(message) { + if (bannerEl === null) return; + bannerEl.textContent = message ?? ""; + bannerEl.hidden = message === null || message === ""; +} + +export function unmount() { + bestEl = null; + bannerEl = null; +} diff --git a/app/static/js/shared/enums.js b/app/static/js/shared/enums.js new file mode 100644 index 0000000..aceefed --- /dev/null +++ b/app/static/js/shared/enums.js @@ -0,0 +1,137 @@ +// Undercooked — the wire enums (phase 07 task 03). +// +// The client's vocabulary for the sim's StrEnums (src/undercooked/sim/model.py) +// and the protocol's `ErrorCode` (src/undercooked/server/protocol.py). The +// wire carries the *string values* (a StrEnum's `.value`); these frozen sets +// are what `net/decode.js` maps against and what a frame's values are +// checked against. +// +// Drift is a build failure, not a surprise: `tests/unit/test_client_enum_parity.py` +// reads this file and asserts each set is set-equal to the Python enum, in +// both directions (the same guard pattern as `test_protocol_doc_sync.py`). +// Add a value to the sim and forget it here (or vice versa) and the gate +// fails, naming the missing value. + +/** The terrain under a grid cell (`TileKind`). */ +export const TILE_KIND = Object.freeze([ + "floor", + "counter", + "wall", + "void", +]); + +/** The kitchen fixture types (`StationKind`). */ +export const STATION_KIND = Object.freeze([ + "spawn_shelf", + "chop_board", + "stove", + "prep", + "serve", + "sink", + "trash", +]); + +/** Ingredient / dish types (`ItemKind`). */ +export const ITEM_KIND = Object.freeze([ + "tomato", + "onion", + "lettuce", + "dough", + "patty", + "rice", + "fish", + "plate", +]); + +/** The preparation stage of an item (`ItemForm`). */ +export const ITEM_FORM = Object.freeze([ + "raw", + "chopped", + "cooking", + "cooked", + "burnt", + "plated", + "dirty", +]); + +/** The four directions; the wire's `f` is an index into this list (PLAN §4: 0-3). */ +export const FACING = Object.freeze(["up", "right", "down", "left"]); + +/** The transient FX/log event kinds (`EventKind`; `encode_events`' `k`). */ +export const EVENT_KIND = Object.freeze([ + "chopped", + "cooked", + "ready", + "served", + "burnt", + "order_missed", + "fire_started", + "denied", + "score", + "stunned", + "washed", + "trashed", + "round_end", + "wasted", + "picked", + "dropped", + "handed_off", + "trashed_already_empty", + "tick_slow", + "err", + "resync", + "ghost_on", + "ghost_off", + "ghost_park", +]); + +/** The stable rejection codes (`ErrorCode`, docs/PROTOCOL.md "Error codes"). */ +export const ERROR_CODE = Object.freeze([ + "unknown_type", + "bad_json", + "bad_field", + "too_big", + "rate_limited", + "not_seated", + "not_host", + "level_locked", + "wrong_state", + "room_full", + "room_gone", + "protocol_mismatch", + "evicted", + "internal", +]); + +// The extras the renderer (task 04) needs. Not in the parity gate's +// seven, but the same discipline applies: the wire's short strings. + +/** What a cook is doing this tick (`PlayerState`; the wire's `s`). */ +export const PLAYER_STATE = Object.freeze([ + "idle", + "moving", + "using", + "stunned", + "disconnected", +]); + +/** A station's lifecycle (`StationState`; the wire's `s`). */ +export const STATION_STATE = Object.freeze([ + "idle", + "working", + "ready", + "busy", + "dirty", + "blocked", +]); + +/** The hazard families (`HazardKind`; the wire's `t`). */ +export const HAZARD_KIND = Object.freeze([ + "fire", + "spill", + "moving_obstacle", + "rush", +]); + +/** The terminal outcome of a round (`RoundResult`). */ +export const ROUND_RESULT = Object.freeze(["won", "lost", "aborted"]); diff --git a/app/static/js/ui/help.js b/app/static/js/ui/help.js new file mode 100644 index 0000000..ba78667 --- /dev/null +++ b/app/static/js/ui/help.js @@ -0,0 +1,118 @@ +// Undercooked — the help overlay (phase 07, task 05; the task's +// `app/static/ui/help.js`, served from `js/ui/` like the HUD). +// +// The `?`-key in-game help: a body-level, fixed, dismissible panel +// listing the key map. Created once at boot, persists across screens +// (it is not a screen — the state machine never knows it), hidden by +// default. `?` toggles it; `Escape` closes it (the keyboard module's +// `Esc` branch defers to it while it is open); a click on the panel's +// backdrop or its Close button dismisses it. No external docs needed +// mid-game. +// +// The rows are `getKeyMap()`'s — `input/keyboard.js` re-exports it +// (the canonical source) and the README's controls section renders the +// same rows (a test keeps them identical: the browser-rendered map +// against the README's block). + +import { el } from "../screens/dom.js"; + +/** + * The key map the help panel (and the README's controls section — a + * test keeps them identical) renders: `{ keys, action }` rows in + * display order. `keys` are joined with " / " when rendered. + */ +export function getKeyMap() { + return [ + { keys: ["W", "A", "S", "D"], action: "move (or the arrow keys)" }, + { keys: ["Space", "Enter"], action: "action (pick, work, set down, serve)" }, + { keys: ["1", "2", "3", "4"], action: "emote (wave, cheer, thumbs up, thumbs down)" }, + { keys: ["?"], action: "help overlay" }, + { keys: ["Esc"], action: "close help / leave the room" }, + ]; +} + +/** + * The help overlay itself. + * + * `onToggle` — optional `(visible) => void` (the census seam; the + * keyboard module's `?`/`Esc` branches need no hook — they call + * `toggle()` / `hide()` directly). + * + * Returns `{ show, hide, toggle, visible }`. + */ +export function createHelpOverlay({ onToggle = null } = {}) { + let panel = null; + let visible = false; + + function build() { + if (panel !== null) return panel; + const title = el( + "div", + { class: "help-title" }, + el("span", { class: "help-heading", text: "Controls" }), + el("button", { + class: "btn help-close", + type: "button", + "aria-label": "Close help", + text: "Close", + onClick: () => hide(), + }) + ); + const rows = getKeyMap().map((row) => + el( + "div", + { class: "help-row" }, + el("kbd", { class: "help-keys", text: row.keys.join(" / ") }), + el("span", { class: "help-action", text: row.action }) + ) + ); + panel = el( + "div", + { + id: "help-overlay", + class: "help-overlay", + role: "dialog", + "aria-label": "Help", + "aria-hidden": "true", + }, + el( + "div", + { class: "panel help-panel", onClick: (e) => e.stopPropagation() }, + title, + el("div", { class: "help-rows" }, ...rows), + el("p", { + class: "help-foot", + text: "Press ? or Esc to close. On the play screen, Esc leaves the room.", + }) + ) + ); + panel.addEventListener("click", () => hide()); + return panel; + } + + function show() { + const node = build(); + if (visible) return; + node.setAttribute("aria-hidden", "false"); + document.body.append(node); + visible = true; + if (onToggle !== null) onToggle(true); + } + + function hide() { + if (!visible) return; + visible = false; + if (panel !== null) { + panel.setAttribute("aria-hidden", "true"); + panel.remove(); + } + if (onToggle !== null) onToggle(false); + } + + function toggle() { + if (visible) hide(); + else show(); + } + + return { show, hide, toggle, visible: () => visible }; +} diff --git a/app/static/js/ui/hud.js b/app/static/js/ui/hud.js new file mode 100644 index 0000000..c7094e2 --- /dev/null +++ b/app/static/js/ui/hud.js @@ -0,0 +1,273 @@ +// Undercooked — the HUD (phase 07, task 04). +// +// The game's readouts, all DOM (not canvas) so they stay crisp and +// readable by assistive tech: the order rail (one ticket per order — +// the recipe icon, the recipe name, the deadline's time bar, the +// points; the *oldest* ticket — the rail's head — highlighted), the +// score + target bar, the countdown timer (amber below 20 s, red below +// 10 s, each with a **non-colour cue**: the label's text — +// `hurry` / `now` — and the striped bar pattern, so the state reads +// without colour), the streak indicator (the flame, from the +// `score`/`order_missed` events — the snapshot carries no streak +// field), the dirty-dish counter (the `dirty` stations of the latest +// frame), the connection indicator (the socket's status) and the +// spectator banner. +// +// Updates are **coalesced to one write per animation frame**: the +// single render loop (`render/loop.js`) calls `update()` once per +// frame, and `update` diff-writes — a node's `textContent` / class is +// touched only when the value actually changed, so a 60 fps frame that +// changes nothing writes nothing. The HUD owns no `requestAnimationFrame` +// of its own (the single-owner rule) and no event listeners: it is a +// pure function of the frame + the app's live state. +// +// The recipe icon is a `` on the atlas's decoded `data:` URL +// (`atlas.dataUrl("ui-ticket")` — the CSP allows `img-src data:`; the +// bytes are the app's own, the same decode the canvas uses; a +// `#symbol` fragment never renders from a cross-file ``). The +// streak flame rides the same path (`ui-streak`). + +import * as atlas from "../render/atlas.js"; +import { el } from "../screens/dom.js"; + +/** The timer's colour thresholds (seconds; the task's bands). */ +export const TIMER_LOW_S = 20; +export const TIMER_CRITICAL_S = 10; + +/** + * The timer's visual state for a time-left (seconds): `ok` / `low` + * (amber, below 20 s) / `critical` (red, below 10 s) — with the + * non-colour half: `label` (the text cue: `""` / `hurry` / `now`). + */ +export function timerState(timeLeftS) { + if (timeLeftS < TIMER_CRITICAL_S) { + return { state: "critical", label: "now" }; + } + if (timeLeftS < TIMER_LOW_S) { + return { state: "low", label: "hurry" }; + } + return { state: "ok", label: "" }; +} + +/** The socket's status → the indicator's word (the e2e asserts on it). */ +export function connectionWord(status) { + if (status === "open") return "Live"; + if (status === "connecting") return "Connecting…"; + if (status === "mismatch") return "Out of date"; + return "Dropped"; +} + +/** + * One HUD. `update(view, latest, app, eventsDue, nowMs)` — the + * `lerpState` output (the score / target / timer / players), the + * buffer's latest decoded frame (the orders — the rail's source; the + * latest frame, not the lerp: an order's `l` lerps but its identity is + * discrete), the app (the connection status, the spectator flag) and + * the event queue's live entries (the streak's events). Diff-writes + * only. + */ +export function createHud({ root, app } = {}) { + // ----------------------------------------------------------- the DOM + const orderList = el("div", { class: "ticket-list" }); + const ticketIcon = el("img", { class: "ticket-icon", alt: "", "aria-hidden": "true" }); + const ticketIconSet = { done: false }; + const scoreEl = el("b", { id: "hud-score" }, "0"); + const targetEl = el("span", { id: "hud-score-target" }, "/—"); + const scoreBarFill = el("div", { class: "score-bar-fill" }); + const streakEl = el("span", { + id: "hud-streak", + class: "hud-streak", + hidden: true, + "aria-label": "streak", + }); + const streakIcon = el("img", { alt: "", "aria-hidden": "true" }); + const streakNum = el("b", {}, "×2"); + streakEl.append(streakIcon, streakNum); + const timerEl = el("b", { id: "hud-timer" }, "—"); + const timerSection = el("section", { + class: "hud-timer", + "aria-label": "Time left", + }); + const timerLabel = el("span", { id: "hud-timer-label", class: "hud-timer-label" }); + timerSection.append(timerEl, timerLabel); + const dirtyEl = el("span", { id: "hud-dirty", class: "hud-dirty" }, "Dirty 0"); + const connEl = el("span", { id: "hud-conn", class: "hud-conn", role: "status" }, "—"); + const spectatorEl = el( + "p", + { id: "hud-spectator", class: "status", hidden: true }, + "You are watching — the room is full.", + ); + + root.append( + el("div", { class: "hud-grid" }, + el("section", { class: "hud-orders", "aria-label": "Orders" }, + el("p", { class: "section-label" }, "Orders"), + orderList, + ), + el("section", { class: "hud-scorebox", "aria-label": "Score" }, + el("p", { class: "hud-score-line" }, + "Score ", + scoreEl, + " ", + targetEl, + " · ", + streakEl, + ), + el("div", { class: "score-bar" }, scoreBarFill), + ), + timerSection, + el("section", { class: "hud-meta", "aria-label": "Status" }, + dirtyEl, + connEl, + spectatorEl, + ), + ), + ); + + // ------------------------------------------------- the diff-write half + const tickets = new Map(); // order id -> the
+ const lastStreak = { value: 0 }; + const seenEvents = new Map(); // the streak events' dedup keys (the 1.5 s window: a kind's TTL + margin) + const EVENT_DEDUP_MS = 1500; + + function setText(node, value) { + const text = String(value); + if (node.textContent !== text) node.textContent = text; + } + + function ensureIcon(img, id) { + if (ticketIconSet.done && img.dataset.loaded === "1") return; + const url = atlas.dataUrl(id); + if (url !== null) { + if (img.src !== url) img.src = url; + img.dataset.loaded = "1"; + if (id === "ui-ticket") ticketIconSet.done = true; + } + } + + function update(view, latest, app, eventsDue, nowMs) { + const frame = latest !== null && latest !== undefined ? latest : view; + + // The streak (from the events — the snapshot carries no streak + // field): a `score` event's `stk` is the streak after the serve, + // an `order_missed` resets it. The 1.5 s window suppresses the + // same queued entry's repeat frames (a `score` lives 1200 ms). + const now = typeof nowMs === "number" ? nowMs : 0; + for (const entry of Array.isArray(eventsDue) ? eventsDue : []) { + if (entry.k !== "score" && entry.k !== "order_missed") continue; + const payload = + entry.payload !== null && entry.payload !== undefined ? entry.payload : {}; + const key = `${entry.k}|${payload.stk ?? ""}|${payload.d ?? ""}`; + const last = seenEvents.get(key); + if (last !== undefined && now - last < EVENT_DEDUP_MS) continue; + seenEvents.set(key, now); + if (entry.k === "score" && Number.isFinite(payload.stk)) { + lastStreak.value = Math.max(0, Math.trunc(payload.stk)); + } else if (entry.k === "order_missed") { + lastStreak.value = 0; + } + } + for (const [key, at] of [...seenEvents]) { + if (now - at >= EVENT_DEDUP_MS) seenEvents.delete(key); + } + + // Score + target bar. + const score = Number.isFinite(view.s) ? Math.trunc(view.s) : 0; + const target = Number.isFinite(view.tgt) ? Math.trunc(view.tgt) : 0; + setText(scoreEl, score); + setText(targetEl, target > 0 ? `/${target}` : "/—"); + const fraction = target > 0 ? Math.min(1, score / target) : 0; + const barWidth = `${Math.round(fraction * 100)}%`; + if (scoreBarFill.style.width !== barWidth) scoreBarFill.style.width = barWidth; + + // The streak (the flame + the ×N, visible from a 2). + const streak = lastStreak.value; + const visible = streak >= 2; + if (Boolean(streakEl.hidden) === visible) { + streakEl.hidden = !visible; + if (visible) { + setText(streakNum, `×${streak}`); + streakEl.setAttribute("aria-label", `streak ${streak}`); + ensureIcon(streakIcon, "ui-streak"); + } + } + + // The timer (the interpolated `tl`, seconds; the colour bands + + // the non-colour label). + const tl = Number.isFinite(view.tl) ? view.tl : 0; + const shown = Math.max(0, Math.ceil(tl)); + setText(timerEl, shown); + const { state, label } = timerState(tl); + const classes = timerSection.className; + const wanted = + state === "ok" ? "hud-timer" : `hud-timer hud-timer-${state}`; + if (classes !== wanted) timerSection.className = wanted; + setText(timerLabel, label); + + // The dirty-dish counter (the `dirty` stations of the frame). + let dirty = 0; + for (const st of frame.st ?? []) { + if (st.s === "dirty") dirty += 1; + } + setText(dirtyEl, `Dirty ${dirty}`); + + // The connection indicator. + setText(connEl, connectionWord(app !== undefined ? app.connection.status : "closed")); + const connClass = + app !== undefined && app.connection.status === "open" + ? "hud-conn is-live" + : "hud-conn is-down"; + if (connEl.className !== connClass) connEl.className = connClass; + + // The spectator banner. + const spectating = Boolean(app !== undefined && app.spectator); + if (Boolean(spectatorEl.hidden) === spectating) spectatorEl.hidden = !spectating; + + // The order rail: reconcile by id (the `o[]` of the frame), the + // head (index 0 — the oldest: the rail is FIFO) highlighted. + const orders = (frame.o ?? []).filter( + (o) => o !== null && typeof o === "object", + ); + const ids = new Set(); + orders.forEach((o, idx) => { + ids.add(o.id); + let ticket = tickets.get(o.id); + if (ticket === undefined) { + ticket = el("article", { class: "ticket", "data-order-id": String(o.id) }); + const icon = el("img", { class: "ticket-icon", alt: "", "aria-hidden": "true" }); + const name = el("span", { class: "ticket-recipe" }); + const pts = el("span", { class: "ticket-pts" }); + const bar = el("div", { class: "ticket-bar" }, el("div", { class: "ticket-bar-fill" })); + ticket.append(icon, name, pts, bar); + orderList.append(ticket); + tickets.set(o.id, ticket); + } + ensureIcon(ticket.querySelector("img"), "ui-ticket"); + setText(ticket.querySelector(".ticket-recipe"), o.r ?? "dish"); + setText(ticket.querySelector(".ticket-pts"), Number.isFinite(o.pts) ? `${Math.trunc(o.pts)} pts` : ""); + const fill = ticket.querySelector(".ticket-bar-fill"); + const share = Number.isFinite(o.l) ? Math.max(0, Math.min(1, o.l)) : 0; + const width = `${Math.round(share * 100)}%`; + if (fill.style.width !== width) fill.style.width = width; + const isOldest = idx === 0; + ticket.classList.toggle("oldest", isOldest); + }); + for (const [id, ticket] of [...tickets]) { + if (!ids.has(id)) { + ticket.remove(); + tickets.delete(id); + } + } + } + + /** Clear the built DOM (the screen machine clears the root on the + * screen change; this is the module's half, for a tidy unmount). */ + function unmount() { + tickets.clear(); + seenEvents.clear(); + lastStreak.value = 0; + root.replaceChildren(); + } + + return { update, unmount, timerEl, scoreEl, orderList }; +} diff --git a/assets_src/cooks.svg b/assets_src/cooks.svg new file mode 100644 index 0000000..4227619 --- /dev/null +++ b/assets_src/cooks.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets_src/fx.svg b/assets_src/fx.svg new file mode 100644 index 0000000..aef8038 --- /dev/null +++ b/assets_src/fx.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets_src/items.svg b/assets_src/items.svg new file mode 100644 index 0000000..64899c1 --- /dev/null +++ b/assets_src/items.svg @@ -0,0 +1,202 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets_src/stations.svg b/assets_src/stations.svg new file mode 100644 index 0000000..f958357 --- /dev/null +++ b/assets_src/stations.svg @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets_src/tiles.svg b/assets_src/tiles.svg new file mode 100644 index 0000000..ff4c8ec --- /dev/null +++ b/assets_src/tiles.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets_src/ui.svg b/assets_src/ui.svg new file mode 100644 index 0000000..4c8f054 --- /dev/null +++ b/assets_src/ui.svg @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/CLIENT.md b/docs/CLIENT.md new file mode 100644 index 0000000..8363338 --- /dev/null +++ b/docs/CLIENT.md @@ -0,0 +1,516 @@ +# CLIENT.md — the browser half + +## Purpose + +The browser half, in prose: what `app/static/` is, how one state machine +owns the screens, how the wire contract (`docs/PROTOCOL.md`) maps onto +them, and the rules the client code lives under. The code of record is +`app/static/js/` (served from `/static/`, same-origin only, PLAN A12); +`tests/e2e/` is the proof — every claim in this file is asserted by a +browser test, not by eye. + +**No bundler, no framework, no build step (PLAN A10).** Every file in +`app/static/js/` is a plain ES module served as-is: `index.html` loads +one entry module (`/static/js/main.js` — the composition root, task 03; +it imports `app.js` and the net/render modules), the rest of the graph +is plain `import` statements the browser resolves. There is no +`package.json`, no `node`, no transpile, no vendor directory, and no +CDN (PLAN A12 — +`tests/unit/test_no_external_assets.py` fails the build on a remote +reference anywhere in `app/static/**`, and the e2e suites re-assert the +same in a real browser: a full round makes requests to *nothing* but the +page's own origin). Consequences the code obeys: + +- no `import` from a bare specifier (`./` and `/static/` only); +- no dynamic import except the atlas's own same-origin `fetch`; +- no TypeScript, no JSX, no `eval`, no dynamic string assembly of code; +- every server-supplied string lands in `textContent` or an attribute — + `innerHTML` with server data is never (the screens build through + `screens/dom.js`'s `el()`/`frag()`, which make that the only path); +- inline `style` attributes are never set either (the CSP is + `style-src 'self'` — a blocked inline style is a console error); + colours ride on CSS classes. + +## The module map + +``` +app/static/ +├── index.html one page, one entry module, #screen-root +├── css/ +│ ├── base.css the shared skin (palette, type scale, 44 px hit targets) +│ └── screens.css the screens' layout (360 px → 4K, focus rings, reduced motion) +├── assets/ the generated SVG atlas + palette.css + index.json (task 01) +└── js/ + ├── main.js the composition root (task 03): URL params, the buffer, + │ the event queue, the single loop, the wire routing + ├── main-flag.js sets `__uc_main` before `app.js` evaluates (the auto-boot guard) + ├── app.js createApp(): the state machine, the wire reducer, the verbs + ├── identity.js the per-tab token + the last room code (sessionStorage, A16) + ├── shared/ + │ └── enums.js the wire's enum sets (the sim's StrEnums + `ErrorCode`; parity-guarded) + ├── net/ + │ ├── connection.js the WebSocket: hello, send, onMessage/onStatus, close-code table + │ ├── decode.js `applyDelta` — the client's mirror of `server/encoding.py` + │ ├── snapshots.js the snapshot buffer, `lerpState`, the resync rules (pure, injected clock) + │ └── events.js the event queue: per-kind TTL, the dedup window, the tick-reset + ├── input/ + │ └── keyboard.js the desktop input (task 05): the key map, the send pump, `?controls=` + ├── render/ + │ ├── loop.js the single rAF loop (pauses on `document.hidden`, self-measured) + │ ├── atlas.js the SVG atlas loader (ready/drawSync/meta; throws on an unknown id) + │ ├── palette.js the palette's hex values (agrees with assets/palette.css, tested) + │ ├── camera.js fit-to-viewport (DPR-aware, capped at 2), the world⇄screen math + │ ├── canvas.js the layer renderer: `drawFrame`, the sprite mapping, `renderStats`, + │ │ the target highlight (task 04) + │ └── fx.js event→effect: the no-alloc particle pool, the reduced-motion fallbacks + ├── ui/ + │ ├── help.js the help overlay (task 05): the key-map rows, `?`/`Esc`, no backdrop blur + │ └── hud.js the DOM HUD: order rail, score/target, timer, streak, connection + └── screens/ one module per screen: { mount, unmount } (+ update/setBanner…) + ├── dom.js el()/frag() — the no-innerHTML DOM builder + ├── landing.js create/join, code validation, the last-code prefill + ├── connecting.js the between (create in flight, cancel) + ├── lobby.js code + copy, seats, ready, level picker, start, session panel + ├── countdown.js 3…2…1 (reduced-motion-safe) + ├── play.js #kcanvas host, #hud root, leave, emotes, reconnecting overlay + ├── round_end.js result, stars, score/best, per-player, counters, the exits + └── error.js per-code copy, the right retry affordance, the code for support +``` + +The `window.__undercooked` global is the app instance the e2e suites +drive (`createApp()`'s return: the live state, the verbs, the +`connection`). The composition root (task 03) attaches to it: `params` +(the URL params), `seed` (when the URL carries one), `snapshotBuffer` +and `eventQueue`. The loop's census and the pure modules live on +`window.__uc` (`frameMs` p50/p95, `buffer`, `events`, `loop`, +`params`, `lastView` — the last interpolated state, and — task 04 — +`renderStats` (the renderer's `drawCalls` / `frameMs` census), +`runtime` (the play-time runtime handle: `teardown`), and `fx` (the +FX module's `stats`: the processed-event census, `poolInUse`, +`shakeApplied`, the reduced-motion flag)). Both are test seams, not +APIs — production code goes through the modules. + +The URL params `main.js` parses at boot: `room` (join this code on +boot — a test-harness seam), `seed` (exposed for the deterministic +suites; the server stays authoritative), `controls` (`keyboard` +(default) / `touch` / `off` — task 05's input module reads it; `off` +binds nothing), `freeze=1` (the loop holds the *sample time* at the +first playing frame — a pixel-diff test sees a still image), `motion` +(`1` (default) / `0` — the reduced-motion mode; task 04's FX and +phase 14's a11y read it). + +## The screen state machine + +One machine, in `app.js`'s `createApp()`: an explicit screen enum + +``` +LANDING → CONNECTING → LOBBY ⇄ (lobby re-broadcasts) + → COUNTDOWN (3, 2, 1) + → PLAYING (snap stream; the overlay on a drop) + → ROUND_END → (next_level | retry | to_lobby) +any → ERROR (a fatal err code, a refused handshake, a dead socket) +any → LANDING (leave / back-to-landing — a clean close, no error screen) +``` + +`show(screen, data)`: + +- an unknown screen name (a bug) logs **once** and becomes `ERROR`; +- the same screen re-shown in place calls the module's `update()` (a + re-mount would drop focus off the live control); +- a different screen unmounts the current module, **clears + `#screen-root`**, and mounts the new one — a screen module appends, + the machine clears, so the DOM holds exactly one screen. + +Every screen is a module exporting `{ mount(target, data, app), unmount() }` +plus the hooks it needs (`update`, `setBanner`, `showReconnecting`, +`renderBest`) — no global DOM lives outside the modules. + +## The message → screen mapping + +The wire contract is `docs/PROTOCOL.md`; this is the screen half of it. +The reducer is `connection.onMessage` in `app.js` — one `switch` on +`frame.type`, every branch named: + +| server frame | app state it writes | screen it shows | +|---|---|---| +| `welcome` | `welcome`, `roomCode`, `you`, `spectator` | by `welcome.state`: `lobby`→LOBBY, `countdown`→COUNTDOWN, `playing`→PLAYING, `round_end`→ROUND_END, `closed`→ERROR | +| `lobby` | `lobby`, `roomCode` | LOBBY (the room's state is `lobby`), else the state's screen | +| `countdown` | — | COUNTDOWN (the numeral re-shows per frame) | +| `snap` | `lastSnapshot`, the merged view (full resets, delta patches, `gone` tombstones drop) — **and the snapshot buffer** (the render path; the frame's `e` feeds the event queue) | PLAYING | +| `round` | `round` | ROUND_END | +| `err` (fatal: `room_gone`, `room_full`, `protocol_mismatch`, `evicted`, `internal`) | `error` | ERROR (the code's copy, the right retry) | +| `err` (non-fatal) | `banner` | the mounted screen's banner region (e.g. a `start` refusal) | +| `pong` | — | nothing (the keep-alive's answer) | +| `ev` | the event log — **and the event queue** (the `ev` shape, no tick) | (task 04's renderer consumes them) | +| socket death (`onStatus`) | by the close code | `mismatch`→ERROR (no retry, ever); `1000` mid-round→the PLAYING overlay (phase 08 reconnects); `1006`→ERROR "unreachable" (retry); `4401/4402/4403/4404`→ERROR by code; an intentional close (`leave`) shows nothing | +| a refused `POST /api/rooms` (`503`) | `banner` | LANDING ("the server is at capacity") | + +The verbs the screens call are the app's: `createLobby` (`POST +/api/rooms` then `hello`), `join(code)`, `leave`, `backToLanding`, +`retryJoin`, `ready`, `start`, `setLevel`, `emote`, `toLobby`, +`nextLevel`, `sendInput` (the `seq` is owned by `connection.js`). + +## The screens, as the stories read them + +- **landing** — *Create lobby* (keyboard-first: Tab + Enter), the join + form (`inputmode="text"`, `autocapitalize="characters"`, + `spellcheck="false"`, `maxlength="5"`), the client-side code check + (five Crockford characters — a short code never reaches the wire), + the last-code prefill from `sessionStorage`, the "server at + capacity" / "cannot reach the server" messages. +- **lobby** — the code with a copy button (the clipboard API, the + select-and-copy fallback), the seat list (index, name, colour chip — + the server's 4-colour `SEAT_PALETTE` as CSS classes, ready, the + crown, disconnected), the *Ready* toggle (a toggle on the wire), the + host-only level picker (fed by `/api/levels`: difficulty pips, + target, time, blurb in the option's tooltip), *Start* (enabled from + `lobby.can_start` — the client's mirror of `Room.can_start` — + disabled with the named reason otherwise: "Only the host can + start" / "Pick a level first" / "Waiting for ready cooks"), the + session panel (`/api/session`: unlocked, bests, counters), the + spectator notice. +- **countdown** — 3/2/1 in big numerals; the pulse is decorative and + `prefers-reduced-motion`-gated (under reduced motion the numeral just + changes). +- **play** — `#kcanvas` (hidden until task 04's renderer sizes and + unhides it), the `#hud` root (task 04), *Leave*, the reconnecting + overlay (phase 08 populates it), the emote row (the + `docs/PROTOCOL.md` whitelist: `wave`, `cheer`, `thumbs_up`, + `thumbs_down`, `angry`, `star`), the spectator notice, and — task 05 + — the keyboard control (WASD/arrows, the action button, the emotes, + the help overlay; below) and its `?controls=` gate. +- **round_end** — the result word, the stars (1–3, the pop is + `prefers-reduced-motion`-gated), score against target, the session's + best for the level played, the per-player contribution list (display + only), the counters (`served` / `missed` / `wasted`, the longest + streak), *Next level* (only when `round.unlocked` names a level) / + *Retry* / *Back to lobby*, and the outcome's `aria-live="polite"` + announcement. +- **error** — per-code copy: `room_gone` (no same-code retry), + `room_full` (points at another room), `protocol_mismatch` (a client + that must not retry — it is out of date), `evicted` (the other tab + owns the seat), `internal`, `closed` / `unreachable` (a retry is + offered), and the room code shown for support. + +## Keyboard input and the help overlay (task 05) + +`input/keyboard.js` is the desktop control for the play screen, wired +straight to the protocol's `input` frame (the `seq` stays the +connection's; the module never touches it). The key map is one +function — `getKeyMap()` (the canonical source; `ui/help.js` and the +README's `### Controls` block render the same rows, and a test keeps +them identical): + +| Keys | Action | +|---|---| +| `W` `A` `S` `D` (or the arrow keys) | move — 8-way intent; opposites cancel; a diagonal is normalised (`1/√2` — the server clamps the magnitude to 1) | +| `Space` / `Enter` | the one action button — a press is a rising edge; the hold rides the frame's `act` flag (the sim's gate turns it into one event + the hold progress) | +| `1`–`4` | the emote whitelist's first four (`wave`, `cheer`, `thumbs_up`, `thumbs_down`); legal in `LOBBY` and `PLAYING` (the server's per-state whitelist decides) | +| `?` | the help overlay's toggle | +| `Esc` | close the help, or — on the play screen with the help closed — leave the room (the *Leave* behaviour) | + +Key events are ignored while focus is in an input field, while +`document.hidden`, or while a modifier is held (browser shortcuts +survive — no `preventDefault` on Ctrl/Cmd/Meta combos). A `blur` / +`visibilitychange` (hidden) / `pagehide` clears every held key and +sends one zero-intent frame with the action released — no cook running +away on an alt-tab. + +The send pump: while a key is down it re-sends the intent ~every 48 ms +(≈ 20.8 Hz — above the 20 Hz tick; a tick with no frame brakes the +body, so a held key must ride every tick window); idle it re-sends a +zero-intent frame at 10 Hz (the protocol's ≥ 5 Hz keep-alive — it is +also what asserts "no input" after a release). `?controls=off` (and +`touch`, until phase 08 ships it) binds nothing: the module still +exposes `getKeyMap()` and a `stats()` census so the suites can tell +the modes apart — `off` is the scripted-test mode, where the e2e suites +drive the wire through the `sendInput` seam and the pump's keep-alive +would race the script's frames on the same seat. + +The help overlay (`ui/help.js`): a `role="dialog"` panel (a +translucent backdrop, no blur) with the key-map rows, opened by `?` +(toggled by it), closed by `Esc`, its *Close* button, or a backdrop +click; no external docs mid-game. + +## The snapshot buffer and the render loop (task 03) + +`main.js` (the composition root) owns the play-time pipeline: the +`snap` stream feeds the **snapshot buffer** (`net/snapshots.js`) +*and* the app's merged view (the DOM path the lobby/HUD/ +choreography read); the buffer's frames drive the **single render +loop** (`render/loop.js`) — one `requestAnimationFrame` instance per +page, the only owner of the clock (`sample → lerpState → render → +hud.update` per frame; task 04 installs the canvas layers and the HUD +drawing in the render). The buffer, the decoder and the event queue +are pure modules: no DOM, no network, an **injected clock** — +`tests/e2e/test_us06_interp_math.py` drives them in the browser with +synthetic streams, and `tests/e2e/test_us06_smoothness.py` proves the +60 ms-latency round is smooth end to end. + +**The buffer** (`createSnapshotBuffer({interpDelayMs=100, maxAgeMs=1000})`): + +- frames are keyed by `t` (the server tick) and arrive in order; an + older or duplicate `t` is **dropped** (counted), a `t` jump is a + **gap** (recorded with its size); +- a gap *longer than* `MAX_GAP_TICKS` (6 — three 200 ms windows) + sets the resync latch and fires `onResync`, **debounced to one + request per `RESYNC_DEBOUNCE_MS` (500 ms)**. While the latch is + set, delta frames are *skipped* (their changes were partly missed; + applying them against a stale `prev` would freeze half-changed + state in the view). The stream's full cadence + (`FULL_SNAPSHOT_EVERY_TICKS`, phase 06) guarantees a full frame + arrives, and it clears the latch — the whole resync contract is + *never ask, just hold*: the next full frame is the resync; +- `sample(now)` renders at `now - interpDelayMs` (the fixed 100 ms + delay — it covers the injected 60 ms latency) and returns the two + bracketing decoded frames `{a, b}` plus `alpha` (by the frames' + arrival times); no frame for `maxAgeMs` and the sample **freezes** + (`a === b`, `alpha 0`) — the buffer never extrapolates, a frozen + cook stays exactly where the last frame put it; +- `stats()` is the census (`pushed`, `dropped`, `gaps`, + `maxGapTicks`, `skippedUntilFull`, `resyncs`, `lastT`). + +**`lerpState(a, b, alpha)`** turns the bracket into a render-ready +state: positions lerp along the chord (a cook never passes through a +wall, so the chord is the path), progress / time-left / hazard life +lerp, and the discrete fields (the facing, the carried item, the +states) **snap at the `alpha >= 0.5` boundary**. Entities added at +`b` appear for the whole interval (at `b`'s position); entities +removed vanish exactly at the boundary (held at `a`'s values until +`alpha >= 0.5`, then gone). Stations and orders diff by their wire +identity (`k` + coordinates / `id`), as do players (`i`) and hazards +(`t` + coordinates). + +**`applyDelta(frame, prev)`** (`net/decode.js`) is the client's mirror +of `server/encoding.py`'s delta mode: a `full: false` frame's +entities live under `d` (its `gone` tombstones are *not* part of the +previous state), upserted by the encoder's identity keys onto the +previous frame (full or delta — the encoder's own rule); the scalars +(`t`, `s`, `tgt`, `tl`) and the tick's events ride every frame and +are taken from the frame itself. `tests/e2e/test_us06_interp_math.py` +proves the decoded frame is deep-equal to the full frame the server +would have sent, over states encoded by the *real* `encode_snapshot` +(a move, a turn, a tombstone, an append — and a delta-over-delta +chain). The enum mapping goes through `shared/enums.js`, whose sets +`tests/unit/test_client_enum_parity.py` keeps set-equal to the +Python enums in both directions (a drift is a build failure, naming +the missing value). + +**The event queue** (`net/events.js`) turns the wire's transient +events (a frame's `e[]`, the standalone `ev`) into bounded FX work: +per-kind `ttlMs` (a table, a default for the rest), a **dedup window** +(the same event — kind + position + payload — inside 100 ms is +counted, not re-queued: the 20 Hz cadence can echo one tick's +events, and a chop plays once), `drift(now)` pruning the expired +(called each frame, so an unattended queue shrinks to nothing), and +**tick-reset detection**: a `push(t)` whose tick is below the last +seen tick is a resync (a new round, a rejoin) — the queue clears +first, so a `served` from the previous round cannot replay. + +**The loop's lifecycle**: *paused* when `document.hidden` (the rAF +chain stops; the `visibilitychange` back to visible restarts it, and +the delta spanning the pause is dropped, not measured); *idle* when +the screen is not `PLAYING` (a non-active frame renders nothing but +keeps the chain alive, so the `PLAYING` flip is picked up on the very +next frame); `?freeze=1` holds the *sample time* at the first playing +frame (the composition's business — a frozen render is a still image +without the loop pausing). Every rendered frame's time lands in a +sliding 120-sample window; `window.__uc.frameMs` is the self- +measured census (`p50` / `p95` / `max`). + +## The atlas (task 01) + +`render/atlas.js` loads `assets/index.json` and decodes each SVG symbol +through a `data:` URL (the page CSP blocks the art's `style` hooks, and +browsers do not render `#symbol` fragments from a cross-file reference +in that path — the `data:` round-trip is what the engine renders). +`atlas.ready()` resolves when every symbol has `naturalWidth`; +`atlas.draw(ctx, id, x, y, w, h, {tint, alpha, flipX})` throws on an +unknown id (never a silent nothing-drawn); `atlas.meta(id)` is the +read-only probe. The hex values the canvas and the CSS both use live in +`assets/palette.css` and `render/palette.js`, and +`tests/unit/test_assets.py` asserts the two files agree. + +## Adding a sprite (the asset convention) + +1. Draw the symbol in `assets_src/.svg` (one `` per + drawable, the ids namespaced — `tile-floor`, `item-tomato-chopped`, + `cook-2-front-1`; the file stays ≤ 40 KB, `tests/unit/test_assets.py` + guards it; no `xlink:href`/`http` reference, the same-origin guard + covers it). +2. Run `uv run python -m undercooked.tools.gen_art_index` — it rewrites + `app/static/assets/index.json` and **fails naming a duplicated or a + missing expected id** (the expected set derives from the sim's + enums: every `ItemKind` × reachable form, every station state, every + cook facing/frame must exist, so a sim change that orphans a symbol + is a build failure, not a blank tile). +3. Copy the SVG into `app/static/assets/` (the generator's check keeps + the two trees in sync) and commit the generated `index.json`. +4. `tests/e2e/test_atlas_loads.py` proves the engine decodes it (a + non-blank pixel assertion on a probe canvas) — run it before the + phase completes. +5. Give the new id a **mapping entry** so the renderer knows how to + draw it: for an *item* form, the pair is already covered if the + kind is in `render/canvas.js`'s `REACHABLE_ITEM_FORMS` (the sim's + `raw`-guaranteed kinds are there by construction; the set is + parity-guarded against the sim's `ItemKind` / `ItemForm`, which + `shared/enums.js` mirrors); for a *stove state variant*, add it to + `render/canvas.js`'s `STATION_STATE_VARIANT` (a non-stove station + is state-blind, and a cook's id is derived by `cookSpriteId` — + `cook---` — so a new facing/frame needs only + the symbol, `gen_art_index`'s expected set covers it). + `tests/unit/test_client_sprite_map.py` walks every reachable + (kind, form) pair and every (station kind, state) pair against the + shipped `index.json` — a new pair without its sprite, or a mapping + that names a missing id, is a build failure naming the pair. + +## The renderer and the HUD (task 04) + +`main.js`'s play-time pipeline installs the canvas and the HUD in the +loop's `render` step: `sample → lerpState → renderer.drawFrame(state, +now) → fx.update/draw → hud.update(state, view)`. A **play runtime** +(`makePlayRuntime` in `main.js`) is created the first time `#kcanvas` +exists in `PLAYING`, torn down when it vanishes, and its canvas stays +`hidden` until the atlas is ready (the first `drawFrame` un-hides it). +The runtime is exposed on `window.__uc.runtime` (the e2e suites call +`teardown` to release the rAF-bound resources between runs). + +**The camera** (`render/camera.js`): fits the kitchen to the viewport +with a min/max zoom in tiles-per-pixel, letterboxes the overflow, and +is DPR-aware (`devicePixelRatio`, capped at 2) with integer-ish scaling +to avoid shimmer. `resize()` re-derives the transform on resize / +visibility; `worldToScreen(x, y)` / `screenToWorld(x, y)` are the +world⇄CSS-pixel maps the renderer and the target highlight use. The +renderer **reconciles the canvas size to the camera every frame** +(`drawFrame`): if `#kcanvas` is connected and its `clientWidth/ +Height` differ from the camera's `cssW/cssH`, it calls `camera.resize()` +— so a camera attached while the canvas was `hidden` (a zero +`clientWidth`) is re-fit the moment the canvas is un-hidden, and a +window resize during play is picked up on the next frame. + +**The layer order** (`render/canvas.js`, `drawFrame` draws in this +exact order — the order is the contract; a layer drawn earlier is +occluded by the ones after it): + +| # | layer | source | +|---|---|---| +| 1 | floor | `tile-floor` per walkable cell of the level bounds | +| 2 | floor decals (spills) | the `hz`-derived stain decals (faint, under everything) | +| 3 | station bases | `station-` per station (y-sorted within the layer) | +| 4 | carried-under items | the carried item drawn *under* its cook (so the cook overlaps it) | +| 5 | players | `cook---` (body 1–4 by seat), y-sorted (a cook never draws through a wall) | +| 6 | carried overlays | the carried item drawn *over* its cook (the held highlight) | +| 7 | station progress rings | a `0..1` arc over a working/ready station (`progress` lerped) | +| 8 | hazards | `hazard-` (the `hz` life lerps the fade) | +| 9 | particles | the FX pool (above the world, under the HUD) | +| 10 | selection / target highlight | the reach-zone tile the server would target (faint) | + +**Sprite selection** maps the interpolated state to atlas ids via the +three `render/canvas.js` mappers: `stationSpriteId(kind, state)` +(a non-stove station is state-blind; the stove's `s` — one of the +`StationState` set — selects a variant through +`STATION_STATE_VARIANT`, and a `fire` hazard on the stove's cell +switches the base to the burning sprite in the hazard layer), +`itemSpriteId(kind, form)` (the `h` wire field, mapped through +`shared/enums.js`; an unreachable pair falls back to the kind's +`raw` sprite so a protocol bug draws *something*, never a blank tile), +and `cookSpriteId(seat, facing, frame)` (`cook--- +`). An **unknown id throws** — the throw is surfaced to the +console *once* (naming the id) and then suppressed, so a bad mapping +is a one-line console error plus a fallback rect, never a silent blank +tile and never a console flood. The walk animation is **fixed-step**: +the frame is `floor(now / WALK_STEP_MS) % 2` while the cook's velocity +is non-zero, so a 120 Hz display does not animate faster than a 60 Hz +one; the clock is `now` (the loop's sample time), which the loop +already drops when `document.hidden`, so the animation pauses with the +render. + +**`renderStats()`** is the renderer's census — `drawCalls` (cumulative +`atlas.drawSync` calls), `frames`, `frameMs` (a moving average), +`frameMsP95`, `frameMsMax`, and `warnedSprites` (the set of ids that +already logged their one-line console error). It is exposed on +`window.__uc.renderStats` for the perf assertions (`tests/e2e/ +test_us04_render_smoke.py` asserts `drawCalls` is within a sane band, +< 4 000 per the budget, and the frame time ≤ 16 ms p95). + +**The target highlight** is a client-side approximation of +`interact.candidate_cells`: `candidateCells(x, y, facing)` (the +`CANDIDATE_OFFSETS` mirror in `render/canvas.js`) gives the four +reach-zone cells — the front tile, the two diagonal-front tiles, and +the tile under the cook, deduplicated in priority order — and the +renderer draws them faintly, so reaching for a station is +predictable. The grid / legend / station-ids never leave the server +(PLAN §8), so the highlight is an approximation from the +interpolated position and facing — it is a *hint*, not the server's +truth. + +**The FX** (`render/fx.js`) maps the wire's transient events to +effects: `CHOPPED` a spark, `COOKED`/`READY` steam, `SERVED` a pop + +score float, `BURNT` smoke, `DENIED` a cross + nudge, `STUNNED` a +spin, `ORDER_MISSED` a flash, `SCORE` a float. Particles come from a +**fixed pool** (no allocation per event: a `spawn` reuses a slot, and +when the pool is full it steals the oldest, so a burst cannot +allocate unboundedly). The camera shake is a decaying offset the +renderer adds to the camera; under `[data-motion="reduced"]` / +`prefers-reduced-motion` the shake is suppressed (the `shakeApplied` +census stays 0) and the effects are **static markers** (no particle +animation), so a denied nudge and a hazard are still visible without +motion. `fx.stats()` is the census the e2e suites read: `processed` +(per-kind counts), `poolInUse`, `shakeApplied`, `reducedMotion`. + +**The HUD** (`ui/hud.js`) is **DOM, not canvas** — it stays crisp and +is readable by assistive tech. The structure (all under `#hud`): +the **order rail** (a ticket per order: a recipe-icon chain, a time +bar, the points; the oldest ticket highlighted with an `oldest` +class), the **score + target bar** (the current score over the +target), the **countdown timer** (turns amber < 20 s, red < 10 s, +with a non-colour cue — the timer's text label: `hurry` < 20 s, +`now` < 10 s), the **streak indicator**, the **dirty-dish counter**, +the **connection indicator**, and the **spectator banner**. Updates are +**coalesced to one write per animation frame** (the HUD accumulates the +frame's changes and writes the DOM once, so a 20 Hz event stream +causes at most one DOM write per rAF). The HUD changes are visible +within 200 ms of the server event (`tests/e2e/test_us04_render_smoke.py` +asserts the score text changes within 200 ms of the `served` event). + +- The story suites (`tests/e2e/test_us01_*` … `test_us06_*`): a real + browser against a real uvicorn server (the `e2e_server` factory in + `tests/e2e/conftest.py` — an ephemeral `127.0.0.1` port, never a + routable interface), two contexts = two players; they assert visible + text and the `window.__undercooked` seam, not pixels, for the DOM + screens. `test_us04_render_smoke.py` is the one exception that + asserts *pixels and the perf budget*: the canvas is non-blank + (variance over sampled pixels) and changes after a move, `renderStats + ().drawCalls` is within a sane band (< 4 000), the frame time is ≤ + 16 ms p95 over a 10 s scripted round, the HUD elements exist and + update (the score text changes within 200 ms of the `served` event, + the timer decreases), `document.hidden` pauses the loop, and the + reduced-motion mode shakes nothing while the hazard markers stay + visible. +- The pure-module suites (`test_atlas_loads.py`, + `test_us06_interp_math.py`): the browser imports the module directly + (`import("/static/js/…")`) and drives it — no bundler, no jsdom. + `test_us06_interp_math.py` covers the buffer's ordering / gap / + resync / freeze contract, `lerpState`'s lerp-and-snap, the + `applyDelta` decoder against the real encoder, the event queue's + TTL / dedup / tick-reset, and the loop's pause / idle / census — + all with injected clocks and captured rAFs. `test_us06_smoothness.py` + is the end-to-end smoothness proof: the 60 ms-latency round, the + per-tick displacement bounded by + `MAX_SPEED * (1/snapshot_hz) * 1.35`, and both clients on the + result screen within 1 s of each other. +- The enum parity suite (`tests/unit/test_client_enum_parity.py`): + the JS wire enums stay set-equal to the Python ones, in both + directions (a removed value is a build failure, naming it). +- The sprite-map suite (`tests/unit/test_client_sprite_map.py`): + walks `render/canvas.js`'s `REACHABLE_ITEM_FORMS` (its kind set is + parity-guarded against the sim's `ItemKind` / `ItemForm`, which + `shared/enums.js` mirrors) and the station-state / cook-frame + tables, against the sim's enums and the shipped `assets/index.json` + — every reachable (item kind, form) pair, every (station kind, + state) pair, and every cook facing/frame must map to an id that + exists, so a new sim pair without its sprite, or a mapping table + naming a missing id, is a build failure naming the pair. +- The same-origin rule is re-asserted in the browser (a full round's + request census must equal the module graph + the API calls, all + same-origin). diff --git a/src/undercooked/server/room.py b/src/undercooked/server/room.py index 395ae88..13ccfa9 100644 --- a/src/undercooked/server/room.py +++ b/src/undercooked/server/room.py @@ -169,6 +169,7 @@ __all__ = [ "ROOM_EVENTS", "ROOM_TRANSITIONS", "SEAT_GRACE_S", + "TEST_MODE_SEED", "IllegalTransition", "Room", "RoomState", @@ -200,6 +201,15 @@ _SEED_MODULUS: Final[int] = 2**63 _SEED_MULTIPLIER: Final[int] = 6364136223847 _SEED_INCREMENT: Final[int] = 1442695040888963407 +#: The fixed base seed a room is born with under `UNDERCOOKED_TEST_MODE=1` +#: (the test-only seam, the e2e flagship suite's `?seed=` — a test-mode +#: server's rounds are deterministic, so a page loaded with +#: `?seed=20240701` plays the same seed the room's sim actually draws). +#: A production room (`test_mode` off) instead draws fresh from +#: `secrets.randbelow(_SEED_MODULUS)`. The value is documented in the +#: README's configuration table and `tests/e2e/README.md`. +TEST_MODE_SEED: Final[int] = 20240701 + class RoomState(StrEnum): """The room's lifecycle state (PLAN §6 FSM).""" diff --git a/src/undercooked/server/rooms.py b/src/undercooked/server/rooms.py index 31f4e7f..9ba64ae 100644 --- a/src/undercooked/server/rooms.py +++ b/src/undercooked/server/rooms.py @@ -47,7 +47,7 @@ from contextlib import suppress from undercooked.config import Settings from undercooked.server.clock import Clock from undercooked.server.codes import new_code, normalize_code -from undercooked.server.room import SEAT_GRACE_S, Room, RoomState +from undercooked.server.room import SEAT_GRACE_S, TEST_MODE_SEED, Room, RoomState from undercooked.server.seats import NAME_MAX_LEN, sanitize_name from undercooked.server.sessions import SessionRecord, SessionStore from undercooked.sim.content import ContentBundle @@ -202,6 +202,11 @@ class RoomSupervisor: settings=self._settings, clock=self._clock, level_id=level_id, + # The test-only seam: under `UNDERCOOKED_TEST_MODE=1` the room + # is born with the documented fixed seed (`TEST_MODE_SEED`), so + # a `?seed=20240701` page plays the seed the sim actually draws; + # a production room draws fresh (`None` → `secrets.randbelow`). + seed=TEST_MODE_SEED if self._settings.test_mode else None, sleep=self._sleep, content=self._content, sessions=self.session_for, diff --git a/src/undercooked/server/ws.py b/src/undercooked/server/ws.py index c2f4ef9..3e09a19 100644 --- a/src/undercooked/server/ws.py +++ b/src/undercooked/server/ws.py @@ -602,10 +602,17 @@ def _welcome(room: Room, token: str, rejoin: bool = False) -> WelcomeMsg: ) -def _broadcast_lobby(room: Room, registry: ConnectionRegistry) -> None: +def _broadcast_lobby( + room: Room, + registry: ConnectionRegistry, + exclude: Connection | None = None, +) -> None: """The `lobby` re-send: one frame per live connection, each built for - *that* viewer (the `unlocked` field is viewer-specific).""" + *that* viewer (the `unlocked` field is viewer-specific). `exclude` + skips one connection (a joiner that just got its own copy).""" for conn in registry.for_room(room.code): + if conn is exclude: + continue conn.enqueue(LobbyMsg.model_validate(room.lobby_payload(conn.token))) @@ -1005,6 +1012,15 @@ async def _seat_or_spectate( await evict(previous) conn.enqueue(_welcome(room, token, rejoin=was_ghost)) conn.enqueue(LobbyMsg.model_validate(room.lobby_payload(token))) + # A join is a `lobby` state change (PLAN §7: the `lobby` re-send + # rides "lobby-state changes"): the room's other live members see + # the new seat — or a ghost seat's `connected` flip back on a + # rejoin. The joiner just got its own copy above, so it is + # excluded from the re-send. A reclaim of the joiner's own + # *connected* seat (a duplicate `hello` on a live socket, the + # creator's first) changes nothing for the others. + if room.state is RoomState.LOBBY and (previous_seat is None or was_ghost): + _broadcast_lobby(room, registry, exclude=conn) async def _reseat( diff --git a/src/undercooked/tools/gen_art_index.py b/src/undercooked/tools/gen_art_index.py new file mode 100644 index 0000000..b8a3904 --- /dev/null +++ b/src/undercooked/tools/gen_art_index.py @@ -0,0 +1,307 @@ +"""The art index generator (phase 07, task 01). + +A small standard-library script that turns the hand-authored SVG in +`assets_src/` (served from `app/static/assets/`) into the `index.json` +the browser atlas (`app/static/js/render/atlas.js`) loads: + + { "": {"file": ".svg", "w": 32, "h": 32}, ... } + +It refuses to write the index when the art lies: + +* a symbol id duplicated anywhere in the set (the name and both files + are printed); +* an expected id missing from the art. The expected set is derived from + the simulation's own vocabulary (`undercooked.sim.model`'s enums) and + its pipeline data (`undercooked.sim.stations`' `ACCEPTS` / + `TRANSITIONS`), so a new `ItemKind` or `ItemForm` the sim can produce + makes the build fail until a symbol is drawn for it; +* a symbol without an `id`, or without a `viewBox`/`width`/`height` to + size it with (the name is printed); +* a missing `assets_src` file, or a served copy under + `app/static/assets/` that has drifted from its source. + +Usage (from the repo root): + + uv run python -m undercooked.tools.gen_art_index + +`--src` and `--out` re-point the source tree and the index destination +(the tests run it against a temporary copy). Exit `0` on success, `1` +when a check fails. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import sys +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path +from typing import Final + +REPO_ROOT: Final[Path] = Path(__file__).resolve().parents[3] +DEFAULT_SRC: Final[Path] = REPO_ROOT / "assets_src" +DEFAULT_OUT: Final[Path] = REPO_ROOT / "app" / "static" / "assets" + +if importlib.util.find_spec("undercooked") is None: + # Run as a plain file (`python src/undercooked/tools/gen_art_index.py`): + # make the package importable first. + sys.path.insert(0, str(REPO_ROOT / "src")) + +# The sys.path bootstrap above must run first (plain-file mode); the +# directives at the end of the import lines keep E402 quiet. +from undercooked.sim.model import Facing, ItemForm, ItemKind, StationKind, TileKind # noqa: E402 +from undercooked.sim.stations import ACCEPTS, TRANSITIONS # noqa: E402 + +__all__ = [ + "SVG_FILES", + "ArtIndexError", + "build_index", + "check_served", + "expected_ids", + "main", + "parse_symbols", + "reachable_item_forms", +] + +#: The six hand-authored files, in the order the index lists them. +SVG_FILES: Final[tuple[str, ...]] = ( + "tiles.svg", + "stations.svg", + "items.svg", + "cooks.svg", + "fx.svg", + "ui.svg", +) + +FLOOR_VARIANTS: Final[int] = 4 +COOK_BODIES: Final[int] = 4 +WALK_FRAMES: Final[int] = 2 +STOVE_STATES: Final[tuple[str, ...]] = ("off", "working", "ready", "burning") +STATION_EXTRAS: Final[tuple[str, ...]] = ("pot", "pan") +FX_IDS: Final[tuple[str, ...]] = ("chop", "steam", "flame", "splash", "deny", "score", "serve") +UI_IDS: Final[tuple[str, ...]] = ("ticket", "star", "crown", "ready", "ghost", "streak") + + +class ArtIndexError(ValueError): + """The art tree cannot be indexed; the message names every problem.""" + + +@dataclass(frozen=True, slots=True) +class Symbol: + """One drawable: its id and the size its `viewBox` (or `width`/ + `height`) gives it.""" + + id: str + w: float + h: float + + +def parse_symbols(svg_path: Path) -> list[Symbol]: + """Every `` of one SVG file, in document order. + + Raises: + ArtIndexError: the file is not well-formed XML, a symbol has no + `id`, or a symbol has no `viewBox`/`width`/`height` to size + it with (each failure names the file and, where possible, + the symbol). + """ + try: + root = ET.parse(svg_path) # noqa: S314 - own hand-authored art files + except ET.ParseError as exc: + msg = f"{svg_path.name}: not well-formed XML: {exc}" + raise ArtIndexError(msg) from None + symbols: list[Symbol] = [] + for position, element in enumerate(root.iter()): + if not element.tag.endswith("symbol"): + continue + symbol_id = element.get("id") + if not symbol_id: + msg = ( + f"{svg_path.name}: a has no id " + f"(symbol number {position + 1} in the file)" + ) + raise ArtIndexError(msg) + size = _symbol_size(element) + if size is None: + msg = ( + f"{svg_path.name}: symbol {symbol_id!r} has no viewBox or " + "width/height, so the atlas cannot size it" + ) + raise ArtIndexError(msg) + symbols.append(Symbol(id=symbol_id, w=size[0], h=size[1])) + return symbols + + +def _symbol_size(element: ET.Element) -> tuple[float, float] | None: + """The (w, h) a symbol's `viewBox` — else `width`/`height` — implies.""" + view_box = element.get("viewBox") + if view_box: + parts = view_box.split() + if len(parts) == 4: + return float(parts[2]), float(parts[3]) + return None + width = element.get("width") + height = element.get("height") + if width and height: + return float(width), float(height) + return None + + +def reachable_item_forms() -> dict[ItemKind, frozenset[ItemForm]]: + """The (kind, form) pairs the simulation can actually produce. + + Walked to a fixpoint from the sim's own data: every kind the spawn + shelf stocks starts `RAW`; `TRANSITIONS` (restricted to the kinds + `ACCEPTS` lets the station work) moves forms forward; the prep's + `PLATED` output is a *plate* item, so it is granted to `PLATE` + alone; `COOKING` is a kind on the stove's heat; `BURNT` is what the + 20 s board rot, the half-rate carry or the 8 s stove burn leaves + behind. `PLATE` also carries the dirty-prop states (`DIRTY`, then + washed back to `RAW` by the sink). + """ + reachable: dict[ItemKind, frozenset[ItemForm]] = {} + for kind in ItemKind: + forms: set[ItemForm] = set() + if kind in ACCEPTS[StationKind.SPAWN_SHELF]: + forms.add(ItemForm.RAW) + if kind is ItemKind.PLATE: + forms |= {ItemForm.DIRTY, ItemForm.PLATED} + changed = True + while changed: + changed = False + for (station, form), (output, _seconds) in TRANSITIONS.items(): + if station is StationKind.PREP and output is ItemForm.PLATED: + continue # the dish comes out as a PLATE item, not `kind` + if ( + kind in ACCEPTS[station] + and form in forms + and output not in forms + ): + forms.add(output) + changed = True + if kind in ACCEPTS[StationKind.STOVE] and forms & {ItemForm.RAW, ItemForm.CHOPPED}: + forms.add(ItemForm.COOKING) + if ( + forms & {ItemForm.RAW, ItemForm.CHOPPED} + and kind in ACCEPTS[StationKind.CHOP_BOARD] + ): + forms.add(ItemForm.BURNT) # board rot / half-rate carry burn + if ItemForm.COOKED in forms: + forms.add(ItemForm.BURNT) # stove burn / half-rate carry burn + reachable[kind] = frozenset(forms) + return reachable + + +def expected_ids() -> frozenset[str]: + """Every id the atlas must be able to draw, derived from the sim.""" + ids: set[str] = {f"tile-{kind.value}" for kind in TileKind} + ids |= {f"tile-floor-{n}" for n in range(1, FLOOR_VARIANTS + 1)} + ids |= {f"station-{kind.value}" for kind in StationKind} + ids |= {f"station-stove-{state}" for state in STOVE_STATES} + ids |= {f"station-{extra}" for extra in STATION_EXTRAS} + ids |= { + f"item-{kind.value}-{form.value}" + for kind, forms in reachable_item_forms().items() + for form in forms + } + ids |= { + f"cook-{n}-{facing.value}-{frame}" + for n in range(1, COOK_BODIES + 1) + for facing in Facing + for frame in range(WALK_FRAMES) + } + ids |= {"cook-carry", "cook-stun"} + ids |= {f"fx-{fx}" for fx in FX_IDS} + ids |= {f"ui-{ui}" for ui in UI_IDS} + return frozenset(ids) + + +def build_index(src_dir: Path) -> dict[str, dict[str, object]]: + """`{id: {file, w, h}}` for every symbol under ``src_dir``. + + Raises: + ArtIndexError: a listed SVG file is missing, a symbol id is + duplicated (both files named), or an expected id is missing + (every missing id named). + """ + index: dict[str, dict[str, object]] = {} + for name in SVG_FILES: + path = src_dir / name + if not path.is_file(): + msg = f"{path}: the source art file is missing" + raise ArtIndexError(msg) + for symbol in parse_symbols(path): + if symbol.id in index: + previous = index[symbol.id]["file"] + msg = ( + f"symbol id {symbol.id!r} is duplicated: " + f"{path.name} and {previous} both define it" + ) + raise ArtIndexError(msg) + index[symbol.id] = {"file": name, "w": symbol.w, "h": symbol.h} + missing = sorted(expected_ids() - index.keys()) + if missing: + msg = "missing expected ids (derived from the sim's enums): " + ", ".join( + missing + ) + raise ArtIndexError(msg) + return index + + +def check_served(src_dir: Path, out_dir: Path) -> list[str]: + """The served copies under ``out_dir`` must match their source byte-for-byte. + + Returns: + The list of problems (empty when the served output is faithful). + """ + problems: list[str] = [] + for name in SVG_FILES: + source = src_dir / name + served = out_dir / name + if not source.is_file(): + problems.append(f"{source}: the source art file is missing") + continue + if not served.is_file(): + problems.append(f"{served}: the served copy is missing") + continue + if source.read_bytes() != served.read_bytes(): + problems.append(f"{served.name}: the served copy differs from {source.name}") + return problems + + +def main(argv: list[str] | None = None) -> int: + """Run the generator; `0` on success, `1` with the problems on stderr.""" + parser = argparse.ArgumentParser( + prog="gen_art_index", + description="Write app/static/assets/index.json from the SVG art.", + ) + parser.add_argument("--src", type=Path, default=DEFAULT_SRC, help=argparse.SUPPRESS) + parser.add_argument("--out", type=Path, default=DEFAULT_OUT, help=argparse.SUPPRESS) + args = parser.parse_args(argv) + + try: + index = build_index(args.src) + problems = check_served(args.src, args.out) + except ArtIndexError as exc: + print(f"art index: {exc}", file=sys.stderr) + return 1 + if problems: + for problem in problems: + print(f"art index: {problem}", file=sys.stderr) + return 1 + + args.out.mkdir(parents=True, exist_ok=True) + target = args.out / "index.json" + target.write_text( + json.dumps(index, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print( + f"art index: wrote {len(index)} ids from {len(SVG_FILES)} files to {target}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 3cfef5f..641c707 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -1 +1,11 @@ -Playwright browser suites, one per user story (`test_usNN_*.py`, marker `e2e`). +Playwright browser suites, marker `e2e`, driven through the `live_server` and +`context`/`page` fixtures in `tests/e2e/conftest.py`. + +Naming contract (guarded by `tests/unit/test_harness.py`): + +- A **story suite** is named for its story: `test_usNN_*.py`. +- A **technical suite** guards a phase's machinery rather than a single user + story (e.g. `test_atlas_loads.py`, the phase-07 art/atlas guard, named by + the phase's task file). Technical suites are an explicit, documented + exception: they are listed in `E2E_TECHNICAL_SUITES` in `test_harness.py` + before they may exist. diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 8cf2fca..ca1220b 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -6,24 +6,55 @@ a fresh context and a fresh page, all function-scoped (see `browser` for why the is load-bearing). A fresh context per test matters too: `sessionStorage` (the anonymous `uc.token`, `uc.lastCode`) is scoped to it, so tests cannot observe each other's identity. + +The phase-07 story suites (task 02) need servers a browser can drive end to end: +`e2e_server` is a session-scoped factory that spins up a real `uvicorn.Server` +on an ephemeral `127.0.0.1` port (never a routable interface) with a `Settings` +resolved the way `tests/integration/conftest.py` resolves one (the authoritative +`config.ENV_KEYS` contract, ambient `UNDERCOOKED_*` / `DEBUGPY*` stripped, the +`levels_dir` override pointing at the campaign the factory names). The three +session fixtures built on it: + +* `round_server` — the `tests/fixtures/levels_round/` campaign (the shipped level 1, + layout-verbatim, `target_score` 20 / `time_limit` 60 s — one served soup scores + 44-66, so the first serve wins the round; the forced-short-round the task-02 + result-screen test runs. The task's `time_limit: 4` idea is impossible: the + validator's `TIME_LIMIT_INVALID` floor is `MIN_TIME_LIMIT_S` (30 s) and + `create_app`'s content gate is unconditional — 60 s with a first-serve win is + the shortest round a valid campaign can offer); +* `small_server` — `max_players=2`, for the full-room story; +* `full_server` — `max_rooms=1`, for the process-capacity "server full" story. """ from __future__ import annotations -from typing import TYPE_CHECKING +import importlib +import os +import threading +import time +from collections.abc import Callable, Iterator +from pathlib import Path +from typing import TYPE_CHECKING, Any +import httpx import pytest from playwright.sync_api import sync_playwright if TYPE_CHECKING: - from collections.abc import Iterator - from playwright.sync_api import Browser, BrowserContext, Page, ViewportSize # The default desktop viewport; story tests that care about small screens call # `page.set_viewport_size` before navigating. VIEWPORT: ViewportSize = {"width": 1280, "height": 720} +REPO_ROOT = Path(__file__).resolve().parents[2] +#: The playable campaign (phase 06 task 06's test-only copy of the shipped level 1). +ROUND_LEVELS_DIR = REPO_ROOT / "tests" / "fixtures" / "levels_round" + +config = importlib.import_module("undercooked.config") +app_module = importlib.import_module("undercooked.server.app") +uvicorn = importlib.import_module("uvicorn") + @pytest.fixture def browser() -> Iterator[Browser]: @@ -35,8 +66,8 @@ def browser() -> Iterator[Browser]: `sync_playwright()` is open — so a session- or module-scoped fixture makes every later `async def` test in the run die with `RuntimeError: Runner.run() cannot be called from a running event loop` - (pytest-asyncio, `asyncio_mode = "auto"`). Closing the whole stack inside the - test leaves the main thread clean for whatever runs next. + (pytest-asyncio, `asyncio_mode = "auto"`). Closing the whole stack inside + the test leaves the main thread clean for whatever runs next. """ with sync_playwright() as playwright: launched = playwright.chromium.launch(headless=True) @@ -64,3 +95,186 @@ def page(context: BrowserContext) -> Iterator[Page]: yield opened finally: opened.close() + + +# ---------------------------------------------------------------- live servers + + +def _as_env_value(value: object) -> str: + """Render a Python override value the way `load_settings` would read it.""" + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, str | os.PathLike): + return str(value) + if isinstance(value, list | tuple): + return ",".join(str(item) for item in value) + return str(value) + + +def _wait_for_health(url: str, thread: threading.Thread) -> None: + """Poll `/healthz` until it answers 200, failing fast if the server thread died.""" + timeout = float(os.environ.get("UNDERCOOKED_TEST_SOCKET_TIMEOUT", "30")) + deadline = time.monotonic() + timeout + probe = httpx.Client(timeout=1.0, trust_env=False) + try: + while time.monotonic() < deadline: + try: + if probe.get(f"{url}/healthz").status_code == 200: + return + except httpx.HTTPError: + pass + thread.join(timeout=0.1) + if not thread.is_alive(): + raise RuntimeError(f"live server on {url} died during startup") + finally: + probe.close() + raise RuntimeError(f"live server on {url} never became healthy") + + +class E2EServer: + """One live uvicorn server + its app (a context manager).""" + + def __init__(self, url: str, app: Any, server: Any, thread: threading.Thread) -> None: + self.url = url + self.app = app + self._server = server + self._thread = thread + self._closed = False + + def __enter__(self) -> tuple[str, Any]: + return self.url, self.app + + def __exit__(self, *exc_info: object) -> None: + self.close() + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._server.should_exit = True + self._thread.join(timeout=15) + if self._thread.is_alive(): # pragma: no cover - only if uvicorn stops cooperating + pytest.fail("live server did not shut down within 15 s") + + +def _start_e2e_server(overrides: dict[str, object], levels_dir: Path) -> E2EServer: + """One live uvicorn server on an ephemeral loopback port. + + The integration conftest's machine, verbatim in purpose: `Settings` from + `**overrides` (field names, validated against `config.ENV_KEYS`) over the + named `levels_dir` campaign, on an ephemeral `127.0.0.1` port (bound + before the serving thread starts, so the port is known exactly — no log + scraping, no collisions between concurrent runs). + """ + env = { + key: value + for key, value in os.environ.items() + if not key.startswith(("UNDERCOOKED_", "DEBUGPY")) + } + env["UNDERCOOKED_LEVELS_DIR"] = str(levels_dir) + for name, value in overrides.items(): + env[config.ENV_KEYS[name]] = _as_env_value(value) + settings = config.load_settings(env) + application = app_module.create_app(settings) + uv_config = uvicorn.Config( + application, + host="127.0.0.1", + port=0, + log_level="warning", + lifespan="on", + ) + sock = uv_config.bind_socket() + url = f"http://127.0.0.1:{sock.getsockname()[1]}" + server = uvicorn.Server(uv_config) + thread = threading.Thread( + target=server.run, args=([sock],), name="undercooked-e2e", daemon=True + ) + thread.start() + _wait_for_health(url, thread) + return E2EServer(url, application, server, thread) + + +@pytest.fixture(scope="session") +def e2e_server() -> Iterator[Callable[..., E2EServer]]: + """Factory: `e2e_server(ROUND_LEVELS_DIR, max_players=2)` — one call, one + server. Each call starts a fresh `uvicorn.Server` on an ephemeral loopback + port with a `Settings` resolved from the overrides (field names, validated + against `config.ENV_KEYS`) over the named campaign. Shutdown is requested + through `should_exit` so the lifespan stops cleanly; a session-end safety + close covers a leaked server.""" + started: list[E2EServer] = [] + + def _make(levels_dir: Path, **overrides: object) -> E2EServer: + if any(name not in config.ENV_KEYS for name in overrides): + unknown = sorted(name for name in overrides if name not in config.ENV_KEYS) + pytest.fail(f"unknown settings override(s): {unknown}") + live = _start_e2e_server(dict(overrides), levels_dir) + started.append(live) + return live + + def _close_all() -> None: + for live in started: + live.close() + + _make.close_all = _close_all # type: ignore[attr-defined] + yield _make + _close_all() # type: ignore[attr-defined] + + +@pytest.fixture(scope="session") +def round_server(e2e_server: Callable[..., E2EServer]) -> Iterator[E2EServer]: + """The playable campaign (`tests/fixtures/levels_round/`): the forced-short + round for the result-screen test (first serve wins — the module docstring).""" + live = e2e_server(ROUND_LEVELS_DIR) + try: + yield live + finally: + live.close() + + +@pytest.fixture(scope="session") +def small_server(e2e_server: Callable[..., E2EServer]) -> Iterator[E2EServer]: + """`max_players=2` — the full-room story (US-02).""" + live = e2e_server(ROUND_LEVELS_DIR, max_players=2) + try: + yield live + finally: + live.close() + + +@pytest.fixture(scope="session") +def full_server(e2e_server: Callable[..., E2EServer]) -> Iterator[E2EServer]: + """`max_rooms=1` — the process-capacity "server full" story (US-01).""" + live = e2e_server(ROUND_LEVELS_DIR, max_rooms=1) + try: + yield live + finally: + live.close() + + +# ---------------------------------------------------------------- page helpers + + +def open_two_pages( + browser: Browser, url: str +) -> tuple[BrowserContext, Page, BrowserContext, Page]: + """Two tabs in two fresh contexts (the multiplayer harness, US-01/US-02). + + Returns `(context_a, page_a, context_b, page_b)`; the caller closes the + contexts in a `finally` (closing a context is the socket drop a story test + wants when it "closes a laptop").""" + context_a = browser.new_context(viewport=VIEWPORT) + context_b = browser.new_context(viewport=VIEWPORT) + page_a = context_a.new_page() + page_b = context_b.new_page() + return context_a, page_a, context_b, page_b + + +def load_app(page: Page, url: str) -> None: + """Load the shell and wait for its module graph to settle.""" + page.goto(f"{url}/", wait_until="networkidle") + + +def app_screen(page: Page) -> str | None: + """The screen the state machine is on (the `window.__undercooked` seam).""" + return page.evaluate("window.__undercooked ? window.__undercooked.screen : null") diff --git a/tests/e2e/test_atlas_loads.py b/tests/e2e/test_atlas_loads.py new file mode 100644 index 0000000..acd92c6 --- /dev/null +++ b/tests/e2e/test_atlas_loads.py @@ -0,0 +1,278 @@ +"""Phase 07 task 01 e2e: the SVG atlas loads in a real browser and draws. + +`TestClient` cannot prove that a `file.svg#symbol-id` fragment decodes to +pixels in an actual engine, so this suite drives headless Chromium: it +imports the atlas module the way the renderer will (`import()` of the +served ES module), waits for `atlas.ready()`, and draws onto a probe +canvas. A symbol that the engine cannot render (no `naturalWidth`) makes +`atlas.draw` throw instead of painting nothing — the non-blank pixel +assertions below are what catch that. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from playwright.sync_api import Page + +pytestmark = pytest.mark.e2e + +REPO_ROOT = Path(__file__).resolve().parents[2] +INDEX_PATH = REPO_ROOT / "app" / "static" / "assets" / "index.json" + +# A spread across the six files, dense enough to assert on: an +# id the engine failed to decode paints zero pixels and fails loudly. +PROBE_IDS = [ + "tile-floor", + "item-tomato-raw", + "item-plate-plated", + "cook-1-down-0", + "station-stove-ready", + "ui-star", + "fx-score", + "fx-flame", +] + + +@pytest.fixture +def console_errors(page: Page) -> list[str]: + """Console errors and uncaught exceptions raised while the page runs (empty = good).""" + errors: list[str] = [] + page.on( + "console", + lambda message: errors.append(f"{message.type}: {message.text}") + if message.type == "error" + else None, + ) + page.on("pageerror", lambda exception: errors.append(f"pageerror: {exception}")) + return errors + + +def import_atlas(page: Page) -> None: + """The same import the renderer uses; a failure is a loud test error.""" + result = page.evaluate( + """async () => { + const atlas = await import("/static/js/render/atlas.js"); + await atlas.ready(); + return atlas.size(); + }""" + ) + assert result >= 100, f"the atlas decoded only {result} symbols" + + +def test_atlas_ready_resolves_and_covers_the_index( + page: Page, live_server: str, console_errors: list[str] +) -> None: + page.goto(f"{live_server}/", wait_until="domcontentloaded") + count = page.evaluate( + """async () => { + const atlas = await import("/static/js/render/atlas.js"); + await atlas.ready(); + return atlas.size(); + }""" + ) + assert count == len(json.loads(INDEX_PATH.read_text(encoding="utf-8"))) + assert count >= 100 + assert console_errors == [] + + +def test_atlas_meta_answers_for_known_and_unknown_ids( + page: Page, live_server: str +) -> None: + page.goto(f"{live_server}/", wait_until="domcontentloaded") + meta = page.evaluate( + """async () => { + const atlas = await import("/static/js/render/atlas.js"); + await atlas.ready(); + return { known: atlas.meta("item-rice-raw"), unknown: atlas.meta("nope") }; + }""" + ) + assert meta["known"] is not None and meta["known"]["file"] == "items.svg" + assert meta["known"]["w"] == 32 and meta["known"]["h"] == 32 + assert meta["unknown"] is None + + +def test_draw_produces_non_blank_pixels( + page: Page, live_server: str, console_errors: list[str] +) -> None: + """A blank kitchen is the failure mode this phase exists to rule out.""" + page.goto(f"{live_server}/", wait_until="domcontentloaded") + results = page.evaluate( + """async (ids) => { + const atlas = await import("/static/js/render/atlas.js"); + await atlas.ready(); + const canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext("2d"); + const results = {}; + for (const id of ids) { + await atlas.draw(ctx, id, 0, 0, 64, 64); + const data = ctx.getImageData(0, 0, 64, 64).data; + let nonBlank = 0; + for (let i = 3; i < data.length; i += 4) { + if (data[i] > 0) nonBlank += 1; + } + results[id] = nonBlank; + } + return results; + }""", + PROBE_IDS, + ) + for symbol_id, non_blank in results.items(): + assert non_blank > 80, f"{symbol_id} painted only {non_blank} pixels" + assert console_errors == [] + + +def test_unknown_id_thrrows_and_failed_loads_are_loud( + page: Page, live_server: str +) -> None: + page.goto(f"{live_server}/", wait_until="domcontentloaded") + error = page.evaluate( + """async () => { + const atlas = await import("/static/js/render/atlas.js"); + await atlas.ready(); + const canvas = document.createElement("canvas"); + canvas.width = 8; + canvas.height = 8; + const ctx = canvas.getContext("2d"); + try { + await atlas.draw(ctx, "definitely-not-a-sprite", 0, 0, 8, 8); + return null; + } catch (error) { + return String(error); + } + }""" + ) + assert error is not None + assert "unknown atlas id: definitely-not-a-sprite" in error + + +def test_alpha_fades_the_sprite(page: Page, live_server: str) -> None: + page.goto(f"{live_server}/", wait_until="domcontentloaded") + max_alpha = page.evaluate( + """async () => { + const atlas = await import("/static/js/render/atlas.js"); + await atlas.ready(); + const canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext("2d"); + await atlas.draw(ctx, "tile-floor", 0, 0, 64, 64, { alpha: 0.4 }); + const data = ctx.getImageData(0, 0, 64, 64).data; + let max = 0; + for (let i = 3; i < data.length; i += 4) max = Math.max(max, data[i]); + return max; + }""" + ) + assert 0 < max_alpha < 200, max_alpha + + +def test_tint_recolours_and_flipx_mirrors(page: Page, live_server: str) -> None: + page.goto(f"{live_server}/", wait_until="domcontentloaded") + result = page.evaluate( + """async () => { + const atlas = await import("/static/js/render/atlas.js"); + await atlas.ready(); + const make = () => { + const canvas = document.createElement("canvas"); + canvas.width = 64; + canvas.height = 64; + return canvas.getContext("2d"); + }; + const read = (ctx) => Array.from(ctx.getImageData(0, 0, 64, 64).data); + const distanceTo = (pixels, r, g, b) => { + let sum = 0; + let count = 0; + for (let i = 0; i < pixels.length; i += 4) { + if (pixels[i + 3] < 200) continue; + sum += Math.abs(pixels[i] - r) + Math.abs(pixels[i + 1] - g); + sum += Math.abs(pixels[i + 2] - b); + count += 1; + } + return count === 0 ? null : sum / count; + }; + const baseCtx = make(); + await atlas.draw(baseCtx, "item-tomato-raw", 0, 0, 64, 64); + const tintCtx = make(); + await atlas.draw(tintCtx, "item-tomato-raw", 0, 0, 64, 64, { tint: "#3d84a8" }); + const before = distanceTo(read(baseCtx), 0x3d, 0x84, 0xa8); + const after = distanceTo(read(tintCtx), 0x3d, 0x84, 0xa8); + const plainCtx = make(); + await atlas.draw(plainCtx, "item-fish-raw", 0, 0, 64, 64); + const plain = read(plainCtx); + const flippedCtx = make(); + await atlas.draw(flippedCtx, "item-fish-raw", 0, 0, 64, 64, { flipX: true }); + const flipped = read(flippedCtx); + let mismatchedMirror = 0; + let mismatchedIdentity = 0; + for (let row = 0; row < 64; row += 1) { + for (let col = 0; col < 32; col += 1) { + const mirror = ((row * 64) + (63 - col)) * 4; + const identity = ((row * 64) + col) * 4; + for (let k = 0; k < 4; k += 1) { + if (flipped[identity + k] !== plain[mirror + k]) { + mismatchedMirror += 1; + break; + } + } + for (let k = 0; k < 4; k += 1) { + if (flipped[identity + k] !== plain[identity + k]) { + mismatchedIdentity += 1; + break; + } + } + } + } + return { before, after, mismatchedMirror, mismatchedIdentity }; + }""" + ) + assert result["before"] is not None and result["after"] is not None + assert result["after"] < result["before"], ( + f"tinting did not move pixels toward the tint: {result}" + ) + # The fish is asymmetric (tail right): the mirrored draw must match + # the mirror of the plain draw far better than the plain draw itself. + assert result["mismatchedMirror"] < 100, result + assert result["mismatchedMirror"] < result["mismatchedIdentity"], result + + +def test_the_atlas_only_requests_its_own_origin( + page: Page, live_server: str, console_errors: list[str] +) -> None: + """A12 in the atlas: the index and all six sprite files, and nothing else.""" + requested: list[str] = [] + page.on("request", lambda request: requested.append(request.url)) + page.goto(f"{live_server}/", wait_until="domcontentloaded") + import_atlas(page) + off_origin = [url for url in requested if not url.startswith(live_server)] + assert off_origin == [], off_origin + assert f"{live_server}/static/assets/index.json" in requested + svg_files = { + url.split("#")[0] + for url in requested + if "/static/assets/" in url and url.split("#")[0].endswith(".svg") + } + assert svg_files == { + f"{live_server}/static/assets/{name}" + for name in ("tiles.svg", "stations.svg", "items.svg", "cooks.svg", "fx.svg", "ui.svg") + }, svg_files + assert console_errors == [] + + +def test_meta_unknown_id_returns_null_before_any_draw(page: Page, live_server: str) -> None: + """`meta` is a read-only probe: it answers for unknown ids without throwing.""" + page.goto(f"{live_server}/", wait_until="domcontentloaded") + meta = page.evaluate( + """async () => { + const atlas = await import("/static/js/render/atlas.js"); + await atlas.ready(); + return atlas.meta("definitely-not-a-sprite"); + }""" + ) + assert meta is None diff --git a/tests/e2e/test_us01_create_lobby.py b/tests/e2e/test_us01_create_lobby.py new file mode 100644 index 0000000..6c76d15 --- /dev/null +++ b/tests/e2e/test_us01_create_lobby.py @@ -0,0 +1,247 @@ +"""US-01 (create an anonymous lobby) e2e: a real browser creates a kitchen. + +The landing screen's *Create lobby* is wired to `POST /api/rooms` (the +session token in the body — minted per tab, PLAN A16) and the +`/ws/{code}` handshake; the room's 5-character Crockford code is what +the screen shows. This suite asserts the create flow end to end — +keyboard-first (tab to the button, Enter), the code's alphabet, the +creator's seat row (the crown, the name), the *Start* button's +disabled-with-reason state, the process-capacity refusal (US-01 +criterion 7: `503 room_full` from a `max_rooms=1` server turns into the +landing's "server is at capacity" message), and the landing's +before-the-wire validation (a short code is rejected without a +connection). The same-origin-only request rule (PLAN A12) is re-asserted +against the new module graph. +""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +import pytest +from playwright.sync_api import expect + +from conftest import VIEWPORT, app_screen, load_app + +if TYPE_CHECKING: + from playwright.sync_api import Browser, Page + +pytestmark = pytest.mark.e2e + +#: The Crockford base32 alphabet (server/codes.py): I, L, O and U are +#: excluded from a minted code. +CODE_PATTERN = re.compile(r"^[0-9A-HJKMNP-TV-Z]{5}$") +#: The token is `crypto.getRandomValues` 24 bytes → base64url (A16). +TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,}$") + + +@pytest.fixture +def console_errors(page: Page) -> list[str]: + """Console errors and uncaught exceptions raised while the page runs (empty = good).""" + errors: list[str] = [] + page.on( + "console", + lambda message: errors.append(f"{message.type}: {message.text}") + if message.type == "error" + else None, + ) + page.on("pageerror", lambda exception: errors.append(f"pageerror: {exception}")) + return errors + + +def _new_page(browser: Browser, url: str) -> Page: + context = browser.new_context(viewport=VIEWPORT) + page = context.new_page() + page._owner_context = context # type: ignore[attr-defined] + return page + + +def _close(page: Page) -> None: + context = getattr(page, "_owner_context", None) + if context is not None: + context.close() + + +def test_create_lobby_via_keyboard_creates_a_room_and_shows_the_code( + browser: Browser, round_server, console_errors: list[str] +) -> None: + """The door of US-01, keyboard-first: tab to *Create lobby*, Enter.""" + url = round_server.url + page = _new_page(browser, url) + try: + load_app(page, url) + expect(page.get_by_role("heading", name="Kitchen lobby")).to_be_visible() + expect(page.get_by_role("button", name="Create lobby")).to_be_visible() + assert app_screen(page) == "landing" + + page.get_by_role("button", name="Create lobby").focus() + page.keyboard.press("Enter") + + # CONNECTING in between, LOBBY with the code on the other side. + expect(page.locator("#room-code")).to_be_visible(timeout=20000) + assert app_screen(page) == "lobby" + code = page.locator("#room-code").inner_text() + assert CODE_PATTERN.match(code), code + + # The creator is seat 1 with the crown, named by the server's + # fallback ("Cook #1" — no name was given). + you = page.locator("#seat-list li[data-you]") + expect(you).to_contain_text("Cook #1") + expect(you.locator(".crown")).to_be_visible() + expect(you.locator(".chip")).to_be_visible() + + # Start: enabled — the room pre-selects the campaign's first + # level and a solo host needs no ready-up (PLAN §6, §7). + assert page.locator("#start-btn").is_enabled() + assert page.locator("#start-reason").is_hidden() + + # Identity: the per-tab token is in sessionStorage (A16), the code + # is remembered for the next visit. + token = page.evaluate("sessionStorage.getItem('uc.token')") + assert isinstance(token, str) and TOKEN_PATTERN.match(token), token + assert page.evaluate("sessionStorage.getItem('uc.lastCode')") == code + assert page.context.cookies() == [] # anonymous by design + assert console_errors == [] + finally: + _close(page) + + +def test_create_lobby_with_a_display_name_shows_it_on_the_seat( + browser: Browser, round_server, console_errors: list[str] +) -> None: + """The name is optional and non-authoritative (PLAN A16): the seam's + `createLobby({name})` puts it on the seat row, on both the creator's + and a joiner's page.""" + url = round_server.url + page_a = _new_page(browser, url) + page_b = _new_page(browser, url) + try: + load_app(page_a, url) + load_app(page_b, url) + page_a.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })") + expect(page_a.locator("#room-code")).to_be_visible(timeout=20000) + code = page_a.locator("#room-code").inner_text() + + page_b.locator("#join-code").press_sequentially(code) + page_b.locator("#join-form").get_by_role("button", name="Join").click() + expect(page_b.locator("#room-code")).to_be_visible(timeout=20000) + + expect(page_a.locator("#seat-list li[data-you]")).to_contain_text("Ada") + expect(page_b.locator("#seat-list")).to_contain_text("Ada") + assert console_errors == [] + finally: + _close(page_a) + _close(page_b) + + +def test_create_lobby_reports_a_full_server( + browser: Browser, full_server, console_errors: list[str] +) -> None: + """US-01 criterion 7: a `max_rooms=1` process — the second create is a + `503 room_full` and the client turns it into the landing's capacity + message (the screen stays on the landing, the first room is untouched).""" + url = full_server.url + page = _new_page(browser, url) + try: + load_app(page, url) + page.get_by_role("button", name="Create lobby").click() + expect(page.locator("#room-code")).to_be_visible(timeout=20000) + first_code = page.locator("#room-code").inner_text() + + page.evaluate("() => window.__undercooked.backToLanding()") + assert app_screen(page) == "landing" + + page.get_by_role("button", name="Create lobby").click() + # The refusal lands on the landing screen (no CONNECTING escape). + expect(page.locator("#status")).to_contain_text("capacity", timeout=20000) + assert app_screen(page) == "landing" + + # The first room is still live: rejoining its code works. + page.locator("#join-code").press_sequentially(first_code) + page.locator("#join-form").get_by_role("button", name="Join").click() + expect(page.locator("#room-code")).to_have_text(first_code, timeout=20000) + assert console_errors == [] + finally: + _close(page) + + +def test_the_landing_rejects_a_short_code_before_connecting( + browser: Browser, round_server, console_errors: list[str] +) -> None: + """Client-side validation: a 3-character code never reaches the server — + the screen stays on the landing and the field refuses a 6th character.""" + url = round_server.url + page = _new_page(browser, url) + try: + load_app(page, url) + page.locator("#join-code").press_sequentially("abc") + page.locator("#join-form").get_by_role("button", name="Join").click() + expect(page.locator("#status")).to_have_text("A room code is 5 characters.") + assert app_screen(page) == "landing" + + page.locator("#join-code").fill("") + page.locator("#join-code").press_sequentially("abcdef") + assert page.locator("#join-code").input_value() == "abcde" + assert console_errors == [] + finally: + _close(page) + + +def test_the_landing_only_requests_its_own_origin( + browser: Browser, round_server, console_errors: list[str] +) -> None: + """PLAN A12 against the task-04 module graph: the page, its two + stylesheets, `main.js` (the composition root) and `app.js`, the + identity/connection modules, the snapshot-buffer, event-queue, + render-loop, canvas-renderer, camera, fx, atlas and HUD modules, + the eight screen modules and nothing else — and every byte + same-origin.""" + url = round_server.url + page = _new_page(browser, url) + requested: list[str] = [] + try: + page.on("request", lambda request: requested.append(request.url)) + load_app(page, url) + page.get_by_role("button", name="Create lobby").click() + expect(page.locator("#room-code")).to_be_visible(timeout=20000) + + off_origin = [u for u in requested if not u.startswith(url)] + assert off_origin == [], off_origin + expected = { + f"{url}/", + f"{url}/static/css/base.css", + f"{url}/static/css/screens.css", + f"{url}/static/js/app.js", + f"{url}/static/js/identity.js", + f"{url}/static/js/input/keyboard.js", + f"{url}/static/js/main-flag.js", + f"{url}/static/js/main.js", + f"{url}/static/js/net/connection.js", + f"{url}/static/js/net/decode.js", + f"{url}/static/js/net/events.js", + f"{url}/static/js/net/snapshots.js", + f"{url}/static/js/render/atlas.js", + f"{url}/static/js/render/camera.js", + f"{url}/static/js/render/canvas.js", + f"{url}/static/js/render/fx.js", + f"{url}/static/js/render/loop.js", + f"{url}/static/js/screens/dom.js", + f"{url}/static/js/screens/landing.js", + f"{url}/static/js/screens/connecting.js", + f"{url}/static/js/screens/lobby.js", + f"{url}/static/js/screens/countdown.js", + f"{url}/static/js/screens/play.js", + f"{url}/static/js/screens/round_end.js", + f"{url}/static/js/screens/error.js", + f"{url}/static/js/shared/enums.js", + f"{url}/static/js/ui/help.js", + f"{url}/static/js/ui/hud.js", + f"{url}/api/rooms", + f"{url}/api/session", + f"{url}/api/levels", + } + assert set(requested) == expected, sorted(set(requested) ^ expected) + assert console_errors == [] + finally: + _close(page) diff --git a/tests/e2e/test_us01_landing_surface.py b/tests/e2e/test_us01_landing_surface.py index 3a9f58b..2c4b771 100644 --- a/tests/e2e/test_us01_landing_surface.py +++ b/tests/e2e/test_us01_landing_surface.py @@ -1,9 +1,13 @@ """US-01 (landing surface) e2e: a real browser loads the shell, cleanly. -Phase 01 has no gameplay, so this suite asserts only what a browser can prove and -`TestClient` cannot: the document parses, the ES module runs without a single -console error, the lobby controls are reachable, and — the point of PLAN A12 — -loading and clicking the page makes requests to *nothing* but this process. +The shell is now the phase-07 screen machine (task 02 replaced phase 01's +placeholder): loading the page runs the module graph without a single +console error, the landing controls are reachable and 44 px, and — the +point of PLAN A12 — loading, creating and joining makes requests to +*nothing* but this process. The create/join flows are asserted here only +as far as the landing surface reaches (the connecting screen, the error +screen for a dead code, the validation); the lobby and round flows are +the US-02/US-03 suites' territory. """ from __future__ import annotations @@ -21,10 +25,9 @@ if TYPE_CHECKING: pytestmark = pytest.mark.e2e -# Strings a player sees, kept in sync with app/static/index.html and -# app/static/js/app.js by hand: changing one must be a deliberate act. +# Strings a player sees, kept in sync with app/static/js/screens/*.js by +# hand: changing one must be a deliberate act. READY = "Pick a kitchen to start." -NOT_IMPLEMENTED = "Not implemented until phase 07." SHORT_CODE = "A room code is 5 characters." TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,}$") @@ -44,7 +47,7 @@ def console_errors(page: Page) -> list[str]: def open_shell(page: Page, live_server: str) -> None: - """Load the lobby and wait for its one module request to settle.""" + """Load the shell and wait for the module graph to settle.""" page.goto(f"{live_server}/", wait_until="networkidle") @@ -73,20 +76,34 @@ def test_the_lobby_is_readable_and_zoomable(page: Page, live_server: str) -> Non assert page.evaluate("document.documentElement.scrollWidth") <= 360 -def test_create_and_join_explain_that_the_phase_is_missing( +def test_create_reaches_the_lobby_and_a_dead_code_reaches_the_error_screen( page: Page, live_server: str, console_errors: list[str] ) -> None: + """The landing surface now reaches the wire (phase 02): create shows the + connecting screen and then the room's code; joining a code no room + answers reaches the error screen with the normalised code — no more + 'not implemented' placeholder.""" open_shell(page, live_server) - status = page.locator("#status") page.get_by_role("button", name="Create lobby").click() - expect(status).to_have_text(NOT_IMPLEMENTED) - + # The connecting screen is transient (a fast server reaches the + # lobby before this can assert it): wait for the room code either + # way, and get back to the landing through the seam (or the + # *Cancel* button, if the screen is still there). + expect(page.locator("#room-code")).to_have_text( + re.compile(r"[0-9A-HJKMNPQRSTVWXYZ]{5}"), timeout=20_000 + ) + cancel = page.get_by_role("button", name="Cancel") + if cancel.count() > 0: + cancel.click() + else: + page.evaluate("() => window.__undercooked.backToLanding()") + expect(page.get_by_role("button", name="Create lobby")).to_be_visible(timeout=10_000) page.locator("#join-code").press_sequentially("abcde") page.get_by_role("button", name="Join").click() - expect(status).to_have_text(NOT_IMPLEMENTED) - # The code is normalised for the player: uppercase, five characters. - assert page.locator("#join-code").input_value() == "ABCDE" + # The error screen names the refusal and the code it tried, normalised. + expect(page.locator("#error-title")).to_have_text("That kitchen is gone") + expect(page.locator("#error-code")).to_contain_text("Room code: ABCDE") assert console_errors == [] @@ -102,17 +119,53 @@ def test_a_short_code_is_rejected_before_any_network_use(page: Page, live_server def test_the_only_requests_the_page_makes_are_ours(page: Page, live_server: str) -> None: - """PLAN A12 in a real browser: no CDN, no web font, no beacon, no analytics.""" + """PLAN A12 in a real browser: no CDN, no web font, no beacon, no analytics. + + The census is the whole module graph (the two stylesheets, `main.js` + — the composition root, — `app.js`, the identity, connection, + snapshot-buffer, event-queue, render-loop, canvas-renderer, camera, + fx, atlas and HUD modules, and the enum modules, the eight screen + modules) plus the three API calls create and the lobby make — and + nothing else.""" requested: list[str] = [] page.on("request", lambda request: requested.append(request.url)) open_shell(page, live_server) page.get_by_role("button", name="Create lobby").click() + expect(page.locator("#room-code")).to_be_visible(timeout=20_000) assert [url for url in requested if not url.startswith(live_server)] == [] - # …and the check above is not vacuous: the shell really did fetch its own assets. - assert sorted(url for url in requested if url != f"{live_server}/") == [ + # …and the check above is not vacuous: the page really did fetch its own assets. + assert sorted({url for url in requested if url != f"{live_server}/"}) == [ + f"{live_server}/api/levels", + f"{live_server}/api/rooms", + f"{live_server}/api/session", f"{live_server}/static/css/base.css", + f"{live_server}/static/css/screens.css", f"{live_server}/static/js/app.js", + f"{live_server}/static/js/identity.js", + f"{live_server}/static/js/input/keyboard.js", + f"{live_server}/static/js/main-flag.js", + f"{live_server}/static/js/main.js", + f"{live_server}/static/js/net/connection.js", + f"{live_server}/static/js/net/decode.js", + f"{live_server}/static/js/net/events.js", + f"{live_server}/static/js/net/snapshots.js", + f"{live_server}/static/js/render/atlas.js", + f"{live_server}/static/js/render/camera.js", + f"{live_server}/static/js/render/canvas.js", + f"{live_server}/static/js/render/fx.js", + f"{live_server}/static/js/render/loop.js", + f"{live_server}/static/js/screens/connecting.js", + f"{live_server}/static/js/screens/countdown.js", + f"{live_server}/static/js/screens/dom.js", + f"{live_server}/static/js/screens/error.js", + f"{live_server}/static/js/screens/landing.js", + f"{live_server}/static/js/screens/lobby.js", + f"{live_server}/static/js/screens/play.js", + f"{live_server}/static/js/screens/round_end.js", + f"{live_server}/static/js/shared/enums.js", + f"{live_server}/static/js/ui/help.js", + f"{live_server}/static/js/ui/hud.js", ] @@ -137,7 +190,11 @@ def test_the_page_can_still_call_its_own_health_endpoint( return {status: response.status, body: await response.json()}; }""" ) - assert result == {"status": 200, "body": {"status": "ok", "version": __version__, "rooms": 0}} + assert result["status"] == 200 + body = result["body"] + assert body["status"] == "ok" + assert body["version"] == __version__ + assert isinstance(body["rooms"], int) and body["rooms"] >= 0 assert console_errors == [] @@ -159,16 +216,32 @@ def test_the_last_code_is_remembered_for_the_next_visit(page: Page, live_server: open_shell(page, live_server) page.locator("#join-code").press_sequentially("k7xyz") page.get_by_role("button", name="Join").click() - expect(page.locator("#status")).to_have_text(NOT_IMPLEMENTED) + # K7XYZ is not a live room: the error screen is where a dead code lands. + expect(page.locator("#error-title")).to_have_text( + "That kitchen is gone", timeout=20_000 + ) open_shell(page, live_server) assert page.evaluate("sessionStorage.getItem('uc.lastCode')") == "K7XYZ" assert page.locator("#join-code").input_value() == "K7XYZ" -def test_the_canvas_stays_hidden_until_the_renderer_exists(page: Page, live_server: str) -> None: - """Phase 07 unhides `#kcanvas`; until then it is out of the layout and a11y tree.""" +def test_the_canvas_is_absent_outside_the_play_screen(page: Page, live_server: str) -> None: + """The canvas lives on the play screen (task 02); off it there is no + `#kcanvas` at all, and on it the canvas mounts hidden and out of the + a11y tree until task 04's renderer sizes and unhides it.""" open_shell(page, live_server) + assert page.locator("#kcanvas").count() == 0 + assert ( + page.evaluate( + """() => { + const app = window.__undercooked; + app.show('playing', {}); + return document.querySelector('#kcanvas'); + }""" + ) + is not None + ) canvas = page.locator("#kcanvas") expect(canvas).to_be_hidden() assert canvas.get_attribute("aria-hidden") == "true" diff --git a/tests/e2e/test_us02_join_by_code.py b/tests/e2e/test_us02_join_by_code.py new file mode 100644 index 0000000..76ffc6e --- /dev/null +++ b/tests/e2e/test_us02_join_by_code.py @@ -0,0 +1,175 @@ +"""US-02 (join by code) e2e: a second browser tab joins the kitchen. + +The join form normalises the typed code (uppercase, five characters — +the confusable mapping `I L O U` is the server's, not the client's), +remembers it in `sessionStorage` (a reload rejoins fast) and opens +`/ws/{code}`; the seat/spectate decision is the server's (the +`docs/PROTOCOL.md` close-code table). This suite covers: two contexts +seeing each other's seat (the phase's "two tabs in one lobby see each +other's seat" criterion), a bad code (unknown code → `room_gone` → the +error screen without a same-code retry), a full room (a fresh token on +a `max_players=2` room → `room_full` → "try another room"), and the +last-code prefill after a reload. All of it keyboard-first. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from playwright.sync_api import expect + +from conftest import VIEWPORT, app_screen, load_app, open_two_pages + +if TYPE_CHECKING: + from playwright.sync_api import Browser, Page + +pytestmark = pytest.mark.e2e + + +def _join_ui(page: Page, code: str) -> None: + """The keyboard-first join: focus the code field, type, Enter (submit).""" + page.locator("#join-code").focus() + page.keyboard.type(code) + page.keyboard.press("Enter") + + +def test_join_by_code_two_contexts_see_each_others_seats( + browser: Browser, round_server +) -> None: + """A creates (name "Ada"), B joins by the typed code; *both* pages + show both seats — A with the crown, B with the server's fallback + name — and B's page shows A's colour chip (the 4-colour palette).""" + url = round_server.url + context_a, page_a, context_b, page_b = open_two_pages(browser, url) + try: + load_app(page_a, url) + load_app(page_b, url) + page_a.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })") + expect(page_a.locator("#room-code")).to_be_visible(timeout=20000) + code = page_a.locator("#room-code").inner_text() + + _join_ui(page_b, code) + expect(page_b.locator("#room-code")).to_have_text(code, timeout=20000) + assert app_screen(page_b) == "lobby" + + # B's page: two seats — Ada (crown) and B's own row (the fallback + # name, the colour chip, marked data-you). + expect(page_b.locator("#seat-list")).to_contain_text("Ada") + you_b = page_b.locator("#seat-list li[data-you]") + expect(you_b).to_contain_text("Cook #2") + expect(you_b.locator(".chip")).to_be_visible() + expect(page_b.locator("#seat-list li:has(.crown)")).to_contain_text("Ada") + + # A's page: B's join is a lobby re-broadcast — the second seat + # row is there too. + expect(page_a.locator("#seat-list")).to_contain_text("Cook #2") + assert page_a.locator("#seat-list li").count() == 2 + finally: + context_a.close() + context_b.close() + + +def test_a_bad_code_is_a_friendly_error_without_retry( + browser: Browser, round_server +) -> None: + """An unminted code (a Crockford-valid `ZZZZZ`): the server answers + `err room_gone` + close `4401` — the error screen's copy names the + situation, offers *no* same-code retry (the room is gone) and the + *Back to landing* button works.""" + url = round_server.url + context = browser.new_context(viewport=VIEWPORT) + page = context.new_page() + try: + load_app(page, url) + _join_ui(page, "ZZZZZ") + expect(page.locator("#error-title")).to_have_text("That kitchen is gone", timeout=20000) + assert app_screen(page) == "error" + # No same-code retry affordance. + assert page.locator("#error-retry").count() == 0 + # …but the code is shown for support, and back-to-landing works. + expect(page.locator("#error-code")).to_contain_text("ZZZZZ") + page.get_by_role("button", name="Back to landing").click() + assert app_screen(page) == "landing" + finally: + context.close() + + +def test_joining_a_full_room_is_a_friendly_error( + browser: Browser, small_server +) -> None: + """A `max_players=2` room with two seats: a third fresh token is a + `room_full` refusal (close `4402`) — the error screen's copy points + at another room, and the two seated tabs are untouched.""" + url = small_server.url + context_a, page_a, context_b, page_b = open_two_pages(browser, url) + context_c = browser.new_context(viewport=VIEWPORT) + page_c = context_c.new_page() + try: + load_app(page_a, url) + load_app(page_b, url) + load_app(page_c, url) + page_a.evaluate("() => window.__undercooked.createLobby()") + expect(page_a.locator("#room-code")).to_be_visible(timeout=20000) + code = page_a.locator("#room-code").inner_text() + _join_ui(page_b, code) + expect(page_b.locator("#room-code")).to_have_text(code, timeout=20000) + + _join_ui(page_c, code) + expect(page_c.locator("#error-title")).to_have_text("That kitchen is full", timeout=20000) + assert page_c.locator("#error-retry").count() == 0 + expect(page_c.locator("#error-detail")).to_contain_text("another") + + # The two seated tabs are still in the lobby, both seats intact. + assert app_screen(page_a) == "lobby" + assert app_screen(page_b) == "lobby" + assert page_a.locator("#seat-list li").count() == 2 + finally: + context_a.close() + context_b.close() + context_c.close() + + +def test_the_last_code_prefills_after_reload_and_normalises( + browser: Browser, round_server +) -> None: + """Typed lowercase, normalised to uppercase on submit, remembered in + `sessionStorage` — a reload pre-fills the join form (the task's + last-code prefill).""" + url = round_server.url + context = browser.new_context(viewport=VIEWPORT) + page = context.new_page() + try: + load_app(page, url) + _join_ui(page, "k7xyz") # → normalised to K7XYZ (an unminted code) + # K7XYZ is not a real room here: the error screen is the proof + # the code left the field. + expect(page.locator("#error-code")).to_contain_text("K7XYZ", timeout=20000) + page.get_by_role("button", name="Back to landing").click() + + load_app(page, url) # the reload + assert page.locator("#join-code").input_value() == "K7XYZ" + assert page.evaluate("sessionStorage.getItem('uc.lastCode')") == "K7XYZ" + finally: + context.close() + + +def test_the_join_form_is_keyboard_operable(browser: Browser, round_server) -> None: + """Tab order into the join form, type with the keyboard, submit with + Enter (no pointer anywhere). `abcde` is an unminted Crockford code, + so the Enter submit ends on the error screen — the proof the frame + left the field through the keyboard alone.""" + url = round_server.url + context = browser.new_context(viewport=VIEWPORT) + page = context.new_page() + try: + load_app(page, url) + page.keyboard.press("Tab") # → the Create button + assert page.evaluate("document.activeElement.id") == "create-lobby" + page.keyboard.press("Tab") # → the code field + assert page.evaluate("document.activeElement.id") == "join-code" + page.keyboard.type("abcde") + page.keyboard.press("Enter") + expect(page.locator("#error-code")).to_contain_text("ABCDE", timeout=20000) + finally: + context.close() diff --git a/tests/e2e/test_us03_ready_and_start.py b/tests/e2e/test_us03_ready_and_start.py new file mode 100644 index 0000000..f0cb306 --- /dev/null +++ b/tests/e2e/test_us03_ready_and_start.py @@ -0,0 +1,406 @@ +"""US-03 (ready up and start) e2e: two tabs ready up, the host starts, +and the round runs to a result screen. + +Two contexts against the `round_server` (the `levels_round` campaign — +the shipped level 1 layout-verbatim, `target_score` 20 / `time_limit` +60 s: one served soup scores 44-66 by `sim/scoring.py`, so the *first* +serve wins the round — the "forced short round" of the task's test +list; a `time_limit: 4` level would not survive the validator's floor). + +The lobby half, over the UI (keyboard-first): both tabs show the ready +state of both seats, the non-host's *Start* is disabled with the named +reason ("Only the host can start"), the host's *Level* picker is fed +by `/api/levels` (difficulty pips, target, time) while the non-host's +is disabled, the host's *Start* is "Waiting for ready cooks" until a +non-host ready-up, enabled after, and the 3-2-1 `countdown` shows on +both tabs before the kitchen (`#kcanvas` host + `#hud` root). + +The round half: the host's tab drives the scripted soup through the +`window.__undercooked` seam (`sendInput` — walk, shelf pick, board +hold, stove hold, prep hold, serve press; the phase-06 +`test_ws_round_two_clients.py` choreography, ports of its waypoints +and staging points verbatim), the round ends `WON` on the serve, and +*both* tabs show the result screen: the result word, the stars, the +score against the target, the session's best, the per-player rows, the +counters, the exits (no *Next level* — the campaign's single level +unlocks nothing on a win) and the `aria-live="polite"` announcement. +""" + +from __future__ import annotations + +import itertools +import re +from typing import TYPE_CHECKING + +import pytest +from playwright.sync_api import expect + +from conftest import app_screen, load_app, open_two_pages + +if TYPE_CHECKING: + from playwright.sync_api import Browser, Page + +pytestmark = pytest.mark.e2e + + +def _join_ui(page: Page, code: str) -> None: + """The keyboard-first join: focus the code field, type, Enter (submit).""" + page.locator("#join-code").focus() + page.keyboard.type(code) + page.keyboard.press("Enter") + + +def _collect_countdown(page: Page, window_s: float) -> list[str]: + """The `#countdown-num` texts seen over the window (one per poll).""" + seen: list[str] = [] + deadline = page.evaluate("performance.now()") + window_s * 1000 + while page.evaluate("performance.now()") < deadline: + page.wait_for_timeout(100) + value = page.evaluate( + "() => {\n" + " const num = document.querySelector('#countdown-num');\n" + " return num === null ? null : num.textContent;\n" + "}" + ) + if value is not None: + seen.append(value) + return seen + + +def _assert_countdown_sequence(name: str, seen: list[str], *, must_start_at_three: bool) -> None: + """The numerals the tab saw: non-increasing, ending at 1, starting at + the 3 (the starter) or the 2 (the late joiner).""" + assert seen, f"{name} saw no countdown numerals" + assert "1" in seen, f"{name} never reached the 1: {seen}" + for earlier, later in itertools.pairwise(seen): + assert int(later) <= int(earlier), f"{name}'s countdown went up: {seen}" + assert seen[-1] == "1", f"{name}'s countdown did not end on the 1: {seen}" + if must_start_at_three: + assert seen[0] == "3", f"{name} missed the 3: {seen}" + + +SOUP_CHOREOGRAPHY = """async () => { + const app = window.__undercooked; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const ownSeat = app.you.seat; + const player = () => Object.values(app.view.players).find((p) => p.pi === ownSeat); + + const walkTo = async (target, band, settle, waitS) => { + const deadline = performance.now() + waitS * 1000; + for (;;) { + const me = player(); + if (me === undefined) throw new Error('the scripted cook vanished from the view'); + const dx = target[0] - me.x; + const dy = target[1] - me.y; + const distance = Math.hypot(dx, dy); + if (distance <= band) { + if (settle) { + app.sendInput(null, false); + await sleep(150); // the settle: the view catches up + const again = player(); + if (again === undefined) throw new Error('the scripted cook vanished (settle)'); + const tail = Math.hypot(target[0] - again.x, target[1] - again.y); + if (tail <= band + 0.2) return; // the deceleration tail + continue; // the settle was not enough: walk again + } + return; // pass the waypoint (a stopped cook never leaves the band) + } + if (performance.now() > deadline) throw new Error('timed out walking'); + const length = distance > 1e-6 ? distance : 1.0; + app.sendInput({ x: dx / length, y: dy / length }, false); + await sleep(50); // ~20 Hz: one frame per tick — a tick with no + // frame brakes the body, so the feed must ride every tick window + // (walk and hold are sequential: the total is 20 Hz + the ping, + // under the 40/s token bucket) + } + }; + + const walk = async (waypoints, band, waitS) => { + for (let i = 0; i < waypoints.length - 1; i += 1) { + await walkTo(waypoints[i], 0.35, false, waitS); + } + await walkTo(waypoints[waypoints.length - 1], band, true, waitS); + }; + + const press = async () => { + app.sendInput(null, true); // the rising edge + await sleep(90); + app.sendInput(null, false); // the release + }; + + // The pick after a hold (or a set-up press), self-healing: a release + // frame dropped by the token bucket sticks the hold, so a press whose + // edge is not a rising edge is a silent no-op. An explicit release + // before each attempt breaks the stuck state; a missed attempt retries + // on a fresh rising edge. + const pick = async (what, short) => { + for (let i = 0; i < 3; i += 1) { + if (carrying(short)) return; // an early pick: no press while in hand + app.sendInput(null, false); + await sleep(250); // spread the attempts: a tight 3-frame cluster is + // exactly what trips the 40/s bucket under load (4404) + await press(); + const deadline = performance.now() + 6000; + while (!carrying(short) && performance.now() < deadline) { + await sleep(50); + } + if (carrying(short)) return; + } + throw new Error('timed out: ' + what); + }; + + const holdUntil = async (predicate, waitS) => { + const deadline = performance.now() + waitS * 1000; + while (!predicate()) { + if (performance.now() > deadline) throw new Error('timed out holding'); + app.sendInput(null, true); + await sleep(50); // ~20 Hz: one frame per tick — a 1-tick gap resets + // the gate's hold (the work freezes); the next frame re-presses and + // re-claims it. With the 20 Hz walk feed (sequential) the total + // stays under the 40/s token bucket + } + }; + + const waitUntil = async (predicate, what, waitS) => { + const deadline = performance.now() + waitS * 1000; + while (!predicate()) { + if (performance.now() > deadline) throw new Error('timed out: ' + what); + await sleep(50); + } + }; + + const stationReady = (kind) => { + const station = Object.values(app.view.stations).find((s) => s.k === kind); + return station !== undefined && station.s === 'ready'; + }; + const carrying = (short) => { + const me = player(); + return me !== undefined && me.h === short; + }; + + // The phase-06 waypoints and staging points, verbatim. + const SHELF_STAGE = [2.5, 1.5]; // facing LEFT at the shelf (1, 1) + const BOARD_STAGE = [3.5, 1.5]; // facing RIGHT at the board (4, 1) + const STOVE_STAGE = [7.5, 1.5]; // facing RIGHT at the stove (8, 1) + const PREP_STAGE = [3.5, 3.5]; // facing LEFT at the prep (2, 3) + const SERVE_STAGE = [8.8, 3.5]; // in front of the window (9, 3): a stop + // west of its centre keeps the window ahead of the facing plane + // (`not_facing`) + + // 1. The tomato, off the spawn shelf (the 3.5 corridor clears the + // blocking fixtures, the body's edge included). Self-healing: a + // dropped press frame is a silent no-op; the shelf is still full + // for a fresh rising edge. + await walk([[3.5, 7.5], [3.5, 1.5], SHELF_STAGE], 0.4, 120); + await pick('pick the tomato', 'tomato_raw'); + + // 2. Chop it (0.9 s of work). + await walk([BOARD_STAGE], 0.4, 90); + await holdUntil(() => stationReady('chop_board'), 60); + await pick('pick the chopped tomato', 'tomato_chopped'); + + // 3. Cook it (6 s of work). The row-2 corridor clears the board and + // the stove; the last leg comes in west-to-east along row 1. + await walk([[3.5, 2.5], [5.5, 2.5], [5.5, 1.5], STOVE_STAGE], 0.4, 120); + await holdUntil(() => stationReady('stove'), 90); + await pick('pick the cooked tomato', 'tomato_cooked'); + + // 4. Plate it (0.6 s of work). The prep's staging point is its east + // neighbour, reached along row 3. + await walk([[7.5, 3.5], PREP_STAGE], 0.4, 90); + await holdUntil(() => stationReady('prep'), 60); + await pick('pick the plated dish', 'tomato_plated'); + + // 5. Serve it (the window is walkable: stand on it, one press — + // retried: a dropped press frame is a silent no-op, the dish + // stays in the hand for a fresh rising edge). + await walk([SERVE_STAGE], 0.4, 90); + for (let i = 0; i < 3; i += 1) { + if (app.screen !== 'playing') break; // the round is over: an `input` + // frame in ROUND_END is a wrong_state (5 close 4404) + app.sendInput(null, false); + await sleep(250); + if (app.screen !== 'playing') break; + await press(); + await sleep(2500); + } + return true; +}""" + + +def test_ready_start_and_countdown_on_both_clients( + browser: Browser, round_server +) -> None: + """Both tabs ready up; the non-host's *Start* is disabled with the + named reason and the host's is "Waiting for ready cooks" until the + ready-up, enabled after. The host's level picker is fed by + `/api/levels` (pips, target, time) and selected; the non-host's + picker is disabled. The 3-2-1 countdown shows on both tabs, then + the kitchen on both.""" + url = round_server.url + context_a, page_a, context_b, page_b = open_two_pages(browser, url) + try: + load_app(page_a, url) + load_app(page_b, url) + page_a.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })") + expect(page_a.locator("#room-code")).to_be_visible(timeout=20_000) + code = page_a.locator("#room-code").inner_text() + + _join_ui(page_b, code) + expect(page_b.locator("#room-code")).to_have_text(code, timeout=20_000) + assert app_screen(page_a) == "lobby" + assert app_screen(page_b) == "lobby" + + # Both tabs see both seats (A's join re-broadcast reached B too — + # the US-02 criterion, re-asserted where the story needs it). + expect(page_b.locator("#seat-list")).to_contain_text("Ada") + expect(page_a.locator("#seat-list")).to_contain_text("Cook #2") + + # The level picker: Ada's is enabled and fed by /api/levels — + # the campaign's level is selected, with its pips/target/time. + assert page_a.locator("#level-picker").is_enabled() + options_a = page_a.locator("#level-picker option") + assert options_a.count() >= 2 # the "Pick a level" placeholder + the level + assert page_a.locator("#level-picker").input_value() == "01_first_kitchen" + expect(options_a.nth(1)).to_contain_text("First Kitchen") + expect(options_a.nth(1)).to_contain_text("20 pts / 60 s") + expect(options_a.nth(1)).to_contain_text("●") # the difficulty pips + # The non-host's picker is disabled (the host-only control). + assert page_b.locator("#level-picker").is_disabled() + expect(page_b.locator("#level-label")).to_have_text("Level (host only)") + + # Start before the ready-up: disabled with the named reasons. + assert page_a.locator("#start-btn").is_disabled() + expect(page_a.locator("#start-reason")).to_have_text("Waiting for ready cooks") + assert page_b.locator("#start-btn").is_disabled() + expect(page_b.locator("#start-reason")).to_have_text("Only the host can start") + + # Cook #2 ready-up (keyboard-first): the toggle label shows the + # state; *both* tabs see the ready badge on the seat row. + page_b.get_by_role("button", name="Ready up").focus() + page_b.keyboard.press("Enter") + expect(page_b.locator("#ready-btn")).to_have_text("Unready") + expect(page_b.locator("#seat-list li[data-you]")).to_contain_text("ready") + expect(page_a.locator("#seat-list li:has(.ready-badge)")).to_contain_text("Cook #2") + + # The host's Start is enabled now (the reason line hides). + assert page_a.locator("#start-btn").is_enabled() + assert page_a.locator("#start-reason").is_hidden() + + # The countdown: the host tab collects its numerals live (the + # 3 s wall-clock countdown, one frame per whole second — the + # *frame identity* on both clients is the phase-06 two-client + # integration suite's assertion; here the e2e proves the host + # tab's 3-2-1 and that the second tab reaches the kitchen). + page_a.get_by_role("button", name="Start").focus() + page_a.keyboard.press("Enter") + countdown_a = _collect_countdown(page_a, 6.0) + _assert_countdown_sequence("Ada", countdown_a, must_start_at_three=True) + + # Both tabs on the kitchen (the snap stream): the canvas host + # present (hidden until the renderer sizes it) and the HUD root. + expect(page_b.locator("#kcanvas")).to_have_count(1, timeout=30_000) + expect(page_a.locator("#kcanvas")).to_have_count(1) + expect(page_b.locator("#hud")).to_have_count(1) + expect(page_a.locator("#hud")).to_have_count(1) + # The canvas is still hidden and out of the a11y tree (task 04). + assert page_a.locator("#kcanvas").get_attribute("aria-hidden") == "true" + + # The round is live on both tabs. + assert app_screen(page_a) == "playing" + assert app_screen(page_b) == "playing" + finally: + context_a.close() + context_b.close() + + +def test_the_result_screen_after_a_forced_round( + browser: Browser, round_server +) -> None: + """The full run: ready up, start, the scripted soup on the host's + tab, the `round` frame, and the result screen on *both* tabs — the + word, the stars, the score/target, the best, the per-player rows, + the counters, the exits, the aria-live announcement.""" + url = round_server.url + context_a, page_a, context_b, page_b = open_two_pages(browser, url) + try: + # The scripted tab drives the wire through the seam, so the + # keyboard module is off for it (`?controls=off`, the documented + # scripted-test mode): its 10 Hz zero-intent keep-alive would + # race the script's frames on the same seat (an `act=false` + # frame breaks the holds, a zero vector brakes the walks). + load_app(page_a, url + "?controls=off") + load_app(page_b, url) + page_a.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })") + expect(page_a.locator("#room-code")).to_be_visible(timeout=20_000) + code = page_a.locator("#room-code").inner_text() + _join_ui(page_b, code) + expect(page_b.locator("#room-code")).to_have_text(code, timeout=20_000) + + page_b.get_by_role("button", name="Ready up").focus() + page_b.keyboard.press("Enter") + expect(page_a.locator("#start-btn")).to_be_enabled(timeout=20_000) + page_a.get_by_role("button", name="Start").focus() + page_a.keyboard.press("Enter") + + # Both tabs on the kitchen before the soup starts. + for page in (page_a, page_b): + expect(page.locator("#kcanvas")).to_have_count(1, timeout=30_000) + assert app_screen(page_a) == "playing" + assert app_screen(page_b) == "playing" + + # The scripted soup (the host's tab; the ~25 Hz hold feed keeps the + # holds continuous under load, the walk feed ~16.7 Hz — the phase-06 + # choreography, holds until the view's stations read `ready`). + # The run outlasts the 30 s default action timeout, so the window + # is raised for the call and restored after (the choreography's + # under-load margins — 90/60 s walks, 60 s stove hold — sum to + # far less than the 600 s window in the worst case). + page_a.set_default_timeout(600_000) + try: + page_a.evaluate(SOUP_CHOREOGRAPHY) + finally: + page_a.set_default_timeout(30_000) + + # The round frame landed on *both* tabs: the result screen. + for page, name in ((page_a, "Ada"), (page_b, "Cook #2")): + expect(page.locator("#result-word")).to_be_visible(timeout=30_000) + assert app_screen(page) == "round_end", name + expect(page.locator("#result-word")).to_have_text("Won") + # Stars: 1-3 (a single soup is 44-66 of the 20 target — the + # ratio's band), labelled for the a11y tree. + expect(page.locator("#stars")).to_have_attribute( + "aria-label", re.compile(r"[1-3] of 3 stars") + ) + # Score against target: a served soup is 44-66 pts of 20. + match = re.fullmatch(r"Score (\d+) of 20", page.locator("#score-line").inner_text()) + assert match is not None, page.locator("#score-line").inner_text() + assert int(match.group(1)) >= 20 + # The per-player rows: both cooks, with a pts/dishes line. + expect(page.locator("#result-players")).to_contain_text("Ada") + expect(page.locator("#result-players")).to_contain_text("Cook #2") + expect(page.locator("#result-players li").first).to_contain_text( + re.compile(r"\d+ pts") + ) + # The counters: one served dish, a streak of one. + counters = page.locator("#counters-line").inner_text() + assert "Served 1" in counters, counters + assert "Longest streak 1" in counters, counters + # No *Next level*: the campaign's single level unlocks nothing + # on a win — only Retry and Back to lobby. + assert page.locator("#next-level-btn").count() == 0 + expect(page.get_by_role("button", name="Retry")).to_be_visible() + expect(page.get_by_role("button", name="Back to lobby")).to_be_visible() + # The aria-live announcement of the outcome. + expect(page.locator("#result-announce")).to_have_text( + re.compile(r"You won \d+ of 20 points with [1-3] stars?") + ) + # The session's best (the `round` frame's record lands for every + # seated player; the fetch resolves it on both tabs). + for page in (page_a, page_b): + expect(page.locator("#best-line")).to_contain_text( + re.compile(r"Best: \d+ pts"), timeout=20_000 + ) + finally: + context_a.close() + context_b.close() diff --git a/tests/e2e/test_us04_move_and_cook.py b/tests/e2e/test_us04_move_and_cook.py new file mode 100644 index 0000000..d9011b7 --- /dev/null +++ b/tests/e2e/test_us04_move_and_cook.py @@ -0,0 +1,1028 @@ +"""Phase 07 task 05 — the flagship playable suite: keyboard input, end to end. + +The first time the whole stack is proven by a single test family: a real +browser, real key events (CDP `Input.dispatchKeyEvent` — trusted, the +`window.__uc.input` module's WASD/arrow + Space/Enter bindings), a real +live server on the `tests/fixtures/levels_us04/` campaign (the shipped +level 1 layout, `target_score` 100 / `time_limit` 120 s — one served +soup scores 44-66, so a single serve does *not* end the round and the +HUD stays observable), and the test-only fixed-seed seam +(`UNDERCOOKED_TEST_MODE=1` → the room is born with `TEST_MODE_SEED` += 20240701, so the page's `?seed=20240701` plays the seed the sim +actually draws — `tests/e2e/README.md`). + +* **`test_moves_and_faces`** — a key press produces the expected + position delta (the server-observed `app.view`, quantized to 2 + decimals), the facing follows the movement (`f` 1 = right, 3 = left), + the facing is sticky after a stop, and a diagonal is normalised on + the client (the wire's magnitude never exceeds 1 — the `stats()` + census reads the exact `1/√2`). +* **`test_stops_at_walls`** — walking into a wall does not pass + through (the body's centre settles at `wall ± 0.35`, the radius) and + does not jitter (repeated reads are equal to the wire's quantization). +* **`test_serves_one_soup`** — the full chain, keyboard-driven: the + tomato off the spawn shelf, chop, pot, cook, plate, serve — the HUD + score bumps and the order ticket disappears. (The campaign's recipe + is kind-based in v1 — `sim/scoring.py`'s `_candidates`: the hand + holds one item, a `tomato_plated` dish composes the `soup`, and the + two-ingredient combine is a later phase's mechanic.) +* **`test_denied_out_of_reach`** — an early press with no station in + reach shows the deny feedback (the FX census's `denied`) and the item + stays (the shelf's stock, the hand, both untouched). +* **`test_two_players_same_target`** — two contexts press at the same + marked set-down simultaneously; exactly one gets the item, the other + sees the `denied` (a same-tick press is `contested_lost` — the + straddle case, where the later tick's press finds an empty fixture, + re-sets the down and retries), and the server's item count is + conserved (in-process `room.sim_state`). +* **`test_alt_tab_parks_the_cook`** — a hidden tab releases every held + key and sends one zero-intent frame with the action released (the + module's `stats()` census), and the server-observed position freezes + (a short coast, then equal reads) — no cook running away. +* **`test_readme_controls_match_keymap`** — the README's "Controls" + block is exactly `getKeyMap()` (the task's completion criterion, + enforced; the in-game help overlay renders the same rows). + +The harness is the phase-07 e2e machine (`conftest.py`): the +`e2e_server` factory (one module-scoped test-mode server for the whole +suite), fresh contexts per test, the `window.__undercooked` app seam +(the `view` — the server-observed state the choreography steers +against) and the `window.__uc` census seam (`input.stats()`, the FX's +census). Steering is real: the helper holds WASD keys via +`page.keyboard.down/up` on a ~60 ms cadence and reads the server's +own view — no `sendInput` shortcuts in this suite. +""" + +from __future__ import annotations + +import math +import re +import time +from itertools import pairwise +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +import pytest + +from conftest import app_screen, open_two_pages + +if TYPE_CHECKING: + from playwright.sync_api import Browser, Page + +pytestmark = pytest.mark.e2e + +REPO_ROOT = Path(__file__).resolve().parents[2] +#: The single-serve campaign (target 100: one soup is 44-66 — the HUD +#: stays observable after the serve). +US04_LEVELS_DIR: Path = REPO_ROOT / "tests" / "fixtures" / "levels_us04" +#: The documented test-mode seed (the `?seed=` value the page loads +#: with; `server/room.py`'s `TEST_MODE_SEED`). +SEED = "20240701" +#: The kitchen geometry (the campaign's grid): the floor spans x 1-11, +#: y 1-9 in tile units, so a body of radius 0.35 stops at 1.35 / 10.65 +#: (x) and 1.35 / 7.65 (y — the row-8 wall; row 7 is the last floor). +WALL_X_MIN, WALL_X_MAX = 1.35, 10.65 +WALL_Y_MIN, WALL_Y_MAX = 1.35, 7.65 +#: The body radius (the `sim`'s 0.35 — the wall band's tolerance). +WALL_BAND = 0.06 + +#: The phase-06 waypoints and staging points, verbatim (the approach +#: leg decides the facing: a cook arrives facing the station). +SHELF_STAGE = (2.5, 1.5) # facing LEFT at the shelf (1, 1) +BOARD_STAGE = (3.5, 1.5) # facing RIGHT at the board (4, 1) +STOVE_STAGE = (7.5, 1.5) # facing RIGHT at the stove (8, 1) +PREP_STAGE = (3.5, 3.5) # facing LEFT at the prep (2, 3) +SERVE_STAGE = (8.8, 3.5) # in front of the window (9, 3): a stop west of its +# centre keeps the window ahead of the facing plane (`not_facing`) + +#: The server-observed "me" (the `app.view` — the raw merged snapshot; +#: `pi` is the seat index, `f` the facing index, `h` the carried +#: item's short form). +ME_PROBE = """() => { + const app = window.__undercooked; + if (app.you === null) return null; + const me = Object.values(app.view.players).find((p) => p.pi === app.you.seat); + return me === undefined ? null : { x: me.x, y: me.y, f: me.f, h: me.h, s: me.s, pi: me.pi }; +}""" + +#: The FX's census (the `denied` marker's consumption). +FX_PROBE = "() => (window.__uc.fx !== null ? window.__uc.fx.stats() : null)" + +#: The module's input census (the held keys, the last frame sent). +INPUT_PROBE = "() => window.__uc.input.stats()" + +#: The `?seed=` seam's page half (the app's own param, echoed once). +SEED_PROBE = "() => window.__undercooked.seed ?? ''" + + +def _seeded_url(url: str) -> str: + return f"{url}/?seed={SEED}" + + +def _join_ui(page: Page, code: str) -> None: + """The keyboard-first join (the us03 machine).""" + page.locator("#join-code").focus() + page.keyboard.type(code) + page.keyboard.press("Enter") + + +def _start_solo(page: Page) -> str: + """A single-player room: create, ready, start (the solo-host start, + PLAN §6); the room code.""" + page.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })") + page.locator("#room-code").wait_for(state="visible", timeout=20_000) + code = page.locator("#room-code").inner_text() + page.get_by_role("button", name="Ready up").focus() + page.keyboard.press("Enter") + page.wait_for_function( + "() => { const b = document.querySelector('#start-btn'); " + "return b !== null && !b.disabled; }", + timeout=20_000, + ) + page.get_by_role("button", name="Start").focus() + page.keyboard.press("Enter") + page.locator("#kcanvas").wait_for(state="attached", timeout=30_000) + assert app_screen(page) == "playing" + page.wait_for_timeout(600) # the first frame + the spawn settle + return code + + +def _start_duo(page_a: Page, page_b: Page) -> str: + """The us03 lobby (both ready, the host starts); the room code.""" + page_a.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })") + page_a.locator("#room-code").wait_for(state="visible", timeout=20_000) + code = page_a.locator("#room-code").inner_text() + _join_ui(page_b, code) + page_b.locator("#room-code").wait_for(state="visible", timeout=20_000) + page_b.get_by_role("button", name="Ready up").focus() + page_b.keyboard.press("Enter") + start_enabled = ( + "() => { const b = document.querySelector('#start-btn'); " + "return b !== null && !b.disabled; }" + ) + page_a.wait_for_function(start_enabled, timeout=20_000) + page_a.get_by_role("button", name="Start").focus() + page_a.keyboard.press("Enter") + page_a.locator("#kcanvas").wait_for(state="attached", timeout=30_000) + page_b.locator("#kcanvas").wait_for(state="attached", timeout=30_000) + assert app_screen(page_a) == "playing" + assert app_screen(page_b) == "playing" + page_a.wait_for_timeout(600) + page_b.wait_for_timeout(600) + return code + + +def _walk(page: Page, waypoints: list[tuple[float, float]], band: float = 0.45, + timeout_s: float = 90.0) -> None: + """Steer the real keyboard to the waypoints (the ~60 ms cadence: + the key set is the dead-reckoning of the server-observed + position — the dead zone (0.15 per axis) is inside the band, so a + stop always lands in it; the deceleration tail is the settle).""" + for index, (tx, ty) in enumerate(waypoints): + target_band = band if index == len(waypoints) - 1 else 0.3 + deadline = time.monotonic() + timeout_s + held: set[str] = set() + while True: + me = page.evaluate(ME_PROBE) + if me is None: + msg = "the cook vanished from the server's view" + raise AssertionError(msg) + dx = tx - me["x"] + dy = ty - me["y"] + want: set[str] = set() + if abs(dx) > 0.15: + want.add("d" if dx > 0 else "a") + if abs(dy) > 0.15: + want.add("s" if dy > 0 else "w") + for key in sorted(held - want): + page.keyboard.up(key) + for key in sorted(want - held): + page.keyboard.down(key) + held = want + if math.hypot(dx, dy) <= target_band: + break + if time.monotonic() > deadline: + msg = f"timed out steering to ({tx}, {ty}) from ({me['x']}, {me['y']})" + raise AssertionError(msg) + page.wait_for_timeout(60) + for key in sorted(held): + page.keyboard.up(key) + page.wait_for_timeout(300) # the deceleration tail (the sim brakes on zero) + + +def _press(page: Page, hold_ms: int = 120) -> None: + """One action-button press (a rising edge: down, a beat, up).""" + page.keyboard.down("Space") + page.wait_for_timeout(hold_ms) + page.keyboard.up("Space") + + +def _face(page: Page, key: str) -> None: + """A short nudge in the `key` direction: it re-sets the sim's + facing deterministically (a walk-band stop can land facing a + diagonal tail — the station's front tile must be the facing one) + and keeps the body inside the stage band (a ~0.3 nudge).""" + page.keyboard.down(key) + page.wait_for_timeout(100) + page.keyboard.up(key) + page.wait_for_timeout(250) # the deceleration tail + the facing settle + + +#: The wire's facing index per nudge key (`f` on the player entry). +FACING_INDEX = {"a": 3, "d": 1, "w": 0, "s": 2} + + +def _aim(page: Page, face_key: str) -> None: + """Re-set the `face_key` facing only when the wire says it is off + (a blind nudge moves the body ~0.36 and can step it across a + cell boundary — off the station's candidate grid, where a press + is a `nothing_here` no-op).""" + me = page.evaluate(ME_PROBE) + if me is None or me["f"] == FACING_INDEX[face_key]: + return + _face(page, face_key) + + +def _sim_item(room: Any, seat: int) -> str | None: + """The item the sim's `seat` carries, as `kind_form` (the wire's + `h` shape), or None (an empty hand). The in-process sim is the + ground truth: the lerp'd view lags the sim ~100 ms and can sit + stale under snapshot backpressure (two browser contexts on one + room), so a view-based wait can pass on a frame the sim has not + applied yet.""" + carrying = room.sim_state.players[seat].carrying + if carrying is None: + return None + return f"{carrying.kind.value}_{carrying.form.value}" + + +def _sim_wait_item( + room: Any, seat: int, expected: str | None, timeout_s: float = 8.0 +) -> None: + """Poll the sim until `seat` carries exactly `expected` (None = + the empty hand) — the ground-truth replacement for the view's + `h` waits.""" + deadline = time.monotonic() + timeout_s + while time.monotonic() < deadline: + if _sim_item(room, seat) == expected: + return + time.sleep(0.02) + msg = f"the sim never carried {expected!r} at seat {seat}" + raise AssertionError(msg) + + +def _sim_settled( + room: Any, + seat: int, + xy: tuple[float, float], + tol: float = 0.45, + timeout_s: float = 20.0, +) -> None: + """Poll the sim until `seat` is inside `tol` of `xy` and *at + rest* (three consecutive samples ~150 ms: a body in flight + passes the band before its deceleration tail stops it — a press + sent while it moves resolves off the candidate grid).""" + deadline = time.monotonic() + timeout_s + still = 0 + while time.monotonic() < deadline: + pos = room.sim_state.players[seat].pos + if math.hypot(pos.x - xy[0], pos.y - xy[1]) <= tol: + still += 1 + if still >= 3: + return + else: + still = 0 + time.sleep(0.05) + pos = room.sim_state.players[seat].pos + msg = f"the sim never settled seat {seat} near {xy} (at {pos.x:.2f}, {pos.y:.2f})" + raise AssertionError(msg) + + +def _pick( + page: Page, room: Any, seat: int, face_key: str, short: str, *, tries: int = 3 +) -> None: + """A press retried until the sim (the ground truth) carries + `short` (a press that lands while the facing is off the station + is a silent no-op — the next nudge re-aims and re-presses).""" + for _ in range(tries): + _aim(page, face_key) + _press(page) + try: + _sim_wait_item(room, seat, short, timeout_s=5.0) + return + except AssertionError: + sim = room.sim_state.players[seat] + denied = [ + (e.payload.get("reason"), e.payload.get("player_id")) + for _, e in room.event_buffer[-12:] + if e.kind.value == "denied" + ] + print( + f"[pick {short}] miss: sim=({sim.pos.x:.2f},{sim.pos.y:.2f})" + f" f={sim.facing.value if hasattr(sim.facing, 'value') else sim.facing}" + f" denied={denied[-3:] if denied else None}" + ) + continue # the press missed — re-aim, re-press + msg = f"the pick of {short!r} never landed after {tries} presses" + raise AssertionError(msg) + + +def _take(page: Page, room: Any, seat: int, short: str, tries: int = 3) -> None: + """The pick after a hold, retried (a follow-up press that lands + off-facing finds the station still `ready` — a second press + picks; the hold's release already happened).""" + for _ in range(tries): + try: + _sim_wait_item(room, seat, short, timeout_s=3.0) + return + except AssertionError: + _press(page) # the station is still `ready` — a fresh press picks + msg = f"never carried {short!r} after {tries} presses" + raise AssertionError(msg) + + +def _set_down( + page: Page, room: Any, seat: int, face_key: str = "a", tries: int = 3 +) -> None: + """A set-down press, retried until the sim (the ground truth) + shows the empty hand (a press that lands off-facing leaves the + item in the hand — the nudge re-aims and the press retries).""" + for _ in range(tries): + _aim(page, face_key) + _press(page) + try: + _sim_wait_item(room, seat, None, timeout_s=5.0) + return + except AssertionError: + continue # the item is still in the hand — re-aim, re-press + me = page.evaluate(ME_PROBE) + denied = [ev for ev in _events(page) if ev.get("k") == "denied"] + msg = ( + f"the set-down never landed after {tries} presses " + f"(me={me}, last_denial={denied[-1] if denied else None})" + ) + raise AssertionError(msg) + + +def _serve(page: Page, timeout_s: float = 45.0) -> int: + """The window press, retried until the HUD's score bumps (a denied + serve keeps the dish in the hand — a re-press is a fresh rising + edge; the denial's named reason rides the event's `r` and is + reported on the final failure).""" + deadline = time.monotonic() + timeout_s + last_denial: dict | None = None + for _ in range(4): + _press(page) + while time.monotonic() < deadline: + score = page.evaluate( + "() => Number(document.querySelector('#hud-score')" + ".textContent || 0)" + ) + if score > 0: + return int(score) + denied = [ev for ev in _events(page) if ev.get("k") == "denied"] + if denied: + last_denial = denied[-1] + page.wait_for_timeout(150) + msg = f"the serve never scored (last denial: {last_denial})" + raise AssertionError(msg) + + +def _station_ready_js(kind: str) -> str: + return ( + f"() => {{ const app = window.__undercooked; " + f"const st = Object.values(app.view.stations).find((s) => s.k === '{kind}'); " + "return st !== undefined && st.s === 'ready'; }" + ) + + +def _hold_until(page: Page, predicate_js: str, timeout_s: float = 90.0) -> None: + """Hold the action (the module's ~48 ms resend keeps the hold + continuous above the 20 Hz tick) until `predicate_js`, then the + release + one press (the pick's rising edge). A hold that has run + 10 s without the predicate is re-pressed (the press may have + landed off-facing — the station never started; the release + + keydown is a fresh rising edge).""" + deadline = time.monotonic() + timeout_s + attempt_deadline = time.monotonic() + 10 + page.keyboard.down("Space") + try: + while not page.evaluate(predicate_js): + if time.monotonic() > deadline: + me = page.evaluate(ME_PROBE) + st = page.evaluate( + "() => Object.values(window.__undercooked.view.stations)" + ".map((s) => s.k + ':' + s.s + ':' + s.g)" + ) + msg = ( + f"timed out holding the action ({predicate_js}) me={me} " + f"stations={st} last_events={_events(page)[-8:]}" + ) + raise AssertionError(msg) + if time.monotonic() > attempt_deadline: + page.keyboard.up("Space") + page.wait_for_timeout(150) + page.keyboard.down("Space") + attempt_deadline = time.monotonic() + 10 + page.wait_for_timeout(40) + finally: + page.keyboard.up("Space") + page.wait_for_timeout(150) # the release frame lands before the press + _press(page) + + +def _wait_carrying(page: Page, short: str, timeout_s: float = 30.0) -> None: + deadline = time.monotonic() + timeout_s + while True: + me = page.evaluate(ME_PROBE) + if me is not None and me["h"] == short: + return + if time.monotonic() > deadline: + msg = f"timed out waiting to carry {short!r} (carrying {me and me['h']!r})" + raise AssertionError(msg) + page.wait_for_timeout(50) + + +def _events(page: Page) -> list[dict]: + """The wire's events as the client saw them (the `snap` frames' + `e` arrays — the encoder's short form: `k`, `i` = the player id, + `r` = the denied reason).""" + return page.evaluate( + """() => { + const out = []; + for (const frame of window.__undercooked.messageLog) { + const e = frame.raw !== undefined ? frame.raw.e : frame.events; + if (Array.isArray(e)) for (const ev of e) out.push(ev); + } + return out; + }""" + ) + + +def _item_count(sim_state: Any) -> int: + """The server's item census (a station's contents + a hand).""" + stations = sim_state.stations + players = sim_state.players + total = sum(len(station.contents) for station in stations) + for player in players: + if getattr(player, "carrying", None) is not None: + total += 1 + return total + + +@pytest.fixture(scope="module") +def us04_server(e2e_server): + """The module's one test-mode server (the fixed-seed seam).""" + live = e2e_server(US04_LEVELS_DIR, test_mode=True) + yield live + live.close() + + +def test_moves_and_faces(browser: Browser, us04_server) -> None: + """Key presses produce the expected position deltas; the facing + follows the movement and is sticky after a stop; a diagonal is + normalised on the client (the census reads the exact 1/√2).""" + url = us04_server.url # type: ignore[attr-defined] + context = browser.new_context() + page = context.new_page() + try: + page.goto(_seeded_url(url), wait_until="networkidle") + _start_solo(page) + + p0 = page.evaluate(ME_PROBE) + assert p0 is not None and abs(p0["x"] - 2.5) < 0.1 and abs(p0["y"] - 7.5) < 0.1, ( + f"the spawn is not at (2.5, 7.5): {p0}" + ) + + # Hold D (right) — mid-hold: the facing is right (1), x is + # moving, y is the row (the 2-decimal quantization's band). + page.keyboard.down("d") + page.wait_for_timeout(500) + mid = page.evaluate(ME_PROBE) + page.keyboard.up("d") + assert mid is not None + assert mid["f"] == 1, f"the facing is not right while moving right: {mid}" + assert mid["x"] > p0["x"] + 0.2, f"no x delta while moving right: {p0} -> {mid}" + assert abs(mid["y"] - 7.5) < 0.1, f"y drifted off the row: {mid}" + + # The stop: the deceleration tail, then equal reads (the + # facing is sticky — still right). + page.wait_for_timeout(400) + p1 = page.evaluate(ME_PROBE) + page.wait_for_timeout(250) + p2 = page.evaluate(ME_PROBE) + page.wait_for_timeout(250) + p3 = page.evaluate(ME_PROBE) + assert p1 is not None and p2 is not None and p3 is not None + assert abs(p2["x"] - p1["x"]) < 0.05 and abs(p3["x"] - p2["x"]) < 0.05, ( + f"the stop jitters: {p1} -> {p2} -> {p3}" + ) + assert p3["f"] == 1, f"the facing was not sticky after the stop: {p3}" + + # A short A (left) hold: the facing turns left (3) and x drops. + before = page.evaluate(ME_PROBE) + page.keyboard.down("a") + page.wait_for_timeout(350) + page.keyboard.up("a") + page.wait_for_timeout(400) + after = page.evaluate(ME_PROBE) + assert before is not None and after is not None + assert after["f"] == 3, f"the facing is not left after the A hold: {after}" + assert 0.05 < (before["x"] - after["x"]) < 3.0, ( + f"the A hold did not move the cook left: {before} -> {after}" + ) + + # A diagonal (D+W): the client normalises — the census's last + # frame is the exact 1/√2 on both axes (the server's clamp must + # never see a diagonal; the magnitude stays ≤ 1). + page.keyboard.down("d") + page.keyboard.down("w") + page.wait_for_timeout(250) + stats = page.evaluate(INPUT_PROBE) + page.keyboard.up("d") + page.keyboard.up("w") + last = stats["last"] + assert abs(last["x"] - math.sqrt(2) / 2) < 1e-9, ( + f"the diagonal's x is not normalised: {last}" + ) + assert abs(last["y"] + math.sqrt(2) / 2) < 1e-9, ( + f"the diagonal's y is not normalised: {last}" + ) + finally: + context.close() + + +def test_stops_at_walls(browser: Browser, us04_server) -> None: + """Walking into a wall does not pass through (the body's centre + settles at `wall - 0.35`) and does not jitter (repeated reads are + equal to the wire's 2-decimal quantization).""" + url = us04_server.url # type: ignore[attr-defined] + context = browser.new_context() + page = context.new_page() + try: + page.goto(_seeded_url(url), wait_until="networkidle") + _start_solo(page) + + # The left wall (from the spawn's x 2.5: a short walk). + page.keyboard.down("a") + page.wait_for_timeout(1500) + page.keyboard.up("a") + page.wait_for_timeout(500) + me = page.evaluate(ME_PROBE) + assert me is not None + assert WALL_X_MIN - WALL_BAND <= me["x"] <= WALL_X_MIN + WALL_BAND, ( + f"the cook is not stopped at the left wall: x={me['x']} (expect {WALL_X_MIN})" + ) + + # The right wall (the long walk: x 1.35 -> 10.65). + page.keyboard.down("d") + page.wait_for_timeout(5000) + page.keyboard.up("d") + page.wait_for_timeout(500) + me = page.evaluate(ME_PROBE) + assert me is not None + assert WALL_X_MAX - WALL_BAND <= me["x"] <= WALL_X_MAX + WALL_BAND, ( + f"the cook is not stopped at the right wall: x={me['x']} (expect {WALL_X_MAX})" + ) + + # The bottom wall (y 7.5 -> 7.65: a short walk). + page.keyboard.down("s") + page.wait_for_timeout(1000) + page.keyboard.up("s") + page.wait_for_timeout(500) + me = page.evaluate(ME_PROBE) + assert me is not None + assert WALL_Y_MAX - WALL_BAND <= me["y"] <= WALL_Y_MAX + WALL_BAND, ( + f"the cook is not stopped at the bottom wall: y={me['y']} (expect {WALL_Y_MAX})" + ) + + # No jitter: three reads, 300 ms apart, are equal (the wire's + # quantization is 2 decimals — a stopped body is a constant). + reads = [page.evaluate(ME_PROBE)] + for _ in range(2): + page.wait_for_timeout(300) + reads.append(page.evaluate(ME_PROBE)) + for first, second in pairwise(reads): + assert first is not None and second is not None + assert abs(first["x"] - second["x"]) <= 0.01 and abs( + first["y"] - second["y"] + ) <= 0.01, f"the stopped body jitters: {first} -> {second}" + finally: + context.close() + + +def test_serves_one_soup(browser: Browser, us04_server) -> None: + """The full chain, keyboard-driven: the tomato off the spawn + shelf, chop, pot, cook, plate, serve — the HUD score bumps and + the order ticket disappears (the `levels_us04` campaign: target + 100, so the serve does not end the round).""" + url = us04_server.url # type: ignore[attr-defined] + app = us04_server.app # type: ignore[attr-defined] + context = browser.new_context() + page = context.new_page() + try: + page.goto(_seeded_url(url), wait_until="networkidle") + code = _start_solo(page) + room = app.state.supervisor.get(code) + assert room is not None, "the room is not in the supervisor" + page.set_default_timeout(60_000) + + # 1. The tomato, off the spawn shelf (the approach from the + # east: facing left at the shelf; the nudge re-aims a + # walk-band stop and the press retries a missed one). + _walk(page, [(3.5, 7.5), (3.5, 1.5), SHELF_STAGE]) + _sim_settled(room, 0, SHELF_STAGE) + _pick(page, room, 0, "a", "tomato_raw") + + # 2. Chop it (the hold: the module's resend keeps it + # continuous; the press after the release is the pick). + _walk(page, [BOARD_STAGE]) + _sim_settled(room, 0, BOARD_STAGE) + _aim(page, "d") + _hold_until(page, _station_ready_js("chop_board")) + _take(page, room, 0, "tomato_chopped") + + # 3. Pot + cook it (6 s of work — the longest hold). + _walk(page, [(3.5, 2.5), (5.5, 2.5), (5.5, 1.5), STOVE_STAGE]) + _sim_settled(room, 0, STOVE_STAGE) + _aim(page, "d") + _hold_until(page, _station_ready_js("stove")) + _take(page, room, 0, "tomato_cooked") + + # 4. Plate it. + _walk(page, [(7.5, 3.5), PREP_STAGE]) + _sim_settled(room, 0, PREP_STAGE) + _aim(page, "a") + _hold_until(page, _station_ready_js("prep")) + _take(page, room, 0, "tomato_plated") + + # 5. Serve it — but wait for the order first (the campaign's + # 16-20 s spawn interval; the serve would be `no_orders` + # without one). + _walk(page, [SERVE_STAGE]) + _sim_settled(room, 0, SERVE_STAGE) + page.wait_for_function( + "() => Object.values(window.__undercooked.view.orders).length >= 1", + timeout=30_000, + ) + ticket = page.locator(".ticket").first + order_id = ticket.get_attribute("data-order-id") + assert order_id is not None and order_id != "" + assert "0" in page.locator("#hud-score").inner_text() + + # The HUD's score bumps (the 44-66 band at a first streak; a + # denied serve is retried — the dish stays in the hand). + score = _serve(page) + assert 20 <= score <= 100, f"the serve's score is outside the band: {score}" + + # The order ticket disappears (the `gone` tombstone's DOM half). + page.locator(f'.ticket[data-order-id="{order_id}"]').wait_for( + state="detached", timeout=15_000 + ) + # The FX's census: the scripted `served` event landed. + fx = page.evaluate(FX_PROBE) + assert fx is not None and fx["processed"].get("served", 0) >= 1, ( + f"no `served` FX: {fx and fx['processed']}" + ) + # The hand is empty (the dish left it at the window) — the + # sim is the ground truth (the view lags ~100 ms). + assert room.sim_state.players[0].carrying is None, ( + "the sim's hand still carries the dish" + ) + finally: + context.close() + + +def test_denied_out_of_reach(browser: Browser, us04_server) -> None: + """An early press with no station in reach shows the deny + feedback (the FX's `denied`) and the item stays (the shelf's + stock, the hand, both untouched).""" + url = us04_server.url # type: ignore[attr-defined] + context = browser.new_context() + page = context.new_page() + try: + page.goto(_seeded_url(url), wait_until="networkidle") + _start_solo(page) + + # The spawn (2.5, 7.5) is 2.24 tiles from the nearest station + # (the sink (3, 5)) — beyond REACH + FORGIVE (1.6): the press + # has nothing to point at. + _press(page) + page.wait_for_timeout(700) + + # The deny feedback: the FX's census shows the `denied`. + fx = page.evaluate(FX_PROBE) + assert fx is not None and fx["processed"].get("denied", 0) >= 1, ( + f"no `denied` FX: {fx and fx['processed']}" + ) + + # The item stays: the hand is empty and the spawn shelf's + # stock is untouched (the wire's `g` — the first content's + # short form). + me = page.evaluate(ME_PROBE) + assert me is not None and me["h"] is None, f"the hand is not empty: {me}" + shelf = page.evaluate( + """() => Object.values(window.__undercooked.view.stations) + .find((s) => s.k === 'spawn_shelf' && s.x === 1 && s.y === 1)""" + ) + assert shelf is not None and shelf["g"] == "tomato_raw", ( + f"the shelf's stock was touched: {shelf}" + ) + finally: + context.close() + + +def _hand_empty_js() -> str: + return ( + "() => { const me = Object.values(window.__undercooked.view.players)" + ".find((p) => p.pi === window.__undercooked.you.seat); " + "return me !== undefined && me.h === null; }" + ) + + +def _contended_attempt( + page_a: Page, page_b: Page, room: Any, attempt: int +) -> dict[str, object]: + """One simultaneous-press attempt (the module's hold keeps both + frames' presses continuous). Returns the census: the item count + (conserved across the attempt), who — if anyone — carried the + item, and the loser's `contested_lost` denial when one landed. + The straddle case (a carried winner, no denial: the winner's frame + landed one tick first, the loser's tick saw an empty fixture — a + silent no-op) is re-set here: the winner puts the item back on the + prep so the next pair can land in one tick.""" + before = _item_count(room.sim_state) + last_tick_before = room.event_buffer[-1][0] if room.event_buffer else 0 + # The sim's ground truth before the press: a press sent while a + # body is still in flight resolves off the candidate grid. + _sim_settled(room, 0, PREP_STAGE) + _sim_settled(room, 1, (3.5, 4.5)) + # The facing check (both arrive from the east; the nudge happens + # only when the wire says the facing is off — a blind nudge can + # step a cook off the prep's candidate grid). + _aim(page_a, "a") + _aim(page_b, "a") + page_a.keyboard.down("Space") + page_b.keyboard.down("Space") + page_a.wait_for_timeout(300) + page_a.keyboard.up("Space") + page_b.keyboard.up("Space") + page_a.wait_for_timeout(800) + after = _item_count(room.sim_state) + assert before == after, ( + f"the item count is not conserved (attempt {attempt}): {before} -> {after}" + ) + sim_a = room.sim_state.players[0] + sim_b = room.sim_state.players[1] + me_a = page_a.evaluate(ME_PROBE) + me_b = page_b.evaluate(ME_PROBE) + assert me_a is not None and me_b is not None + # The sim is the ground truth for the winner (the view lags ~100 + # ms and a stale `h` would flip the loser's side). + winner: int | None = None + if sim_a.carrying is not None: + winner = me_a["pi"] + elif sim_b.carrying is not None: + winner = me_b["pi"] + denied = None + if winner is not None: + loser_token = ( + page_b if winner == me_a["pi"] else page_a + ).evaluate( + "() => Object.values(window.__undercooked.view.players)" + ".find((p) => p.pi === window.__undercooked.you.seat).i" + ) + # The server-side buffer (the client queue TTL-expires ~1 s + # after the tick the event left it): a `contested_lost` + # denial attached to the loser, after this attempt's start. + denied = next( + ( + ev + for tick, ev in room.event_buffer + if tick > last_tick_before + and ev.kind.value == "denied" + and ev.payload.get("reason") == "contested_lost" + and ev.payload.get("player_id") == loser_token + ), + None, + ) + if denied is None: # the straddle case — the winner re-sets the down + assert winner is not None + winner_page = page_a if winner == me_a["pi"] else page_b + winner_page.wait_for_timeout(300) + _set_down(winner_page, room, winner) # back on the prep + return {"winner": winner, "denied": denied} + + +def test_two_players_same_target(browser: Browser, us04_server) -> None: + """Two contexts press at the same marked set-down simultaneously: + exactly one gets the item, the other sees the `denied` (a + same-tick press is `contested_lost`), and the server's item count + is conserved (the in-process `room.sim_state`). Up to six + attempts: a straddle pair consumes the item without a `denied` — + the winner re-sets the down (the marked object is back on the + prep) and the next pair retries.""" + url = us04_server.url # type: ignore[attr-defined] + app = us04_server.app # type: ignore[attr-defined] + context_a, page_a, context_b, page_b = open_two_pages(browser, url) + try: + page_a.goto(_seeded_url(url), wait_until="networkidle") + page_b.goto(_seeded_url(url), wait_until="networkidle") + code = _start_duo(page_a, page_b) + page_a.set_default_timeout(90_000) + page_b.set_default_timeout(90_000) + + # The in-process room (the item census + the seed's server half). + room = app.state.supervisor.get(code) + assert room is not None, "the room is not in the supervisor" + + # A (spawn (2.5, 7.5)): the tomato, then a marked set-down on + # the prep (a raw set-down is a marked object, no work). + _walk(page_a, [(3.5, 7.5), (3.5, 1.5), SHELF_STAGE], timeout_s=120) + _sim_settled(room, 0, SHELF_STAGE) + _pick(page_a, room, 0, "a", "tomato_raw") + _walk( + page_a, + [(5.5, 2.5), (5.5, 3.5), PREP_STAGE], + timeout_s=120, + ) + _sim_settled(room, 0, PREP_STAGE) + _set_down(page_a, room, 0) # the set-down (the marked raw on the prep) + # B (spawn (9.5, 7.5)): the staging diagonal (facing left at + # (3.5, 4.5): the front-diagonal covers the prep (2, 3)). + _walk(page_b, [(9.5, 4.5), (3.5, 4.5)], timeout_s=120) + _sim_settled(room, 1, (3.5, 4.5)) + # The simultaneous press (up to six attempts — the docstring). + denied = None + winner_seat: int | None = None + for attempt in range(1, 7): + outcome = _contended_attempt(page_a, page_b, room, attempt) + winner_seat = cast(int | None, outcome["winner"]) + denied = outcome["denied"] + if winner_seat is None: + page_a.wait_for_timeout(500) # neither carried — retry the pair + elif denied is not None: + break # a carried winner with a `contested_lost` denial + assert winner_seat is not None, ( + "neither cook ever carried the item " + f"(A={page_a.evaluate(ME_PROBE)}, B={page_b.evaluate(ME_PROBE)}, " + "station_items=" + f"{[(s.kind.value, len(s.contents)) for s in room.sim_state.stations if s.contents]}, " + "hands=" + f"{[None if p.carrying is None else p.carrying.kind.value + for p in room.sim_state.players]}, " + f"marks={len(room.sim_state.item_marks)}, " + f"events={_events(page_a)[-12:]})" + ) + assert denied is not None, ( + "no `denied` (contested_lost) for the loser after three simultaneous presses" + ) + # Exactly one carries (the other's hand is empty) — the sim + # is the ground truth (the view lags ~100 ms). + a_holds = room.sim_state.players[0].carrying is not None + b_holds = room.sim_state.players[1].carrying is not None + assert a_holds != b_holds, ( + f"exactly one should carry: A={a_holds!r}, B={b_holds!r}" + ) + finally: + context_a.close() + context_b.close() + + +def test_alt_tab_parks_the_cook(browser: Browser, us04_server) -> None: + """A hidden tab releases every held key and sends one zero-intent + frame with the action released (the module's `stats()` census — + the last frame is {0, 0, false}, nothing held), and the + server-observed position freezes (a short coast, then equal + reads) — no cook running away when you alt-tab.""" + url = us04_server.url # type: ignore[attr-defined] + context = browser.new_context() + page = context.new_page() + try: + page.goto(_seeded_url(url), wait_until="networkidle") + _start_solo(page) + + # Hold D (right) and let the cook walk. + page.keyboard.down("d") + page.wait_for_timeout(700) + moving = page.evaluate(ME_PROBE) + assert moving is not None and moving["x"] > 2.5, ( + f"the cook was not walking before the tab hid: {moving}" + ) + + # The alt-tab: `document.hidden` forced (the us04 + # `HIDDEN_PAUSE_PROBE` trick) — the module's `visibilitychange` + # branch clears the held keys and sends the forced zero. + page.evaluate( + """() => { + Object.defineProperty(document, 'hidden', { value: true, configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }""" + ) + page.wait_for_timeout(400) + + # The module's census: the zero-intent frame, nothing held. + stats = page.evaluate(INPUT_PROBE) + assert stats["last"] == {"x": 0, "y": 0, "act": False}, ( + f"the hidden tab did not send the forced zero: {stats['last']}" + ) + assert not any(stats["held"].values()), f"keys are still held: {stats['held']}" + assert stats["actionHeld"] is False + + # The server-observed freeze: a short coast (the deceleration), + # then equal reads (the 2-decimal quantization's band). + page.wait_for_timeout(500) + p1 = page.evaluate(ME_PROBE) + page.wait_for_timeout(350) + p2 = page.evaluate(ME_PROBE) + page.wait_for_timeout(350) + p3 = page.evaluate(ME_PROBE) + assert p1 is not None and p2 is not None and p3 is not None + for first, second in ((p1, p2), (p2, p3)): + assert abs(first["x"] - second["x"]) <= 0.01 and abs( + first["y"] - second["y"] + ) <= 0.01, f"the hidden cook is still moving: {first} -> {second}" + assert p3["x"] >= moving["x"], ( + "the cook moved backwards after the park " + f"(a coast stop, not a reversal): {moving} -> {p3}" + ) + + # The cleanup: the key up (the module's `keyup` clears — it + # does), the tab back (a `visibilitychange` the module ignores + # when visible). + page.keyboard.up("d") + page.evaluate( + """() => { + Object.defineProperty(document, 'hidden', { value: false, configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + }""" + ) + finally: + context.close() + + +def test_readme_controls_match_keymap(browser: Browser, us04_server) -> None: + """The README's "Controls" block is exactly `getKeyMap()` (the + task's completion criterion — the in-game help overlay renders the + same rows, so the README, the help and the module are one truth).""" + url = us04_server.url # type: ignore[attr-defined] + context = browser.new_context() + page = context.new_page() + try: + page.goto(url, wait_until="networkidle") + keymap = page.evaluate( + "() => window.__uc.input" + ".getKeyMap().map((row) => ({ keys: row.keys.join(' / '), action: row.action }))" + ) + text = (REPO_ROOT / "README.md").read_text(encoding="utf-8") + match = re.search(r"### Controls\n(.*?)\n###? ", text, re.DOTALL) + assert match is not None, "the README's `### Controls` block is missing" + rows = [ + parsed.groups() + for line in match.group(1).splitlines() + if (parsed := re.match(r"^- \*\*(.+?)\*\* — (.+)$", line.strip())) + ] + assert len(rows) == len(keymap), ( + f"the README has {len(rows)} control rows, the key map has {len(keymap)}" + ) + for (keys_md, action_md), row in zip(rows, keymap, strict=True): + assert keys_md == row["keys"], ( + f"the README's keys {keys_md!r} != the key map's {row['keys']!r}" + ) + assert action_md == row["action"], ( + f"the README's action {action_md!r} != the key map's {row['action']!r}" + ) + # The in-game help renders the same rows (the overlay's + # `getKeyMap()` — the module re-exports the help's map). + help_rows = page.evaluate( + "() => import('/static/js/ui/help.js').then((m) => " + "m.getKeyMap().map((row) => ({ keys: row.keys.join(' / '), action: row.action })))" + ) + assert help_rows == keymap, "the help's key map diverged from the module's" + finally: + context.close() + + +def test_seed_param_matches_the_test_mode_server(browser: Browser, us04_server) -> None: + """The `?seed=` seam's whole point: the page's `?seed=20240701` + is the seed the test-mode room actually draws (the in-process + `room._seed_cursor` — the supervisor's `test_mode` branch), and + the app echoes it (the `seed` field).""" + url = us04_server.url # type: ignore[attr-defined] + app = us04_server.app # type: ignore[attr-defined] + context = browser.new_context() + page = context.new_page() + try: + page.goto(_seeded_url(url), wait_until="networkidle") + page.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })") + page.locator("#room-code").wait_for(state="visible", timeout=20_000) + code = page.locator("#room-code").inner_text() + room = app.state.supervisor.get(code) + assert room is not None + assert room._seed_cursor == 20240701, ( + f"the test-mode room's base seed is not the documented constant: {room._seed_cursor}" + ) + seed = page.evaluate(SEED_PROBE) + assert seed == SEED, f"the app did not echo the ?seed= param: {seed!r}" + finally: + context.close() diff --git a/tests/e2e/test_us04_render_smoke.py b/tests/e2e/test_us04_render_smoke.py new file mode 100644 index 0000000..59a317f --- /dev/null +++ b/tests/e2e/test_us04_render_smoke.py @@ -0,0 +1,724 @@ +"""Phase 07 task 04 — the canvas renderer, camera, FX and HUD, end to end. + +The task's e2e list, one suite: + +* **the canvas is not blank** — a pixel sample of the mounted + `#kcanvas` (once the renderer has sized and unhid it) has a + non-background fraction and colour variance, and **changes after a + move** (a signature diff after the host's cook walks); +* **`renderStats().drawCalls` within a sane band** — the per-frame + draw count is neither zero (nothing drawn) nor a per-frame + explosion (the task's < 4 000 ceiling); +* **frame time ≤ 16 ms p95 over the scripted round** — the + renderer's self-measured `frameMsP95` (the `window.__uc.renderStats` + census) after the full ~25 s soup run (well past the 10 s budget + window); +* **the HUD exists and updates** — the readouts are in the DOM (the + task's a11y half), the order ticket appears when the order spawns + (the campaign's 16-20 s spawn interval) with its points and the + deadline bar, the **score text changes within 200 ms of the serve** + (measured in-page from the serve press; the `levels_us04` campaign — + target 100, 120 s — is a single-serve round that does *not* end on + the serve, so the HUD update is observable instead of being raced + out by the result screen), the **timer decreases**, and the FX's + census shows the scripted `chopped` / `served` events landed; +* **`document.hidden` pauses the loop** — the loop's frame count + stops advancing while `hidden` is forced, and resumes on the + `visibilitychange` back to visible; +* **reduced-motion mode** — `?motion=0` sets `data-motion="reduced"`, + the FX reports reduced, a `denied` event produces the static marker + but **no shake** (`shakeApplied` stays 0) and **no particles**; an + offscreen renderer draw of a `fire` hazard under reduced motion + still shows the marker's pixels (the static fallback's "hazard + markers still visible" half — the campaign itself has no hazards); +* **the mapping walk** — every (station kind * station state) and + (item kind * item form) pair maps, through the module's + `stationSpriteId` / `itemSpriteId`, to an atlas id that resolves + (the task's completion criterion; the unit + `test_client_sprite_map.py` keeps the item forms equal to the sim's + fixpoint). + +The harness is the phase-07 e2e machine (`conftest.py`): a live +uvicorn on an ephemeral loopback, two fresh contexts for the +multi-player tests (the `round_server` / `e2e_server` fixtures), the +`window.__undercooked` app seam (the `us03`/`us06` choreography — the +same waypoints and holds), and the `window.__uc` census seam (task 03's +loop; task 04's `renderStats` / `runtime` / `fx` fields). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from conftest import app_screen, load_app, open_two_pages + +if TYPE_CHECKING: + from playwright.sync_api import Browser, Page + +pytestmark = pytest.mark.e2e + +REPO_ROOT = Path(__file__).resolve().parents[2] +#: The single-serve round that does not end on the serve (target 100: +#: one soup is 44-66 — the HUD's score update is observable). +US04_LEVELS_DIR: Path = REPO_ROOT / "tests" / "fixtures" / "levels_us04" + + +def _join_ui(page: Page, code: str) -> None: + """The keyboard-first join (the us03 machine).""" + page.locator("#join-code").focus() + page.keyboard.type(code) + page.keyboard.press("Enter") + + +def _start_solo(page: Page) -> None: + """A single-player room: create, ready, start (the solo-host start, + PLAN §6).""" + page.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })") + page.locator("#room-code").wait_for(state="visible", timeout=20_000) + page.get_by_role("button", name="Ready up").focus() + page.keyboard.press("Enter") + page.wait_for_function( + "() => { const b = document.querySelector('#start-btn'); " + "return b !== null && !b.disabled; }", + timeout=20_000, + ) + page.get_by_role("button", name="Start").focus() + page.keyboard.press("Enter") + page.locator("#kcanvas").wait_for(state="attached", timeout=30_000) + assert app_screen(page) == "playing" + + +def _start_duo(page_a: Page, page_b: Page) -> None: + """The us03 lobby: both ready up, the host starts.""" + page_a.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })") + page_a.locator("#room-code").wait_for(state="visible", timeout=20_000) + code = page_a.locator("#room-code").inner_text() + _join_ui(page_b, code) + page_b.locator("#room-code").wait_for(state="visible", timeout=20_000) + page_b.get_by_role("button", name="Ready up").focus() + page_b.keyboard.press("Enter") + start_enabled = ( + "() => { const b = document.querySelector('#start-btn'); " + "return b !== null && !b.disabled; }" + ) + page_a.wait_for_function(start_enabled, timeout=20_000) + page_a.get_by_role("button", name="Start").focus() + page_a.keyboard.press("Enter") + page_a.locator("#kcanvas").wait_for(state="attached", timeout=30_000) + page_b.locator("#kcanvas").wait_for(state="attached", timeout=30_000) + assert app_screen(page_a) == "playing" + assert app_screen(page_b) == "playing" + + +#: A canvas pixel census (the non-blank check + the signature). The +#: `blocks` signature is a 16-by-9 grid of mean luminance (the after-a- +#: move diff's input); `hash` a 32-bit fold of the buffer (the +#: non-identical-across-time check is not asserted — an idle kitchen +#: is a still image; the move is the temporal check). +PIXEL_CENSUS = """() => { + const canvas = document.getElementById('kcanvas'); + const w = canvas.width, h = canvas.height; + const data = canvas.getContext('2d').getImageData(0, 0, w, h).data; + let nonBackground = 0; + const colors = new Set(); + for (let i = 0; i < data.length; i += 4) { + const a = data[i + 3]; + const sum = data[i] + data[i + 1] + data[i + 2]; + if (a > 8 && sum > 30) nonBackground += 1; + if (a > 8) colors.add(((sum / 3) | 0) & 15); + } + const COLS = 16, ROWS = 9; + const blocks = []; + const bw = Math.max(1, (w / COLS) | 0); + const bh = Math.max(1, (h / ROWS) | 0); + for (let r = 0; r < ROWS; r += 1) { + for (let c = 0; c < COLS; c += 1) { + let sum = 0, n = 0; + for (let y = r * bh; y < Math.min(h, (r + 1) * bh); y += 4) { + for (let x = c * bw; x < Math.min(w, (c + 1) * bw); x += 4) { + const i = (y * w + x) * 4; + sum += (data[i] + data[i + 1] + data[i + 2]) / 3; + n += 1; + } + } + blocks.push(n > 0 ? (sum / n / 128).toFixed(3) : '0.000'); + } + } + return { + w, h, + nonBackgroundFraction: nonBackground / (w * h), + distinctColors: colors.size, + blocks, + }; +}""" + +#: The `document.hidden` pause probe (the loop's frame count must stop +#: advancing while `hidden` is forced, then resume). +HIDDEN_PAUSE_PROBE = """async () => { + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const frames = () => window.__uc.loop.frameStats().frames; + await sleep(300); // the loop is live and rendering + const f0 = frames(); + await sleep(300); + const f1 = frames(); + if (f1 <= f0) return { error: 'the loop was not advancing before the pause' }; + Object.defineProperty(document, 'hidden', { value: true, configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + await sleep(300); + const f2 = frames(); + await sleep(400); + const f3 = frames(); + Object.defineProperty(document, 'hidden', { value: false, configurable: true }); + document.dispatchEvent(new Event('visibilitychange')); + await sleep(500); + const f4 = frames(); + return { f0, f1, f2, f3, f4, hidden: window.__uc.loop.frameStats().hidden }; +}""" + +#: The mapping walk (the task's completion criterion, in the browser — +#: the real mapping modules, the real atlas): every (station kind * +#: state) and (item kind * form) pair must map to an atlas id that +#: resolves. +MAPPING_WALK = """async () => { + const canvasMod = await import('/static/js/render/canvas.js'); + const enums = await import('/static/js/shared/enums.js'); + const atlas = await import('/static/js/render/atlas.js'); + await atlas.ready(); + const missing = []; + let checked = 0; + for (const kind of enums.STATION_KIND) { + for (const state of enums.STATION_STATE) { + checked += 1; + const id = canvasMod.stationSpriteId(kind, state); + if (atlas.meta(id) === null) missing.push(id); + } + } + for (const kind of enums.ITEM_KIND) { + for (const form of enums.ITEM_FORM) { + checked += 1; + const id = canvasMod.itemSpriteId(kind, form); + if (atlas.meta(id) === null) missing.push(id); + } + } + return { checked, missing }; +}""" + +#: The soup with task 04's instrumentation (the us03 choreography — the +#: same waypoints and holds; the additions: the `__uc_perf` budget +#: sample before the serve walk, the timer's two reads, the HUD score +#: delay measured from the serve press, the FX census). +SOUP_CHOREOGRAPHY = """async () => { + const app = window.__undercooked; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const ownSeat = app.you.seat; + const player = () => Object.values(app.view.players).find((p) => p.pi === ownSeat); + + const walkTo = async (target, band, settle, waitS) => { + const deadline = performance.now() + waitS * 1000; + for (;;) { + const me = player(); + if (me === undefined) throw new Error('the scripted cook vanished from the view'); + const dx = target[0] - me.x; + const dy = target[1] - me.y; + const distance = Math.hypot(dx, dy); + if (distance <= band) { + if (settle) { + app.sendInput(null, false); + await sleep(150); // the settle: the view catches up + const again = player(); + if (again === undefined) throw new Error('the scripted cook vanished (settle)'); + const tail = Math.hypot(target[0] - again.x, target[1] - again.y); + if (tail <= band + 0.2) return; // the deceleration tail + continue; // the settle was not enough: walk again + } + return; // pass the waypoint (a stopped cook never leaves the band) + } + if (performance.now() > deadline) throw new Error('timed out walking'); + const length = distance > 1e-6 ? distance : 1.0; + app.sendInput({ x: dx / length, y: dy / length }, false); + await sleep(50); // ~20 Hz: one frame per tick — a tick with no + // frame brakes the body, so the feed must ride every tick window + // (walk and hold are sequential: the total is 20 Hz + the ping, + // under the 40/s token bucket) + } + }; + + const walk = async (waypoints, band, waitS) => { + for (let i = 0; i < waypoints.length - 1; i += 1) { + await walkTo(waypoints[i], 0.35, false, waitS); + } + await walkTo(waypoints[waypoints.length - 1], band, true, waitS); + }; + + const press = async () => { + app.sendInput(null, true); // the rising edge + await sleep(90); + app.sendInput(null, false); // the release + }; + + // The pick after a hold (or a set-up press), self-healing: an + // explicit release before each attempt breaks a stuck hold; a missed + // attempt retries on a fresh rising edge. The 250 ms spread keeps a + // tight 3-frame cluster off the 40/s token bucket. + const pick = async (what, short) => { + for (let i = 0; i < 3; i += 1) { + if (carrying(short)) return; // an early pick: no press while in hand + app.sendInput(null, false); + await sleep(250); + await press(); + const deadline = performance.now() + 6000; + while (!carrying(short) && performance.now() < deadline) { + await sleep(50); + } + if (carrying(short)) return; + } + throw new Error('timed out: ' + what); + }; + + const holdUntil = async (predicate, waitS) => { + const deadline = performance.now() + waitS * 1000; + while (!predicate()) { + if (performance.now() > deadline) throw new Error('timed out holding'); + app.sendInput(null, true); + await sleep(50); // ~20 Hz: one frame per tick — a 1-tick gap resets + // the gate's hold (the work freezes); the next frame re-presses + // and re-claims it (20 Hz + the walk feed, under the 40/s bucket) + } + }; + + const waitUntil = async (predicate, what, waitS) => { + const deadline = performance.now() + waitS * 1000; + while (!predicate()) { + if (performance.now() > deadline) throw new Error('timed out: ' + what); + await sleep(50); + } + }; + + const stationReady = (kind) => { + const station = Object.values(app.view.stations).find((s) => s.k === kind); + return station !== undefined && station.s === 'ready'; + }; + const carrying = (short) => { + const me = player(); + return me !== undefined && me.h === short; + }; + + // The phase-06 waypoints and staging points, verbatim. + const SHELF_STAGE = [2.5, 1.5]; // facing LEFT at the shelf (1, 1) + const BOARD_STAGE = [3.5, 1.5]; // facing RIGHT at the board (4, 1) + const STOVE_STAGE = [7.5, 1.5]; // facing RIGHT at the stove (8, 1) + const PREP_STAGE = [3.5, 3.5]; // facing LEFT at the prep (2, 3) + const SERVE_STAGE = [8.8, 3.5]; // in front of the window (9, 3): a stop + // west of its centre keeps the window ahead of the facing plane + // (`not_facing`) + + // 1. The tomato, off the spawn shelf. Self-healing: a dropped press + // frame is a silent no-op; the shelf is still full for a fresh + // rising edge. + await walk([[3.5, 7.5], [3.5, 1.5], SHELF_STAGE], 0.4, 120); + await pick('pick the tomato', 'tomato_raw'); + + // 2. Chop it (0.9 s of work). + await walk([BOARD_STAGE], 0.4, 90); + await holdUntil(() => stationReady('chop_board'), 60); + await pick('pick the chopped tomato', 'tomato_chopped'); + + // 3. Cook it (6 s of work). + await walk([[3.5, 2.5], [5.5, 2.5], [5.5, 1.5], STOVE_STAGE], 0.4, 120); + await holdUntil(() => stationReady('stove'), 90); + await pick('pick the cooked tomato', 'tomato_cooked'); + + // 4. Plate it (0.6 s of work). + await walk([[7.5, 3.5], PREP_STAGE], 0.4, 90); + await holdUntil(() => stationReady('prep'), 60); + await pick('pick the plated dish', 'tomato_plated'); + + // 5. The budget sample (the run has well outlasted the 10 s window) + // and the timer's two reads (2.5 s apart). + window.__uc_perf = window.__uc.renderStats(); + const timerEl = document.querySelector('#hud-timer'); + if (timerEl === null) throw new Error('the HUD timer is not in the DOM'); + const timer1 = Number(timerEl.textContent); + await sleep(2500); + const timer2 = Number(document.querySelector('#hud-timer').textContent); + window.__uc_timer = [timer1, timer2]; + + // 6. Serve it — and measure the HUD's score change from the press + // (the campaign's target is 100: this serve does not end the + // round, so the update is observable within the 200 ms window). + await walk([SERVE_STAGE], 0.4, 90); + // Self-healing serve: a dropped press frame (or a `not_facing` stop) + // is a silent no-op; the dish stays in the hand for a fresh rising + // edge. `servedAt` marks the first real serve (the measurement + // window's origin). + let servedAt = -1; + for (let i = 0; i < 3; i += 1) { + app.sendInput(null, false); + await sleep(250); + app.sendInput(null, true); // the rising edge + await sleep(90); + app.sendInput(null, false); // the release + // The server-event time: when the `served` FX first lands (the task's + // "200 ms of the server event" — the press->event latency is excluded; + // the `served` and `score` events land in the same snapshot, so this is + // the `score` event's landing to within one frame). + const eventDeadline = performance.now() + 3000; + while (performance.now() < eventDeadline) { + const fx = window.__uc.fx !== null ? window.__uc.fx.stats() : null; + if (fx !== null && (fx.processed.served || 0) >= 1) { servedAt = performance.now(); break; } + await sleep(15); + } + if (servedAt >= 0) break; + } + if (servedAt < 0) throw new Error('the serve never landed (3 attempts)'); + let scoreDelay = -1; + { + const deadline = performance.now() + 300; + while (performance.now() < deadline) { + const el = document.querySelector('#hud-score'); + if (el !== null && Number(el.textContent) > 0) { + scoreDelay = performance.now() - servedAt; + break; + } + await sleep(15); + } + } + window.__uc_score_delay = scoreDelay; + window.__uc_fx = window.__uc.fx !== null ? window.__uc.fx.stats() : null; + return true; +}""" + +#: The reduced-motion offscreen renderer smoke (the campaign has no +#: hazards, so the "hazard markers still visible" half is proven on an +#: offscreen canvas: a synthetic frame with a `fire` hazard, drawn +#: under `data-motion="reduced"`, must still show the marker's +#: pixels — the static fallback — while a `denied` produces no shake +#: and a `score` launches particles on the normal-motion canvas). +REDUCED_MOTION_SMOKE = """async () => { + const canvasMod = await import('/static/js/render/canvas.js'); + const cameraMod = await import('/static/js/render/camera.js'); + const fxMod = await import('/static/js/render/fx.js'); + const atlas = await import('/static/js/render/atlas.js'); + await atlas.ready(); + + const mk = (reduced) => { + const c = document.createElement('canvas'); + c.width = 800; + c.height = 450; + if (reduced) c.dataset.motion = 'reduced'; + const cam = cameraMod.createCamera({ canvas: c, getSize: () => ({ w: 800, h: 450 }) }); + cam.attach(); // the size (the custom getSize) + the resize listeners + const fx = fxMod.createFx({ canvas: c }); + const r = canvasMod.createRenderer({ canvas: c, camera: cam, fx }); + return { c, cam, fx, r }; + }; + + const view = { + s: 0, tgt: 100, tl: 60, + p: [{ i: 0, pi: 0, x: 2.5, y: 2.5, f: 2, s: 'idle', h: null }], + st: [{ k: 'stove', x: 5, y: 2, g: null, q: 0, p: 0.4, s: 'working' }], + o: [], + hz: [{ t: 'fire', x: 5.5, y: 2.5, l: 1 }], + }; + + const out = {}; + // The kitchen bounds (the renderer's own arithmetic — `kitchenBounds`: + // the stations' trunc cells + the players' floor cells, the 1-tile + // `FLOOR_MARGIN`, `w = maxX - minX + 1 + FLOOR_MARGIN`). + let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; + for (const s of view.st) { + minX = Math.min(minX, Math.trunc(s.x)); + maxX = Math.max(maxX, Math.trunc(s.x)); + minY = Math.min(minY, Math.trunc(s.y)); + maxY = Math.max(maxY, Math.trunc(s.y)); + } + for (const p of view.p) { + minX = Math.min(minX, Math.floor(p.x)); + maxX = Math.max(maxX, Math.floor(p.x)); + minY = Math.min(minY, Math.floor(p.y)); + maxY = Math.max(maxY, Math.floor(p.y)); + } + minX -= 1; + minY -= 1; + const R = mk(true); + await R.r.ensureReady(); + R.fx.consume([{ k: 'denied', x: 2.5, y: 2.5, payload: { i: 0, r: 'no_target' } }], view, 1000); + R.r.drawFrame(view, 1000); + R.r.drawFrame(view, 1125); + out.reducedFlag = R.fx.reduced(); + out.reducedDrawCalls = R.r.renderStats().drawCalls; + out.reducedShake = R.fx.shakeOffset(0, 1100); + const hs = R.cam.worldToScreen(view.hz[0].x - minX, view.hz[0].y - minY); + const hx = hs.x, hy = hs.y; + const data = R.c.getContext('2d').getImageData( + Math.max(0, hx - 40), Math.max(0, hy - 40), 80, 80 + ).data; + let lit = 0; + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] > 8 && data[i] + data[i + 1] + data[i + 2] > 40) lit += 1; + } + out.reducedHazardLitFraction = lit / (80 * 80); + + const N = mk(false); + await N.r.ensureReady(); + N.fx.consume([{ k: 'score', x: 3, y: 3, payload: { pts: 44, stk: 1 } }], view, 2000); + N.fx.update(2100); + N.fx.consume([{ k: 'denied', x: 2.5, y: 2.5, payload: { i: 0, r: 'no_target' } }], view, 2100); + N.r.drawFrame(view, 2100); + out.normalReduced = N.fx.reduced(); + out.normalParticles = N.fx.stats().particlesLaunched; + out.normalShake = N.fx.shakeOffset(0, 2110) !== null; + return out; +}""" + + +def test_the_rendered_kitchen_is_not_blank_and_responds_to_input( + browser: Browser, round_server +) -> None: + """One tab, a solo room: the canvas is not blank, `drawCalls` is + within the band, the HUD exists (the ticket appears with the + order, the timer decreases), a move changes the pixels, the + `document.hidden` pause holds the frame count, and the mapping + walk resolves every pair.""" + url = round_server.url + context = browser.new_context() + page = context.new_page() + try: + load_app(page, url) + _start_solo(page) + + # The canvas is visible once the renderer is ready (task 04 + # un-hides it; the us03 contract keeps it out of the a11y + # tree — `aria-hidden` stays true). + page.locator("#kcanvas").wait_for(state="visible", timeout=30_000) + assert page.locator("#kcanvas").get_attribute("aria-hidden") == "true" + page.wait_for_timeout(800) # a few frames at the fit + + # Not blank: the non-background fraction and the colour census. + census = page.evaluate(PIXEL_CENSUS) + assert census["nonBackgroundFraction"] > 0.05, ( + f"the canvas is blank (non-background fraction " + f"{census['nonBackgroundFraction']:.3f})" + ) + assert census["distinctColors"] >= 4, ( + f"the canvas has no colour variance ({census['distinctColors']} colours)" + ) + + # The per-frame draw census: something is drawn, and no + # per-frame explosion (the task's < 4 000 band). + stats = page.evaluate("() => window.__uc.renderStats()") + assert 50 < stats["drawCalls"] < 4000, ( + f"drawCalls {stats['drawCalls']} is outside the sane band (50, 4000)" + ) + + # The HUD exists (the task's a11y half: all DOM). + for selector in ("#hud-score", "#hud-timer", "#hud-conn", "#hud-dirty"): + assert page.locator(selector).count() == 1, f"{selector} is missing" + + # The timer decreases (two reads, 2.5 s apart — the + # interpolated `tl`, one second of game per second of wall). + timer1 = int(page.locator("#hud-timer").inner_text()) + page.wait_for_timeout(2500) + timer2 = int(page.locator("#hud-timer").inner_text()) + assert timer2 < timer1, f"the timer did not decrease ({timer1} -> {timer2})" + + # The order ticket appears (the campaign's 16-20 s spawn + # interval), with its points and the deadline bar; the head + # (the oldest) is highlighted. + page.locator(".ticket").first.wait_for(state="visible", timeout=30_000) + ticket = page.locator(".ticket").first + assert ticket.locator(".ticket-pts").inner_text().endswith("pts") + assert ticket.locator(".ticket-bar-fill").count() == 1 + assert "oldest" in (ticket.get_attribute("class") or ""), ( + "the oldest ticket is not highlighted" + ) + + # A move changes the pixels (the signature diff). + before = page.evaluate(PIXEL_CENSUS)["blocks"] + page.evaluate( + """async () => { + const app = window.__undercooked; + for (let i = 0; i < 20; i += 1) { + app.sendInput({ x: 1, y: 0 }, false); // velocity holds per send + await new Promise((resolve) => setTimeout(resolve, 100)); + } + app.sendInput(null, false); + await new Promise((resolve) => setTimeout(resolve, 500)); + }""" + ) + after = page.evaluate(PIXEL_CENSUS)["blocks"] + changed = sum( + 1 for a, b in zip(before, after, strict=True) if abs(float(a) - float(b)) > 0.02 + ) + assert changed >= 3, ( + f"a move did not change the rendered frame (only {changed} of " + f"{len(before)} blocks differ)" + ) + assert changed < len(before) * 0.9, ( + f"a move changed {changed} of {len(before)} blocks — a full redraw, not a move" + ) + + # The `document.hidden` pause: the loop's frame count stops + # advancing, then resumes on the `visibilitychange`. + probe = page.evaluate(HIDDEN_PAUSE_PROBE) + assert "error" not in probe, probe.get("error") + assert probe["f3"] == probe["f2"], ( + f"the loop kept advancing while hidden ({probe['f2']} -> {probe['f3']})" + ) + assert probe["f4"] > probe["f3"], ( + f"the loop did not resume on the visibilitychange ({probe['f3']} -> {probe['f4']})" + ) + + # The mapping walk: every (station * state) and (item * form) + # pair resolves in the atlas (the task's completion criterion). + walk = page.evaluate(MAPPING_WALK) + assert walk["checked"] >= 50, f"the mapping walk checked too few pairs: {walk['checked']}" + assert walk["missing"] == [], f"the mapping left pairs without sprites: {walk['missing']}" + finally: + context.close() + + +def test_the_frame_budget_and_the_hud_score_update( + browser: Browser, e2e_server +) -> None: + """Two tabs, the `levels_us04` campaign (target 100, 120 s): the + scripted soup (the us03 machine) with the task's instrumented + half — the renderer's `frameMsP95` ≤ 16 ms over the run (well + past the 10 s window), `drawCalls` within the band, the HUD's + score text changed within 200 ms of the serve (the serve does not + end the round: target 100, one soup is 44-66), the timer + decreased, and the FX's census shows the scripted `chopped` and + `served` events landed (with the particles' launch).""" + url = e2e_server(US04_LEVELS_DIR).url + context_a, page_a, context_b, page_b = open_two_pages(browser, url) + try: + # The scripted tab drives the wire through the seam, so the + # keyboard module is off for it (`?controls=off`, the documented + # scripted-test mode): its 10 Hz zero-intent keep-alive would + # race the script's frames on the same seat (an `act=false` + # frame breaks the holds, a zero vector brakes the walks). + load_app(page_a, url + "?controls=off") + load_app(page_b, url) + _start_duo(page_a, page_b) + + page_a.set_default_timeout(600_000) + try: + page_a.evaluate(SOUP_CHOREOGRAPHY) + finally: + page_a.set_default_timeout(30_000) + + perf = page_a.evaluate("() => window.__uc_perf") + assert perf is not None, ( + "the budget sample was not taken (the round did not reach the serve?)" + ) + # The task's budget: frame time ≤ 16 ms p95 (the run has + # well outlasted the 10 s window). + assert perf["frameMsP95"] <= 16.0, ( + f"frameMsP95 {perf['frameMsP95']:.2f} ms exceeds the 16 ms budget " + f"(max {perf['frameMsMax']:.2f} ms over {perf['frames']} frames)" + ) + # The draw census band (the same ceiling as the smoke test). + assert 50 < perf["drawCalls"] < 4000, ( + f"drawCalls {perf['drawCalls']} is outside the sane band (50, 4000)" + ) + # The HUD's score changed within 200 ms of the `served` event (the + # task's criterion — the press->event latency is excluded). + delay = page_a.evaluate("() => window.__uc_score_delay") + assert delay >= 0, ( + "the HUD's score never changed after the `served` event (the 300 ms window missed)" + ) + assert delay <= 200.0, ( + f"the HUD's score changed {delay:.0f} ms after the `served` event (> 200 ms)" + ) + # The timer decreased over the 2.5 s window. + timer = page_a.evaluate("() => window.__uc_timer") + assert timer[1] < timer[0], f"the HUD timer did not decrease: {timer}" + # The FX's census: the scripted `chopped` and `served` events + # landed, and the particles launched (the normal-motion half). + fx = page_a.evaluate("() => window.__uc_fx") + assert fx is not None, "the FX census was not taken" + assert fx["processed"].get("chopped", 0) >= 1, f"no `chopped` FX: {fx['processed']}" + assert fx["processed"].get("served", 0) >= 1, f"no `served` FX: {fx['processed']}" + assert fx["particlesLaunched"] > 0, "no particles launched (the pool never fired)" + finally: + context_a.close() + context_b.close() + + +def test_reduced_motion_mode(browser: Browser, round_server) -> None: + """`?motion=0` on a solo room: the canvas's `data-motion="reduced"`, + the FX reports reduced, a live `denied` produces the static marker + with **no shake** and **no particles** (the task's "no shake + transform, hazard markers still visible" half — the live half is + the dataset + the census; the marker's visibility is the + offscreen smoke: a synthetic `fire` drawn under reduced motion + still shows its pixels, while a `score` on the normal canvas + launches the particles and a `denied` there shakes).""" + url = round_server.url + context = browser.new_context() + page = context.new_page() + try: + page.goto(f"{url}/?motion=0", wait_until="networkidle") + _start_solo(page) + page.locator("#kcanvas").wait_for(state="visible", timeout=30_000) + assert page.locator("#kcanvas").get_attribute("data-motion") == "reduced" + assert page.evaluate("() => window.__uc.fx !== null && window.__uc.fx.reduced()") is True + + # A live `denied` (an act press with no station in reach): the + # static marker is consumed; the reduced-motion half is no + # shake and no particles. Self-healing press: the keyboard + # module's 10 Hz zero-intent keep-alive (the default + # `?controls=keyboard` — `?motion=0` only changes the FX) can win + # the same tick ahead of the single rising edge; its + # `act=false` frame drops the press, so a release + a fresh + # rising edge, up to three times. + page.evaluate( + """async () => { + const app = window.__undercooked; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + for (let i = 0; i < 3; i += 1) { + app.sendInput(null, false); + await sleep(150); + app.sendInput(null, true); + await sleep(120); + app.sendInput(null, false); + await sleep(500); + const fx = window.__uc.fx.stats(); + if ((fx.processed.denied || 0) >= 1) return; + } + }""" + ) + fx = page.evaluate("() => window.__uc.fx.stats()") + assert fx["processed"].get("denied", 0) >= 1, ( + f"no `denied` event consumed: {fx['processed']}" + ) + assert fx["shakeApplied"] == 0, ( + f"the reduced-motion mode shook ({fx['shakeApplied']} frames)" + ) + assert fx["particlesLaunched"] == 0, ( + f"the reduced-motion mode launched particles ({fx['particlesLaunched']})" + ) + + # The offscreen smoke: the hazard marker's pixels under reduced + # motion (the static fallback), the no-shake half, the + # particles' launch on the normal canvas. + smoke = page.evaluate(REDUCED_MOTION_SMOKE) + assert smoke["reducedFlag"] is True, "the reduced canvas does not report reduced" + assert smoke["reducedDrawCalls"] > 10, ( + f"the offscreen reduced frame drew too little ({smoke['reducedDrawCalls']} calls)" + ) + assert smoke["reducedHazardLitFraction"] > 0.01, ( + f"the fire hazard's marker is not visible under reduced motion " + f"({smoke['reducedHazardLitFraction']:.3f} lit pixels)" + ) + assert smoke["reducedShake"] is None, ( + f"a reduced-motion `denied` shook ({smoke['reducedShake']})" + ) + assert smoke["normalReduced"] is False, "the normal canvas reports reduced" + assert smoke["normalParticles"] > 0, "no particles launched on the normal canvas" + assert smoke["normalShake"] is True, "a normal-motion `denied` did not shake" + finally: + context.close() diff --git a/tests/e2e/test_us06_interp_math.py b/tests/e2e/test_us06_interp_math.py new file mode 100644 index 0000000..825d046 --- /dev/null +++ b/tests/e2e/test_us06_interp_math.py @@ -0,0 +1,750 @@ +"""Phase 07 task 03 — the snapshot buffer, the interpolation, the delta +decoder and the event queue, driven through the page's own modules. + +The pure modules (`net/snapshots.js`, `net/decode.js`, `net/events.js`, +`render/loop.js`) are tested *in the browser* — one `page.evaluate` +assertion per module path (the task's completion criterion) — with an +injected clock, so the synthetic streams are exact: no real network, no +wall clock, no waiting. The server (the `round_server` campaign) is +only the page's host; the wire half — `applyDelta` against a frame +encoded by the *real* `encode_snapshot` — is the one place the Python +and the JS meet (deep equality, field by field). + +The stream contract under test (docs/CLIENT.md "The snapshot buffer"): +frames keyed by `t`, in order (older/duplicate dropped, a `t` jump a +recorded gap), a gap *longer than* `MAX_GAP_TICKS` (6) fires the +resync — debounced to one request per `RESYNC_DEBOUNCE_MS` (500 ms) — +and the stream's full cadence clears the latch; no frame for +`maxAgeMs` and the sample **freezes** (it never extrapolates). +""" + +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING + +import pytest + +from undercooked.server.encoding import encode_snapshot +from undercooked.sim.grid import Grid +from undercooked.sim.model import ( + Facing, + Hazard, + HazardKind, + Item, + ItemForm, + ItemKind, + KitchenState, + Order, + Player, + PlayerState, + Station, + StationKind, + StationState, + TileKind, + Vec2, +) + +if TYPE_CHECKING: + from playwright.sync_api import Page + +pytestmark = pytest.mark.e2e + +#: The per-step bound the smoothness suite asserts (the task's number): +#: `MAX_SPEED * (1 / SNAPSHOT_HZ) * 1.35` = 3.5 * 0.05 * 1.35. +STEP_BOUND = 3.5 * 0.05 * 1.35 + + +def _load(page: Page, url: str) -> None: + page.goto(url, wait_until="networkidle") + + +# ---------------------------------------------------------------- the buffer + + +def test_buffer_drops_older_frames_and_records_gaps(page: Page, round_server) -> None: + """In-order delivery: older and duplicate `t` are dropped (counted), + a `t` jump of 3 (under the 6) is a recorded gap, not a resync.""" + _load(page, f"{round_server.url}/") + result = page.evaluate( + """ + async () => { + const { createSnapshotBuffer } = await import('/static/js/net/snapshots.js'); + let fake = 0; + const buf = createSnapshotBuffer({ now: () => fake }); + const fr = (t, x, full = true) => { + const p = [{ i: 'c', x, y: 5, f: 1, h: null, s: 'moving', pi: 0 }]; + if (full) { + return { full: true, t, s: 0, tgt: 20, tl: 1, p, st: [], o: [], hz: [], e: [] }; + } + return { full: false, t, s: 0, tgt: 20, tl: 1, d: { p, st: [], o: [], hz: [] }, e: [] }; + }; + fake = 0; const a0 = buf.push(fr(0, 0)); + fake = 50; const a1 = buf.push(fr(1, 0.175)); + fake = 100; const older = buf.push(fr(0, 0)); // older: dropped + fake = 150; const dup = buf.push(fr(1, 0.175)); // duplicate: dropped + fake = 200; const jump = buf.push(fr(4, 0.7)); // gap of 3: recorded + return { a0, a1, older, dup, jump, stats: buf.stats(), pending: buf.needsFullResync() }; + } + """ + ) + assert result["a0"] is True and result["a1"] is True + assert result["older"] is False and result["dup"] is False + assert result["jump"] is True + assert result["stats"]["pushed"] == 3 + assert result["stats"]["dropped"] == 2 + assert result["stats"]["gaps"] == 1 + assert result["stats"]["maxGapTicks"] == 3 + assert result["pending"] is False # a gap of 3 is not a resync + + +def test_buffer_gap_over_six_ticks_triggers_a_debounced_resync(page: Page, round_server) -> None: + """A gap *longer than* 6 ticks fires the resync request — once — + holds delta frames out until a full frame arrives (the stream's + cadence is the resync), debounces a second gap inside the 500 ms + window, and fires again after it.""" + _load(page, f"{round_server.url}/") + result = page.evaluate( + """ + async () => { + const { createSnapshotBuffer } = await import('/static/js/net/snapshots.js'); + let fake = 0; + let resyncs = 0; + const buf = createSnapshotBuffer({ now: () => fake, onResync: () => { resyncs += 1; } }); + const fr = (t, x, full = true) => { + const p = [{ i: 'c', x, y: 5, f: 1, h: null, s: 'moving', pi: 0 }]; + if (full) { + return { full: true, t, s: 0, tgt: 20, tl: 1, p, st: [], o: [], hz: [], e: [] }; + } + return { full: false, t, s: 0, tgt: 20, tl: 1, d: { p, st: [], o: [], hz: [] }, e: [] }; + }; + fake = 0; buf.push(fr(0, 0)); + fake = 50; buf.push(fr(1, 0.175)); + // The gap of 8 (> 6), on a *delta* frame: the request fires. + const snap = () => { + const s = buf.stats(); + return { pending: buf.needsFullResync(), resyncs, skipped: s.skippedUntilFull }; + }; + fake = 100; const gapped = buf.push(fr(9, 1.6, false)); + const afterGap = snap(); + // A further delta while pending: skipped, no second request. + fake = 150; const skipped = buf.push(fr(10, 1.8, false)); + const afterSkip = snap(); + // A second big gap inside the 500 ms debounce window: no fire. + fake = 200; buf.push(fr(20, 3.5, false)); + const debounced = { resyncs, pending: buf.needsFullResync() }; + // The stream's full cadence (a full frame) clears the latch. + fake = 300; const cleared = buf.push(fr(21, 3.7)); + const afterFull = { pending: buf.needsFullResync(), resyncs, cleared }; + // And after the 500 ms window, a big gap fires again. + fake = 900; buf.push(fr(30, 5.3, false)); + const refired = { resyncs, pending: buf.needsFullResync() }; + fake = 1000; buf.push(fr(31, 5.45, false)); + return { + gapped, afterGap, skipped, afterSkip, debounced, afterFull, refired, + latest: buf.latest().t, + }; + } + """ + ) + assert result["gapped"] is False, "the gapped delta is held back, not recorded" + assert result["afterGap"]["pending"] is True, "the gap over six must set the latch" + assert result["afterGap"]["resyncs"] == 1, "the resync request fires exactly once" + assert result["skipped"] is False, "a delta while pending is skipped" + assert result["afterSkip"]["resyncs"] == 1, "the debounce holds the second request" + assert result["afterSkip"]["skipped"] == 2, ( + "both the gapped delta and the pending one are held back" + ) + assert result["debounced"]["resyncs"] == 1, "a gap inside 500 ms is debounced" + assert result["debounced"]["pending"] is True + assert result["afterFull"]["cleared"] is True + assert result["afterFull"]["pending"] is False, "a full frame clears the latch" + assert result["refired"]["resyncs"] == 2, "after 500 ms the request fires again" + assert result["refired"]["pending"] is True, ( + "the gapped delta re-set the latch; the next full frame will clear it" + ) + assert result["latest"] == 21, "the view holds the last good (full) frame" + + +def test_buffer_freezes_when_the_stream_goes_quiet(page: Page, round_server) -> None: + """No frame for `maxAgeMs`: the sample holds the last frame + (`a === b`, `alpha 0`) — the buffer never extrapolates, a frozen + cook stays exactly where the last frame put it.""" + _load(page, f"{round_server.url}/") + result = page.evaluate( + """ + async () => { + const { createSnapshotBuffer, lerpState } = await import('/static/js/net/snapshots.js'); + let fake = 0; + const buf = createSnapshotBuffer({ interpDelayMs: 100, maxAgeMs: 1000, now: () => fake }); + const fr = (t, x) => ({ + full: true, t, s: 0, tgt: 20, tl: 1, + p: [{ i: 'c', x, y: 5, f: 1, h: null, s: 'moving', pi: 0 }], + st: [], o: [], hz: [], e: [], + }); + fake = 0; buf.push(fr(0, 0)); + fake = 50; buf.push(fr(1, 0.175)); + fake = 100; buf.push(fr(2, 0.35)); + // The stream goes quiet for well over `maxAgeMs` (1 s). + fake = 5000; + const sample = buf.sample(5000); + const state = lerpState(sample.a, sample.b, sample.alpha); + return { + sameFrame: sample.a === sample.b, + alpha: sample.alpha, + frozen: sample.frozen, + x: state.p[0].x, + latestT: buf.latest().t, + }; + } + """ + ) + assert result["sameFrame"] is True + assert result["alpha"] == 0 + assert result["frozen"] is True + assert result["x"] == 0.35, "the frozen hold is the last frame's position, verbatim" + assert result["latestT"] == 2 + + +def test_synthetic_thirty_frame_stream_is_monotonic_and_bounded(page: Page, round_server) -> None: + """The 30-frame synthetic stream: a cook walking at `MAX_SPEED` + (3.5 tiles/s, 0.175 per 50 ms tick). The sampled positions are + monotonic, the first sample is the walk's start (0), the last + in-stream sample is the frame it brackets, no sample-to-sample + step exceeds `MAX_SPEED * (1/snapshot_hz) * 1.35` (0.23625), and + past the final frame the position holds (no extrapolation). + 28 samples: render time 100..1450 (50 ms steps) over a 100 ms + interp delay is 0..1350.""" + _load(page, f"{round_server.url}/") + result = page.evaluate( + """ + async () => { + const { createSnapshotBuffer, lerpState } = await import('/static/js/net/snapshots.js'); + let fake = 0; + // `maxAgeMs` wide enough to keep all 30 frames in the window. + const buf = createSnapshotBuffer({ + interpDelayMs: 100, maxAgeMs: 100000, now: () => fake, + }); + const fr = (t) => ({ + full: true, t, s: 0, tgt: 20, tl: 1, + p: [{ i: 'c', x: t * 0.175, y: 5, f: 1, h: null, s: 'moving', pi: 0 }], + st: [], o: [], hz: [], e: [], + }); + for (let t = 0; t < 30; t += 1) { + fake = t * 50; + buf.push(fr(t)); + } + const xs = []; + for (let now = 100; now <= 1450; now += 50) { + const sample = buf.sample(now); + const state = lerpState(sample.a, sample.b, sample.alpha); + xs.push(state.p[0].x); + } + // The tail: past the final frame the sample holds. + const tailSample = buf.sample(2000); + const tailState = lerpState(tailSample.a, tailSample.b, tailSample.alpha); + let monotonic = true; + let maxStep = 0; + for (let i = 1; i < xs.length; i += 1) { + const step = xs[i] - xs[i - 1]; + if (step < 0) monotonic = false; + if (step > maxStep) maxStep = step; + } + return { + xs, + monotonic, + maxStep, + first: xs[0], + last: xs[xs.length - 1], + tailX: tailState.p[0].x, + tailFrozen: tailSample.frozen, + }; + } + """ + ) + xs = result["xs"] + assert len(xs) == 28, "28 samples: render time 100..1450 in 50 ms steps" + assert result["monotonic"] is True, f"the sampled walk went backwards: {xs}" + assert result["first"] == 0, f"the first sample is the walk's start: {xs[0]}" + assert result["last"] == pytest.approx(27 * 0.175, abs=1e-9), ( + f"the last in-stream sample is the frame it brackets: {xs[-1]}" + ) + assert result["maxStep"] <= STEP_BOUND, ( + f"a sample-to-sample step of {result['maxStep']} exceeds {STEP_BOUND}" + ) + assert result["tailX"] == pytest.approx(29 * 0.175, abs=1e-9), ( + "past the final frame the position holds (no extrapolation)" + ) + assert result["tailFrozen"] is True + + +# ------------------------------------------------------------- lerpState + + +def test_lerp_state_interpolates_and_snaps(page: Page, round_server) -> None: + """`lerpState`: positions lerp along the chord, the facing (and the + carried item and state) snap at the `alpha >= 0.5` boundary, the + station progress and the order time-left lerp, an entity added at + `b` appears for the whole interval (at `b`'s position), and one + removed vanishes exactly at the boundary (held at `a`'s values + until `alpha >= 0.5`, then gone).""" + _load(page, f"{round_server.url}/") + result = page.evaluate( + """ + async () => { + const { lerpState } = await import('/static/js/net/snapshots.js'); + const a = { + t: 10, s: 0, tgt: 20, tl: 59.5, + p: [ + { i: 'stay', x: 1, y: 1, f: 0, h: 'tomato_raw', s: 'moving', pi: 0 }, + { i: 'vanish', x: 4, y: 4, f: 2, h: null, s: 'idle', pi: 1 }, + ], + st: [{ k: 'stove', x: 3, y: 2, g: 'onion_raw', q: 0.9, p: 0.2, s: 'working' }], + o: [{ id: 7, r: 'soup', l: 0.8, pts: 40 }], + hz: [], + e: [{ k: 'chopped', x: 4, y: 1 }], + }; + const b = { + t: 11, s: 5, tgt: 20, tl: 59.45, + p: [ + { i: 'stay', x: 2, y: 1, f: 1, h: 'tomato_chopped', s: 'moving', pi: 0 }, + { i: 'appear', x: 8, y: 1, f: 3, h: null, s: 'idle', pi: 2 }, + ], + st: [{ k: 'stove', x: 3, y: 2, g: 'onion_raw', q: 0.9, p: 0.4, s: 'working' }], + o: [{ id: 7, r: 'soup', l: 0.75, pts: 40 }], + hz: [], + e: [{ k: 'chopped', x: 4, y: 1 }], + }; + const at = (alpha) => lerpState(a, b, alpha); + const at0 = at(0); + const at4 = at(0.4); + const at5 = at(0.5); + const at1 = at(1); + const pick = (s, i) => s.p.find((p) => p.i === i); + return { + at0: { + x: at0.p[0].x, f: at0.p[0].f, h: at0.p[0].h, + vanish: pick(at0, 'vanish'), appear: pick(at0, 'appear'), + sp: at0.st[0].p, tl: at0.tl, + }, + at4: { + x: at4.p[0].x, f: at4.p[0].f, h: at4.p[0].h, + vanish: pick(at4, 'vanish'), sp: at4.st[0].p, + }, + at5: { + x: at5.p[0].x, f: at5.p[0].f, h: at5.p[0].h, s: at5.p[0].s, + vanish: pick(at5, 'vanish'), appear: pick(at5, 'appear'), + sp: at5.st[0].p, + }, + at1: { + x: at1.p[0].x, f: at1.p[0].f, + appear: pick(at1, 'appear'), vanish: pick(at1, 'vanish'), + sp: at1.st[0].p, l: at1.o[0].l, e: at1.e, + }, + }; + } + """ + ) + # `alpha 0`: the chord's start, `a`'s discrete values; the vanishing + # entity is still visible, the new one is here too (it appears for + # the whole interval, at `b`'s position). + assert result["at0"]["x"] == 1 + assert result["at0"]["f"] == 0, "below the boundary the facing holds `a`" + assert result["at0"]["h"] == "tomato_raw", "the carried item holds `a`" + assert result["at0"]["vanish"]["x"] == 4, "the vanishing entity is visible below 0.5" + assert result["at0"]["appear"] is not None, "the added entity appears for the whole interval" + assert result["at0"]["appear"]["x"] == 8 + assert result["at0"]["sp"] == 0.2 + assert result["at0"]["tl"] == pytest.approx(59.5) + # `alpha 0.4`: mid-chord lerp, the discrete fields still `a`. + assert result["at4"]["x"] == pytest.approx(1.4) + assert result["at4"]["f"] == 0 + assert result["at4"]["h"] == "tomato_raw" + assert result["at4"]["vanish"]["x"] == 4 + assert result["at4"]["sp"] == pytest.approx(0.28) + # `alpha 0.5`: the boundary — the discrete fields snap to `b`, the + # vanishing entity is gone, the new one is here. + assert result["at5"]["x"] == pytest.approx(1.5) + assert result["at5"]["f"] == 1, "at the boundary the facing snaps to `b`" + assert result["at5"]["h"] == "tomato_chopped" + assert result["at5"]["s"] == "moving" + assert result["at5"]["vanish"] is None, "at the boundary the vanishing entity is gone" + assert result["at5"]["appear"] is not None + assert result["at5"]["sp"] == pytest.approx(0.3) + # `alpha 1`: `b` verbatim. + assert result["at1"]["x"] == 2 + assert result["at1"]["f"] == 1 + assert result["at1"]["appear"]["x"] == 8 + assert result["at1"]["vanish"] is None + assert result["at1"]["sp"] == pytest.approx(0.4) + assert result["at1"]["l"] == pytest.approx(0.75) + assert result["at1"]["e"] == [{"k": "chopped", "x": 4, "y": 1}] + + +# ------------------------------------------------------------ applyDelta + + +def _make_states() -> tuple[KitchenState, KitchenState]: + """Two hand-built states (the `test_encoding_no_leak.py` machine, + extended): state A is the baseline (two cooks, a stove with + content, an empty board, an order, a fire); state B is one tick + on — the first cook moved and turned, the second *left the round*, + the stove advanced to ready, the board took content and turned + busy, a second order appeared, and the fire was put out. Every + delta path (an in-place change, an append, a tombstone) is + exercised at least once.""" + grid = Grid(width=8, height=8, tiles=(TileKind.FLOOR,) * 64) + a = KitchenState( + level_id="01_first_kitchen", + seed=0x1234, + grid=grid, + tick=100, + stations=( + Station( + id="stove_1", + kind=StationKind.STOVE, + x=3.234, + y=2.0, + contents=(Item(ItemKind.ONION, ItemForm.RAW, quality=0.98),), + state=StationState.WORKING, + progress=0.42, + ), + Station( + id="chop_board_1", + kind=StationKind.CHOP_BOARD, + x=5.0, + y=7.0, + state=StationState.IDLE, + ), + ), + players=( + Player( + id="tok-a", + pos=Vec2(1.234, 2.345), + facing=Facing.RIGHT, + carrying=Item(ItemKind.TOMATO, ItemForm.RAW, quality=0.9), + state=PlayerState.MOVING, + seat=0, + ), + Player( + id="tok-b", + pos=Vec2(6.0, 6.0), + facing=Facing.DOWN, + state=PlayerState.IDLE, + seat=1, + ), + ), + orders=(Order(id=7, recipe_id="soup", time_left=30.0, deadline=60, value=40),), + order_deadline=60, + hazards=(Hazard(id="hz-1", kind=HazardKind.FIRE, pos=Vec2(7.65, 5.2), life=12.34),), + score=0, + target_score=20, + time_left=59.95, + rng_state=0xCAFEBABE, + ) + b = a.with_players( + (replace(a.players[0], pos=Vec2(1.409, 2.345), facing=Facing.DOWN),) + ) + b = b.with_orders( + ( + Order(id=7, recipe_id="soup", time_left=29.95, deadline=60, value=40), + Order(id=8, recipe_id="salad", time_left=60.0, deadline=60, value=30), + ) + ) + b = b.with_hazards(()) + b = b.with_stations( + ( + replace(b.stations[0], progress=0.47, state=StationState.READY), + replace( + b.stations[1], + contents=(Item(ItemKind.TOMATO, ItemForm.CHOPPED, quality=0.95),), + state=StationState.BUSY, + ), + ) + ) + b = b.with_tick(101).with_score(5) + return a, b + + +def test_apply_delta_decodes_the_server_frame_to_the_full_frame( + page: Page, round_server +) -> None: + """`applyDelta` (the client's decoder) applied to a delta frame + encoded by the *real* `encode_snapshot` lands, field by field, on + the full frame the server would have sent — positions, the carried + short, the tombstones (the cook who left, the put-out fire), the + appended order, the scalars, the events.""" + _load(page, f"{round_server.url}/") + a, b = _make_states() + full_a = encode_snapshot(a) + delta = encode_snapshot(b, full_a) + full_b = encode_snapshot(b) + assert delta["full"] is False # the encoder really did make a delta + # The JS decoder, in the page's own module graph. + result = page.evaluate( + """ + async ([fullA, delta]) => { + const { applyDelta } = await import('/static/js/net/decode.js'); + return applyDelta(delta, fullA); + } + """, + [full_a, delta], + ) + assert result["full"] is True + # The scalars and events ride the delta: taken from the frame. + assert result["t"] == full_b["t"] + assert result["s"] == full_b["s"] + assert result["tgt"] == full_b["tgt"] + assert result["tl"] == full_b["tl"] + # Field by field, collection by collection (a deep equality). + assert result["p"] == full_b["p"], "the players (a move, a turn, a tombstone)" + assert result["st"] == full_b["st"], "the stations (a progress change, a content change)" + assert result["o"] == full_b["o"], "the orders (an in-place update, an append)" + assert result["hz"] == full_b["hz"], "the hazards (a tombstone)" + assert result == full_b, "the whole decoded frame equals the full frame" + + +def test_apply_delta_chains_delta_over_delta(page: Page, round_server) -> None: + """The encoder diffs against *the previous frame* — full or delta: + `applyDelta` mirrors that (a delta frame's entities live under `d`, + its `gone` tombstones are not state). A full → delta1 → delta2 + chain decodes to the final full frame.""" + _load(page, f"{round_server.url}/") + a, b = _make_states() + full_a = encode_snapshot(a) + delta1 = encode_snapshot(b, full_a) + c = b.with_players(tuple(replace(p, pos=Vec2(p.pos.x + 0.175, p.pos.y)) for p in b.players)) + delta2 = encode_snapshot(c, delta1) + full_c = encode_snapshot(c) + result = page.evaluate( + """ + async ([fullA, d1, d2]) => { + const { applyDelta } = await import('/static/js/net/decode.js'); + const one = applyDelta(d1, fullA); + return applyDelta(d2, one); + } + """, + [full_a, delta1, delta2], + ) + assert result == full_c, "the chained delta decodes to the final full frame" + + +# ---------------------------------------------------------- the event queue + + +def test_event_queue_ttl_dedup_and_tick_reset(page: Page, round_server) -> None: + """The event queue: a per-kind `ttlMs` (a `served` outlives a + `chopped`), the dedup window (the same event inside 100 ms is + counted, not re-queued), `drift` prunes the expired, and a `push` + whose tick goes *backwards* is a resync — the queue clears, so a + `served` from the previous round cannot replay over the new one. + The standalone `ev` message's shape (`kind`) is admitted too.""" + _load(page, f"{round_server.url}/") + result = page.evaluate( + """ + async () => { + const { createEventQueue } = await import('/static/js/net/events.js'); + let fake = 0; + const q = createEventQueue({ now: () => fake }); + fake = 0; + const first = q.push(1, [{ k: 'chopped', x: 1, y: 2, i: 'tok-a' }]); + const echo = q.push(2, [{ k: 'chopped', x: 1, y: 2, i: 'tok-a' }]); + fake = 200; + const again = q.push(3, [{ k: 'chopped', x: 1, y: 2, i: 'tok-a' }]); + const served = q.push(3, [{ k: 'served', x: 9, y: 1, oi: 7, d: 44 }]); + const unknown = q.push(3, [{ k: 'no_such_kind', x: 0, y: 0 }]); + const viaKind = q.push(3, [{ kind: 'score', x: 0, y: 0, pts: 44 }]); + const at1000 = { due: q.due(1000).map((e) => e.k), size: q.stats().size }; + const pruned = q.drift(1500); + const at1500 = { due: q.due(1500).map((e) => e.k), size: q.stats().size }; + // The tick goes backwards (a resync / a new round): the queue clears. + const resetPush = q.push(2, [{ k: 'score', x: 1, y: 1, pts: 5 }]); + const afterReset = { + due: q.due().map((e) => e.k), + size: q.stats().size, + resyncs: q.stats().resyncs, + }; + return { + first, echo, again, served, unknown, viaKind, at1000, pruned, + at1500, resetPush, afterReset, stats: q.stats(), + }; + } + """ + ) + assert result["first"] == 1 + assert result["echo"] == 0, "the dedup window holds the echo" + assert result["stats"]["deduped"] == 1 + assert result["again"] == 1, "after the window the event queues again" + assert result["served"] == 1 + assert result["unknown"] == 0, "an unknown kind is no FX, not an error" + assert result["viaKind"] == 1, "the standalone `ev` shape (`kind`) is admitted" + # At 1000: the second `chopped` (ttl 800 from 200), the `served` + # (ttl 1200 from 200) and the `score` (ttl 800 from 200) are live; + # the first `chopped` (ttl 800 from 0) is expired (still unpruned). + assert sorted(result["at1000"]["due"]) == ["chopped", "score", "served"] + assert result["at1000"]["size"] == 4 # all four entries, one expired + # At 1500: everything is expired and pruned (the entries and the + # dedup keys past their window). + assert result["at1500"]["due"] == [] + assert result["at1500"]["size"] == 0 + assert result["pruned"] >= 4 + # The reset: only the post-reset event is live. + assert result["resetPush"] == 1 + assert result["afterReset"]["due"] == ["score"] + assert result["afterReset"]["size"] == 1 + assert result["afterReset"]["resyncs"] == 1 + + +# ------------------------------------------------------------ the loop + + +def test_render_loop_pauses_on_hidden_and_measures(page: Page, round_server) -> None: + """The single render loop, driven with an injected clock and a + captured rAF (no real animation): frames render while active, the + `visibilitychange` to hidden stops the chain (no frame renders + while hidden), the resume restarts it — and the delta spanning the + 5 s pause is *dropped*, not measured, so the `frameMs` census + (`p50`/`p95`/`max`) stays honest (what `window.__uc.frameMs` + reports).""" + _load(page, f"{round_server.url}/") + result = page.evaluate( + """ + async () => { + const { createRenderLoop } = await import('/static/js/render/loop.js'); + let t = 0; + let pending = null; + const renders = []; + let active = true; + const stub = { + hidden: false, + listeners: {}, + addEventListener(ev, fn) { (this.listeners[ev] ??= []).push(fn); }, + removeEventListener() {}, + }; + const loop = createRenderLoop({ + now: () => t, + owner: stub, + active: () => active, + render: (now) => { renders.push(now); }, + raf: (fn) => { pending = fn; return 1; }, + unraf: () => { pending = null; return true; }, + }); + const drive = (n) => { + for (let i = 0; i < n; i += 1) { + t += 16.7; + const fn = pending; + pending = null; + if (fn) fn(); + } + }; + loop.start(); + drive(10); + const before = loop.frameStats(); + // Hide the tab: the pending frame finds `hidden` and stops. + stub.hidden = true; + stub.listeners.visibilitychange[0](); + const framesAtPause = renders.length; + if (pending) { const fn = pending; pending = null; fn(); } + t += 5000; // the pause: a long wall-clock gap + const hiddenRenders = renders.length - framesAtPause; + // Resume: the chain restarts; the delta spanning the pause drops. + stub.hidden = false; + stub.listeners.visibilitychange[0](); + drive(10); + const after = loop.frameStats(); + return { before, hiddenRenders, after, rendersCount: renders.length }; + } + """ + ) + assert result["before"]["frames"] == 10 + assert result["before"]["p50"] > 0, "the self-measured delta window is populated" + assert result["hiddenRenders"] == 0, "no frame renders while hidden" + assert result["after"]["frames"] == 20 + assert result["after"]["hidden"] is False + assert result["after"]["max"] < 1000, ( + f"the delta spanning the 5 s pause was measured (max {result['after']['max']})" + ) + assert result["after"]["p95"] < 100 + + +def test_the_idle_loop_keeps_the_chain_alive_for_play(page: Page, round_server) -> None: + """A non-active frame renders nothing but keeps the rAF chain alive: + when the screen turns `PLAYING`, the loop picks it up on the very + next frame — no event, no restart needed.""" + _load(page, f"{round_server.url}/") + result = page.evaluate( + """ + async () => { + const { createRenderLoop } = await import('/static/js/render/loop.js'); + let t = 0; + let pending = null; + const renders = []; + let active = false; // the screen is not PLAYING yet + const stub = { + hidden: false, + listeners: {}, + addEventListener() {}, + removeEventListener() {}, + }; + const loop = createRenderLoop({ + now: () => t, + owner: stub, + active: () => active, + render: (now) => { renders.push(now); }, + raf: (fn) => { pending = fn; return 1; }, + unraf: () => { pending = null; return true; }, + }); + const drive = (n) => { + for (let i = 0; i < n; i += 1) { + t += 16.7; + const fn = pending; + pending = null; + if (fn) fn(); + } + }; + loop.start(); + drive(5); // idle: no renders, the chain stays alive + const idleRenders = renders.length; + active = true; // the screen turns PLAYING + drive(5); // picked up on the next frames + return { idleRenders, afterRenders: renders.length, stats: loop.frameStats() }; + } + """ + ) + assert result["idleRenders"] == 0, "an idle screen renders nothing" + assert result["afterRenders"] == 5, "the chain was alive: PLAYING is picked up" + assert result["stats"]["frames"] == 5 + + +# ---------------------------------------------------------- the seams + + +def test_url_params_and_seams_are_wired(page: Page, round_server) -> None: + """The composition root's seams: `window.__undercooked` (the machine, + plus the buffer/queue/params the boot attaches) and `window.__uc` + (the census and the pure modules) are set by module evaluation, + and `?freeze=1` parses (the sample-time freeze is the composition's + business; the buffer's freeze half is the synthetic stream above).""" + _load(page, f"{round_server.url}/?freeze=1") + result = page.evaluate( + """ + () => ({ + app: typeof window.__undercooked, + screen: window.__undercooked.screen, + params: window.__undercooked.params, + buffer: typeof window.__undercooked.snapshotBuffer, + events: typeof window.__undercooked.eventQueue, + uc: typeof window.__uc, + ucParams: window.__uc.params, + hasFrameMs: typeof window.__uc.frameMs, + }) + """ + ) + assert result["app"] == "object" + assert result["screen"] == "landing" + assert result["buffer"] == "object" + assert result["events"] == "object" + assert result["uc"] == "object" + assert result["ucParams"]["freeze"] is True, "the `?freeze=1` param is parsed" + assert result["ucParams"]["controls"] == "keyboard" # the default + assert result["hasFrameMs"] == "object" # the live census (a getter object) diff --git a/tests/e2e/test_us06_smoothness.py b/tests/e2e/test_us06_smoothness.py new file mode 100644 index 0000000..c08a3fe --- /dev/null +++ b/tests/e2e/test_us06_smoothness.py @@ -0,0 +1,404 @@ +"""Phase 07 task 03 — interpolation smoothness with a 60 ms latency. + +The task's smoothness criterion, end to end: two browsers against a +server with `sim_latency_ms=60` (the injected server-side downlink +delay, `config.py`'s netcode seam), the host's tab drives the scripted +soup (the `test_us03_ready_and_start.py` choreography — the same +waypoints and holds), and the second tab's *rendered* state — the +single loop's `sample → lerpState` output published on +`window.__uc.lastView` — is sampled per animation frame while the +round runs. + +The assertions: + +* **the per-tick displacement is bounded** — a server tick moves a + cook at most `MAX_SPEED * STEP_DT` (3.5 * 0.05 = 0.175); the bound + the task asserts is `MAX_SPEED * (1/snapshot_hz) * 1.35` + (0.23625). Checked on the buffer's *server frames* (consecutive + ticks — the sampler records the frame's `t` and only pairs a + displacement with its one-tick advance), +* **the rendered stream is smooth** — a sample's step is bounded by + its gap's share of the 50 ms tick (a 100 ms gap moves at most two + ticks) plus the 1.35 factor, and the velocity over a normal gap is + at most `MAX_SPEED * 1.35` tiles/s, +* **both clients converge on `ROUND_END` within 1 s** — the round + frame is broadcast once; with the 60 ms injected delay the second + tab's result screen must follow the host's within the second. + +The second tab's sampler is a browser-side `requestAnimationFrame` +promise (started while the host's choreography runs, so the sampling +is live), reading only — the single-loop rule is untouched (one loop +owns the buffer; the probe reads the loop's published `lastView` and +the buffer's `latest()`). +""" + +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +import pytest + +from conftest import ROUND_LEVELS_DIR, app_screen, load_app, open_two_pages + +if TYPE_CHECKING: + from playwright.sync_api import Browser + +pytestmark = pytest.mark.e2e + +#: The host's tab drives the soup — the `test_us03_ready_and_start.py` +#: choreography, the same waypoints and holds (the phase-06 two-client +#: machine, verbatim). +SOUP_CHOREOGRAPHY = """async () => { + const app = window.__undercooked; + const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const ownSeat = app.you.seat; + const player = () => Object.values(app.view.players).find((p) => p.pi === ownSeat); + + const walkTo = async (target, band, settle, waitS) => { + const deadline = performance.now() + waitS * 1000; + for (;;) { + const me = player(); + if (me === undefined) throw new Error('the scripted cook vanished from the view'); + const dx = target[0] - me.x; + const dy = target[1] - me.y; + const distance = Math.hypot(dx, dy); + if (distance <= band) { + if (settle) { + app.sendInput(null, false); + await sleep(150); // the settle: the view catches up + const again = player(); + if (again === undefined) throw new Error('the scripted cook vanished (settle)'); + const tail = Math.hypot(target[0] - again.x, target[1] - again.y); + if (tail <= band + 0.2) return; // the deceleration tail + continue; // the settle was not enough: walk again + } + return; // pass the waypoint (a stopped cook never leaves the band) + } + if (performance.now() > deadline) throw new Error('timed out walking'); + const length = distance > 1e-6 ? distance : 1.0; + app.sendInput({ x: dx / length, y: dy / length }, false); + await sleep(50); // ~20 Hz: one frame per tick — a tick with no + // frame brakes the body, so the feed must ride every tick window + // (walk and hold are sequential: the total is 20 Hz + the ping, + // under the 40/s token bucket) + } + }; + + const walk = async (waypoints, band, waitS) => { + for (let i = 0; i < waypoints.length - 1; i += 1) { + await walkTo(waypoints[i], 0.35, false, waitS); + } + await walkTo(waypoints[waypoints.length - 1], band, true, waitS); + }; + + const press = async () => { + app.sendInput(null, true); // the rising edge + await sleep(90); + app.sendInput(null, false); // the release + }; + + // The pick after a hold, self-healing: an explicit release before + // each attempt breaks a stuck hold; a missed attempt retries on a + // fresh rising edge. The 250 ms spread keeps a tight 3-frame cluster + // off the 40/s token bucket. An early pick (the last hold frame + // landed late) is honoured: no press while the item is in hand. + const pick = async (what, short) => { + for (let i = 0; i < 3; i += 1) { + if (carrying(short)) return; + app.sendInput(null, false); + await sleep(250); + await press(); + const deadline = performance.now() + 6000; + while (!carrying(short) && performance.now() < deadline) { + await sleep(50); + } + if (carrying(short)) return; + } + throw new Error('timed out: ' + what); + }; + + const holdUntil = async (predicate, waitS) => { + const deadline = performance.now() + waitS * 1000; + while (!predicate()) { + if (performance.now() > deadline) throw new Error('timed out holding'); + app.sendInput(null, true); + await sleep(50); // ~20 Hz: one frame per tick — a 1-tick gap resets + // the gate's hold (the work freezes); the next frame re-presses + // and re-claims it (20 Hz + the walk feed, under the 40/s bucket) + } + }; + + const waitUntil = async (predicate, what, waitS) => { + const deadline = performance.now() + waitS * 1000; + while (!predicate()) { + if (performance.now() > deadline) throw new Error('timed out: ' + what); + await sleep(50); + } + }; + + const stationReady = (kind) => { + const station = Object.values(app.view.stations).find((s) => s.k === kind); + return station !== undefined && station.s === 'ready'; + }; + const carrying = (short) => { + const me = player(); + return me !== undefined && me.h === short; + }; + + // The phase-06 waypoints and staging points, verbatim. + const SHELF_STAGE = [2.5, 1.5]; // facing LEFT at the shelf (1, 1) + const BOARD_STAGE = [3.5, 1.5]; // facing RIGHT at the board (4, 1) + const STOVE_STAGE = [7.5, 1.5]; // facing RIGHT at the stove (8, 1) + const PREP_STAGE = [3.5, 3.5]; // facing LEFT at the prep (2, 3) + const SERVE_STAGE = [8.8, 3.5]; // in front of the window (9, 3): a stop + // west of its centre keeps the window ahead of the facing plane + // (`not_facing`) + + // 1. The tomato, off the spawn shelf (the 3.5 corridor clears the + // blocking fixtures, the body's edge included). Self-healing: a + // dropped press frame is a silent no-op; the shelf is still full + // for a fresh rising edge. + await walk([[3.5, 7.5], [3.5, 1.5], SHELF_STAGE], 0.4, 60); + await pick('pick the tomato', 'tomato_raw'); + + // 2. Chop it (0.9 s of work). + await walk([BOARD_STAGE], 0.4, 45); + await holdUntil(() => stationReady('chop_board'), 30); + await pick('pick the chopped tomato', 'tomato_chopped'); + + // 3. Cook it (6 s of work). The row-2 corridor clears the board and + // the stove; the last leg comes in west-to-east along row 1. + await walk([[3.5, 2.5], [5.5, 2.5], [5.5, 1.5], STOVE_STAGE], 0.4, 60); + await holdUntil(() => stationReady('stove'), 40); + await pick('pick the cooked tomato', 'tomato_cooked'); + + // 4. Plate it (0.6 s of work). The prep's staging point is its east + // neighbour, reached along row 3. + await walk([[7.5, 3.5], PREP_STAGE], 0.4, 45); + await holdUntil(() => stationReady('prep'), 30); + await pick('pick the plated dish', 'tomato_plated'); + + // 5. Serve it — retried: a dropped press frame (or a `not_facing` + // stop) is a silent no-op; the dish stays in the hand for a fresh + // rising edge. The round ends on the win (target 20), so an + // `input` frame after the verdict is a wrong_state (5 close 4404) + // — the screen check stops the feed the moment it does. + await walk([SERVE_STAGE], 0.4, 45); + for (let i = 0; i < 3; i += 1) { + if (app.screen !== 'playing') break; + app.sendInput(null, false); + await sleep(250); + if (app.screen !== 'playing') break; + await press(); + await sleep(1500); + } + return true; +}""" + +#: The sampler's contract: it records `{at, x, y, frameT, frameX, +#: frameY, frameStep}` per animation frame — `at` the +#: `performance.now()` of the sample, `x`/`y` the *rendered* +#: (interpolated) position of the remote cook, `frameT` the buffer's +#: latest server frame's tick, and `frameStep` the displacement from +#: the previous sample's server frame — set only when the frame +#: advanced by exactly one tick (a sample that straddles two ticks is +#: not a per-tick displacement). It resolves on the tab's result screen +#: (or after 120 s, whatever it has). +SAMPLER_JS = """ +() => { + window.__uc_sampling = new Promise((resolve) => { + const samples = []; + const startedAt = performance.now(); + const sample = () => { + const uc = window.__uc; + const app = window.__undercooked; + if (uc !== undefined && app !== null && uc.lastView !== null) { + const view = uc.lastView; + const own = app.you === null ? -1 : app.you.seat; + const remote = (view.p || []).find((p) => p.pi !== own); + if (remote !== undefined) { + const latest = uc.buffer !== null ? uc.buffer.latest() : null; + let frameT = null; + let frameX = null; + let frameY = null; + let frameStep = null; + let frameJump = 1; + if (latest !== null && latest.p.length > 0) { + const latestRemote = latest.p.find((p) => p.pi !== own); + if (latestRemote !== undefined) { + frameT = latest.t; + frameX = latestRemote.x; + frameY = latestRemote.y; + const last = samples[samples.length - 1]; + if (last !== undefined && last.frameT !== null) { + const jump = frameT - last.frameT; + if (jump > 1) frameJump = jump; // a delivery hiccup + if (jump === 1) { + frameStep = Math.hypot(frameX - last.frameX, frameY - last.frameY); + } + } + } + } + samples.push({ + at: performance.now(), x: remote.x, y: remote.y, + frameT, frameX, frameY, frameStep, frameJump, + }); + } + } + if (document.querySelector('#result-word') !== null) { resolve(samples); return; } + if (performance.now() - startedAt > 120000) { resolve(samples); return; } + requestAnimationFrame(sample); + }; + sample(); + }); + return 'started'; +} +""" + +#: `MAX_SPEED * (1 / SNAPSHOT_HZ) * 1.35` — the task's per-frame bound. +STEP_BOUND = 3.5 * 0.05 * 1.35 +#: `MAX_SPEED * 1.35` — the rendered velocity bound (tiles / second). +SPEED_BOUND = 3.5 * 1.35 + + +def _assert_stream(samples: list[dict]) -> None: + """The two smoothness assertions (the sample's step bound, the + per-tick displacement bound).""" + # Enough samples to talk about a stream (the round ran well past + # the warm-up; 30 samples at ~60 fps is half a second). + assert len(samples) >= 30, f"only {len(samples)} samples recorded" + + # The per-tick displacement (consecutive server ticks, from the + # buffer's latest frames): the bound is + # `MAX_SPEED * (1/snapshot_hz) * 1.35` (0.23625). + frame_steps = [ + s["frameStep"] for s in samples if s.get("frameStep") is not None + ] + assert len(frame_steps) >= 30, ( + f"only {len(frame_steps)} one-tick displacements (the buffer was empty?)" + ) + worst_tick = max(frame_steps) + assert worst_tick <= STEP_BOUND, ( + f"a server tick moved the cook {worst_tick:.4f} tiles " + f"(> {STEP_BOUND:.4f} = MAX_SPEED * 1/snapshot_hz * 1.35)" + ) + + # The rendered positions: a sample's step is bounded by its gap's + # share of the 50 ms tick (a 100 ms gap moves at most two + # ticks) plus the 1.35 factor; a delivery hiccup (the latest + # frame's tick jumping ahead — a stall's resume) scales the + # allowance by the jump, and the velocity over a normal gap is + # at most `MAX_SPEED * 1.35` tiles/s (a hiccup's jump is not a + # velocity). + for i in range(1, len(samples)): + a, b = samples[i - 1], samples[i] + dt_ms = b["at"] - a["at"] + if dt_ms <= 0: + continue + jump = max(1, b.get("frameJump", 1)) + step = ((b["x"] - a["x"]) ** 2 + (b["y"] - a["y"]) ** 2) ** 0.5 + allowance = STEP_BOUND * max(dt_ms / 50.0 + 0.5, jump) + assert step <= allowance, ( + f"the rendered step {step:.4f} at a {dt_ms:.1f} ms gap (frame jump " + f"{jump}) exceeds the allowed {allowance:.4f}" + ) + if dt_ms >= 25.0 and jump <= dt_ms / 50.0 + 0.5: + velocity = step / (dt_ms / 1000.0) + assert velocity <= SPEED_BOUND, ( + f"the rendered velocity {velocity:.3f} tiles/s over a " + f"{dt_ms:.1f} ms gap exceeds {SPEED_BOUND:.3f}" + ) + + +def _join_ui(page, code: str) -> None: + """The keyboard-first join (the us03 machine).""" + page.locator("#join-code").focus() + page.keyboard.type(code) + page.keyboard.press("Enter") + + +def test_the_rendered_stream_is_bounded_and_converges_within_a_second( + browser: Browser, e2e_server +) -> None: + """The 60 ms-latency round: the scripted soup on the host's tab, + per-frame sampling of the second tab's rendered state, the per-tick + displacement bounded by `MAX_SPEED * (1/snapshot_hz) * 1.35`, the + rendered stream smooth, and both clients on the result screen + within 1 s of each other.""" + url = e2e_server(ROUND_LEVELS_DIR, sim_latency_ms=60).url + context_a, page_a, context_b, page_b = open_two_pages(browser, url) + try: + # The scripted tab drives the wire through the seam, so the + # keyboard module is off for it (`?controls=off`, the documented + # scripted-test mode): its 10 Hz zero-intent keep-alive would + # race the script's frames on the same seat (an `act=false` + # frame breaks the holds, a zero vector brakes the walks). + load_app(page_a, url + "?controls=off") + load_app(page_b, url) + page_a.evaluate("() => window.__undercooked.createLobby({ name: 'Ada' })") + page_a.locator("#room-code").wait_for(state="visible", timeout=20_000) + code = page_a.locator("#room-code").inner_text() + _join_ui(page_b, code) + page_b.locator("#room-code").wait_for(state="visible", timeout=20_000) + + page_b.get_by_role("button", name="Ready up").focus() + page_b.keyboard.press("Enter") + # The host's Start is enabled once the ready-up's re-broadcast + # lands (a visible-but-disabled Start swallows the Enter). + start_enabled = ( + "() => { const b = document.querySelector('#start-btn'); " + "return b !== null && !b.disabled; }" + ) + page_a.wait_for_function(start_enabled, timeout=20_000) + page_a.get_by_role("button", name="Start").focus() + page_a.keyboard.press("Enter") + + for page in (page_a, page_b): + page.locator("#kcanvas").wait_for(state="attached", timeout=30_000) + assert app_screen(page_a) == "playing" + assert app_screen(page_b) == "playing" + + # The second tab's sampler starts now (a browser-side rAF + # promise — live while the choreography's evaluate blocks this + # one); the host's tab drives the soup (the ~60 s run, the + # us03 margins). + assert page_b.evaluate(SAMPLER_JS) == "started" + page_a.set_default_timeout(300_000) + try: + page_a.evaluate(SOUP_CHOREOGRAPHY) + finally: + page_a.set_default_timeout(30_000) + + # The host is on the result screen (the round frame landed). + # The window covers the 60 s buzzer: with the injected 60 ms + # lag the host's *own* view trails the server truth, so the + # soup's final serve press can miss the window's reach zone + # and the round runs to the buzzer (a LOST is as good as a + # WON for the smoothness and convergence assertions). + page_a.set_default_timeout(90_000) + try: + page_a.locator("#result-word").wait_for(state="visible", timeout=65_000) + finally: + page_a.set_default_timeout(30_000) + assert app_screen(page_a) == "round_end" + convergence_start = time.monotonic() + + # The second tab's sampler resolves on *its* result screen. + page_b.set_default_timeout(120_000) + try: + samples = page_b.evaluate("() => window.__uc_sampling") + finally: + page_b.set_default_timeout(30_000) + convergence_s = time.monotonic() - convergence_start + + page_b.locator("#result-word").wait_for(state="visible", timeout=10_000) + assert app_screen(page_b) == "round_end" + assert convergence_s <= 1.0, ( + f"the second tab's result screen came {convergence_s:.2f} s after the host's " + "(the 60 ms latency round must converge within the second)" + ) + _assert_stream(samples) + finally: + context_a.close() + context_b.close() diff --git a/tests/fixtures/levels_us04/01_first_kitchen.yaml b/tests/fixtures/levels_us04/01_first_kitchen.yaml new file mode 100644 index 0000000..10e3173 --- /dev/null +++ b/tests/fixtures/levels_us04/01_first_kitchen.yaml @@ -0,0 +1,44 @@ +# tests/fixtures/levels_us04/01_first_kitchen.yaml — the phase-07 task-04 +# campaign: the shipped level 1's layout, with `target_score` 100 and +# `time_limit` 120 s. One served soup scores 44-66 +# (`round(40 x 1.0 x 1.1 x (1 + 0.5 x fraction))` — sim/scoring.py), so a +# *single* serve (44-66 < 100) does **not** end the round: the HUD's +# score text is observable changing after the serve (the task's "within +# 200 ms of the server event" assertion needs the round to outlive the +# serve), and the render budget is sampled over a longer window. + +id: 01_first_kitchen +name: First Kitchen +order: 1 +difficulty: 1 +time_limit: 120 +target_score: 100 +blurb: >- + The e2e campaign's kitchen: one dish at a time, open floor, no hazards. +grid: + - "############" + - "#T..C...S..#" + - "#..........#" + - "#.P......X.#" + - "#..........#" + - "#..K....O..#" + - "#..........#" + - "#..........#" + - "############" +tiles: + "#": {kind: wall} + ".": {kind: floor} + "T": {kind: floor, station: spawn_shelf, item: tomato} + "O": {kind: floor, station: spawn_shelf, item: onion} + "C": {kind: floor, station: chop_board} + "S": {kind: floor, station: stove} + "P": {kind: floor, station: prep} + "X": {kind: floor, station: serve} + "K": {kind: floor, station: sink} +spawns: [[2, 7], [9, 7]] +hazards: [] +orders: + pool: [soup] + concurrent: 1 + spawn_interval: [16, 20] + deadline: 60 diff --git a/tests/fixtures/levels_us04/recipes.yaml b/tests/fixtures/levels_us04/recipes.yaml new file mode 100644 index 0000000..2040a28 --- /dev/null +++ b/tests/fixtures/levels_us04/recipes.yaml @@ -0,0 +1,80 @@ +# levels/recipes.yaml — the campaign recipe book (PLAN §5/§8, A8). +# +# Every dish the game knows lives here; no Python enumerates recipes. The +# schema is `src/undercooked/sim/recipe.py` and the reference is +# docs/LEVELS.md — every model is strict (`extra="forbid"`), so a typo here +# fails the load loudly instead of silently defaulting. +# +# Step lists mirror PLAN §5's station pipelines: pick from the spawn shelf, +# chop (~0.9 s), cook on the stove, plate at the prep station, serve at the +# window. `seconds` feeds only the validator's greedy target-plausibility +# bound; the simulation (phase 04) re-enacts these steps per tick. + +# Catalog of the item kinds the campaign's dishes consume (documentation of +# intent; spawn shelves are what actually put items on the floor). +ingredients: [tomato, onion, lettuce, dough, patty, rice, fish, plate] + +recipes: + # tomato + onion -> chop -> pot on the stove -> plate + soup: + id: soup + name: Tomato Soup + base_score: 40 + ingredients: + - {kind: tomato, form: raw} + - {kind: onion, form: raw} + steps: + - {kind: pick, station: spawn_shelf} + - {kind: chop, station: chop_board, seconds: 0.9} + - {kind: cook, station: stove, seconds: 5.0} + - {kind: plate, station: prep, seconds: 0.4} + - {kind: serve, station: serve} + tags: [hot, soup] + + # lettuce + tomato -> chop -> plate; the fast, no-fire dish + salad: + id: salad + name: Garden Salad + base_score: 30 + ingredients: + - {kind: lettuce, form: raw} + - {kind: tomato, form: raw} + steps: + - {kind: pick, station: spawn_shelf} + - {kind: chop, station: chop_board, seconds: 0.9} + - {kind: plate, station: prep, seconds: 0.4} + - {kind: serve, station: serve} + tags: [cold, quick] + + # rice + onion straight onto the stove (PLAN §5: raw rice needs no board) + fried_rice: + id: fried_rice + name: Fried Rice + base_score: 50 + ingredients: + - {kind: rice, form: raw} + - {kind: onion, form: raw} + steps: + - {kind: pick, station: spawn_shelf} + - {kind: cook, station: stove, seconds: 6.0} + - {kind: plate, station: prep, seconds: 0.4} + - {kind: serve, station: serve} + tags: [hot, grain] + + # patty + dough + lettuce -> chop + pan on the stove -> plate. Defined now; + # later levels (phase 11) put it in an order pool. + burger: + id: burger + name: Chef's Burger + base_score: 70 + ingredients: + - {kind: patty, form: raw} + - {kind: dough, form: raw} + - {kind: lettuce, form: raw} + steps: + - {kind: pick, station: spawn_shelf} + - {kind: chop, station: chop_board, seconds: 0.9} + - {kind: cook, station: stove, seconds: 5.0} + - {kind: plate, station: prep, seconds: 0.5} + - {kind: serve, station: serve} + tags: [hot, stacked] diff --git a/tests/integration/test_app.py b/tests/integration/test_app.py index 1858efc..cf3e8ba 100644 --- a/tests/integration/test_app.py +++ b/tests/integration/test_app.py @@ -111,25 +111,30 @@ def test_root_serves_the_shell_html(client: TestClient) -> None: assert response.headers["content-type"].startswith("text/html") assert int(response.headers["content-length"]) > 500 # streamed from the file html = response.text - assert 'src="/static/js/app.js"' in html + assert 'src="/static/js/main.js"' in html # the composition root (task 03) assert 'href="/static/css/base.css"' in html - assert 'id="screen-lobby"' in html - assert 'aria-live="polite"' in html + assert 'href="/static/css/screens.css"' in html + assert 'id="screen-root"' in html assert ( 'name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"' in html ) def test_shell_offers_the_lobby_controls(client: TestClient) -> None: + """The controls are mounted by the landing screen module (phase 07 +task 02); the shell offers the screen root they mount into.""" html = client.get("/").text - assert 'id="create-lobby"' in html - assert 'id="join-form"' in html - assert 'id="join-code"' in html - assert 'inputmode="text"' in html - assert 'autocapitalize="characters"' in html - assert 'size="5"' in html # five characters wide: no 20-char intrinsic box - assert 'maxlength="5"' in html - assert 'for="join-code"' in html # the code input has a real label + assert 'id="screen-root"' in html + landing = client.get("/static/js/screens/landing.js") + assert landing.status_code == 200 + text = landing.text + assert 'id: "create-lobby"' in text + assert 'id: "join-form"' in text + assert 'id: "join-code"' in text + assert 'inputmode: "text"' in text + assert 'autocapitalize: "characters"' in text + assert "maxlength: String(CODE_LENGTH)" in text # five characters: no 20-char intrinsic box + assert 'for: "join-code"' in text # the code input has a real label def test_static_css_and_js_are_served_with_real_mime_types(client: TestClient) -> None: @@ -138,10 +143,24 @@ def test_static_css_and_js_are_served_with_real_mime_types(client: TestClient) - assert css.headers["content-type"].startswith("text/css") assert "--uc-hit: 44px" in css.text # the 44px hit-target contract lives in CSS - script = client.get("/static/js/app.js") + screens_css = client.get("/static/css/screens.css") + assert screens_css.status_code == 200 + assert screens_css.headers["content-type"].startswith("text/css") + + script = client.get("/static/js/main.js") assert script.status_code == 200 assert script.headers["content-type"].startswith("text/javascript") - assert "uc.token" in script.text + assert "createApp" in script.text # the composition root imports the machine + + app = client.get("/static/js/app.js") + assert app.status_code == 200 + assert app.headers["content-type"].startswith("text/javascript") + assert "createApp" in app.text + + identity = client.get("/static/js/identity.js") + assert identity.status_code == 200 + assert identity.headers["content-type"].startswith("text/javascript") + assert "uc.token" in identity.text # the per-tab token lives in identity.js (A16) def test_unknown_path_is_404(client: TestClient) -> None: diff --git a/tests/integration/test_supervisor.py b/tests/integration/test_supervisor.py index b2af310..b26757e 100644 --- a/tests/integration/test_supervisor.py +++ b/tests/integration/test_supervisor.py @@ -21,7 +21,7 @@ import pytest from undercooked.config import Settings from undercooked.server.clock import FakeClock from undercooked.server.codes import CROCKFORD -from undercooked.server.room import Room, RoomState +from undercooked.server.room import TEST_MODE_SEED, Room, RoomState from undercooked.server.rooms import ( GC_INTERVAL_S, SEAT_GRACE_S, @@ -473,3 +473,26 @@ async def test_gc_loop_runs_on_the_injected_cadence_and_is_cancellable() -> None with pytest.raises(asyncio.CancelledError): await next(iter(loop_task)) assert clock.now() == 3 * GC_INTERVAL_S # exactly three 5-second cycles + + +# ---------------------------------------------------------------- the test-mode seed + + +async def test_create_room_in_test_mode_uses_the_documented_fixed_seed() -> None: + """The test-only seam: under `UNDERCOOKED_TEST_MODE=1` the room is born + with the documented fixed seed, so a `?seed=20240701` page plays the + seed the room's sim actually draws (the e2e flagship's determinism).""" + supervisor, clock, _ = make_supervisor(test_mode=True) + room = supervisor.create_room(new_session("Ada", clock), "Ada") + assert room._seed_cursor == TEST_MODE_SEED + + +async def test_create_room_outside_test_mode_draws_a_fresh_seed() -> None: + """A production room never pins the seam's value: the base seed is a + fresh draw from `secrets.randbelow(_SEED_MODULUS)` (the range check — + a value equal to the constant is possible in principle, so the + assertion is the range, not an inequality).""" + supervisor, clock, _ = make_supervisor() # test_mode defaults to False + room = supervisor.create_room(new_session("Ada", clock), "Ada") + assert isinstance(room._seed_cursor, int) + assert 0 <= room._seed_cursor < 2**63 diff --git a/tests/integration/test_ws_handshake.py b/tests/integration/test_ws_handshake.py index e25e363..26e1044 100644 --- a/tests/integration/test_ws_handshake.py +++ b/tests/integration/test_ws_handshake.py @@ -195,6 +195,37 @@ async def test_happy_handshake_welcome_and_lobby(ws_server: Any) -> None: assert len(lobby2["seats"]) == 2 +async def test_a_new_seat_rebroadcasts_the_lobby_to_existing_members( + ws_server: Any, +) -> None: + """PLAN §7: the lobby is re-broadcast on lobby-state changes, not + only to the joiner — a member who is already in the room must see + the new seat appear (and a ghost rejoin revives it), so the lobby + screen in an open tab updates without a refresh.""" + with ws_server() as (url, _app): + code, _ = _create_room(url, "tok-host") + async with await _connect(url, code, "tok-host", name="Host") as host: + welcome = await _next_json(host) + assert welcome["type"] == "welcome" + await _next_json(host) # the initial lobby (one seat) + # The joiner arrives on their own socket: welcome + lobby for + # them, and a *re-broadcast* for the host. + async with await _connect( + url, code, "tok-joiner", name="Joiner" + ) as joiner: + j_welcome = await _next_json(joiner) + assert j_welcome["type"] == "welcome" + assert j_welcome["you"]["seat"] == 1 + j_lobby = await _next_json(joiner) + assert j_lobby["type"] == "lobby" + host_lobby = await _next_json(host) + assert host_lobby["type"] == "lobby" + assert host_lobby["code"] == code + assert len(host_lobby["seats"]) == 2 + assert host_lobby["seats"][1]["name"] == "Joiner" + assert host_lobby["seats"][1]["ready"] is False + + async def test_ping_before_hello_gets_pong(ws_server: Any) -> None: with ws_server() as (url, _app): code, _ = _create_room(url) diff --git a/tests/integration/test_ws_round_two_clients.py b/tests/integration/test_ws_round_two_clients.py index ca2cc3d..2a2a4e9 100644 --- a/tests/integration/test_ws_round_two_clients.py +++ b/tests/integration/test_ws_round_two_clients.py @@ -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() diff --git a/tests/unit/test_assets.py b/tests/unit/test_assets.py new file mode 100644 index 0000000..227dd98 --- /dev/null +++ b/tests/unit/test_assets.py @@ -0,0 +1,485 @@ +"""Phase 07 task 01: the in-repo SVG art, guarded like the sim's goldens. + +The art is a contract, not decoration: the browser can only draw what +`index.json` names, and `index.json` is what +`undercooked.tools.gen_art_index` derives from the *sim's own* vocabulary +(`undercooked.sim.model`'s enums) and pipeline data +(`undercooked.sim.stations`' `ACCEPTS` / `TRANSITIONS`). So these tests +ask the mechanical questions: + +* **the served output is faithful** — every served asset exists, is under + 40 KB, is a byte-for-byte copy of its `assets_src/` source, and the + committed `index.json` is exactly what the generator derives from the + source tree; +* **every id has a drawable** — each `index.json` id is a real + `` in its named file, sized by its `viewBox`, unique across the + whole set, and free of external `href`/`src`/`http` references; +* **the sim's vocabulary is fully drawn** — every `ItemKind` in every form + the sim can reach has a symbol (and the walk itself is pinned against + the sim's pipeline data), and every `ItemForm` value is covered; +* **the palette is one palette** — `assets/palette.css` and + `js/render/palette.js` carry identical hex values in both scopes + (default and colourblind), and every `var()` fallback the SVG art + relies on resolves to a palette entry; +* **the generator fails loudly** — run against a temporary copy, it + writes the index on a good tree and names a missing expected id, a + duplicated id, a drifted served copy, a missing file, and a symbol + that cannot be identified or sized. +""" + +from __future__ import annotations + +import json +import re +import shutil +import xml.etree.ElementTree as ET +from pathlib import Path + +import pytest + +from undercooked.sim.model import ItemForm, ItemKind +from undercooked.tools import gen_art_index + +pytestmark = pytest.mark.unit + +REPO_ROOT = Path(__file__).resolve().parents[2] +ASSETS_SRC = REPO_ROOT / "assets_src" +ASSETS_OUT = REPO_ROOT / "app" / "static" / "assets" +INDEX_PATH = ASSETS_OUT / "index.json" +SVG_FILES = gen_art_index.SVG_FILES +MAX_BYTES = 40 * 1024 + + +def load_index() -> dict[str, dict[str, object]]: + """The committed `index.json`, as a dict.""" + return json.loads(INDEX_PATH.read_text(encoding="utf-8")) + + +def symbols_of(path: Path) -> dict[str, ET.Element]: + """Every id-tagged `` in one SVG file.""" + root = ET.parse(path) # noqa: S314 - own hand-authored art files + return { + symbol_id: element + for element in root.iter() + if element.tag.endswith("symbol") and (symbol_id := element.get("id")) + } + + +def copy_tree(tmp_path: Path) -> tuple[Path, Path]: + """A throwaway (source, served) pair under ``tmp_path``.""" + src = tmp_path / "assets_src" + out = tmp_path / "app" / "static" / "assets" + shutil.copytree(ASSETS_SRC, src) + shutil.copytree(ASSETS_OUT, out) + return src, out + + +def remove_symbol(path: Path, symbol_id: str) -> None: + """Delete one `...` block from a file's text.""" + text = path.read_text(encoding="utf-8") + start = text.index(f'", start) + len("") + path.write_text(text[:start] + text[end:], encoding="utf-8") + + +# --------------------------------------------------------------------------- the served output + + +def test_served_assets_exist_and_are_small() -> None: + """Every served asset is present and under the 40 KB guard.""" + for name in [*SVG_FILES, "index.json", "palette.css"]: + path = ASSETS_OUT / name + assert path.is_file(), f"{path} is missing" + for name in SVG_FILES: + size = (ASSETS_OUT / name).stat().st_size + assert size <= MAX_BYTES, f"{name} is {size} bytes (limit {MAX_BYTES})" + + +def test_served_copies_match_the_source_art() -> None: + """`app/static/assets/` is a faithful copy of `assets_src/`.""" + assert gen_art_index.check_served(ASSETS_SRC, ASSETS_OUT) == [] + + +def test_index_json_is_what_the_generator_derives() -> None: + """The committed index cannot drift from the source tree.""" + assert gen_art_index.build_index(ASSETS_SRC) == load_index() + + +def test_every_index_id_has_a_symbol_with_matching_size() -> None: + """Each id is a real `` in its named file, sized by viewBox.""" + index = load_index() + assert len(index) >= 100, f"the art lost symbols: {len(index)}" + by_file: dict[str, dict[str, ET.Element]] = {} + for name in SVG_FILES: + by_file[name] = symbols_of(ASSETS_OUT / name) + for symbol_id, entry in index.items(): + file = str(entry["file"]) + assert file in by_file, f"{symbol_id} names an unknown file {file!r}" + symbol = by_file[file].get(symbol_id) + assert symbol is not None, f"no in {file}" + view_box = (symbol.get("viewBox") or "").split() + assert len(view_box) == 4, f"{symbol_id} has no parsable viewBox" + assert (float(view_box[2]), float(view_box[3])) == ( + entry["w"], + entry["h"], + ), f"{symbol_id}: index {entry['w']}x{entry['h']} vs viewBox {view_box}" + + +def test_no_symbol_id_is_duplicated() -> None: + ids: list[str] = [] + for name in SVG_FILES: + ids.extend(symbols_of(ASSETS_OUT / name)) + assert len(ids) == len(set(ids)), sorted({i for i in ids if ids.count(i) > 1}) + + +def test_the_art_references_nothing_external() -> None: + """No `http` outside the w3.org namespace declarations, no non-fragment href.""" + for name in SVG_FILES: + text = (ASSETS_OUT / name).read_text(encoding="utf-8") + for match in re.finditer(r"https?://[^\s\"'>]+", text): + assert match.group(0).startswith( + "http://www.w3.org/" + ), f"{name}: external reference {match.group(0)!r}" + for match in re.finditer(r"""(?:href|src)\s*=\s*"([^"]*)" """, text): + assert match.group(1).startswith( + "#" + ), f"{name}: non-fragment reference {match.group(1)!r}" + + +def test_svg_var_fallbacks_resolve_to_the_palette() -> None: + """A `var(--uc-*, #hex)` fallback in the art must be a palette entry.""" + palette = parse_js_palette( + (REPO_ROOT / "app" / "static" / "js" / "render" / "palette.js").read_text( + encoding="utf-8" + ) + )["default"] + fallbacks: list[tuple[str, str]] = [] + for file_name in SVG_FILES: + text = (ASSETS_OUT / file_name).read_text(encoding="utf-8") + fallbacks.extend(re.findall(r"var\(\s*(--uc-[\w-]+)\s*,\s*(#[0-9a-fA-F]{3,8})\s*\)", text)) + assert len(fallbacks) >= 32, "the cook symbols stopped using palette var() fallbacks" + for variable, fallback in fallbacks: + key = variable.removeprefix("--uc-").replace("-", "") + assert key in palette, f"{variable} is not a palette entry" + assert palette[key].lower() == fallback.lower(), ( + f"{variable} falls back to {fallback} but the palette says {palette[key]}" + ) + + +# --------------------------------------------------------------------------- sim-enum coverage + + +def test_every_reachable_item_form_has_a_symbol() -> None: + """The whole (kind, form) surface the sim can produce is drawn.""" + index = load_index() + for kind, forms in gen_art_index.reachable_item_forms().items(): + for form in forms: + symbol_id = f"item-{kind.value}-{form.value}" + assert symbol_id in index, f"no symbol for {symbol_id}" + covered = set().union(*gen_art_index.reachable_item_forms().values()) + assert covered == set(ItemForm), ( + f"ItemForm values with no symbol anywhere: {set(ItemForm) - covered}" + ) + + +def test_reachability_walks_the_sim_pipeline() -> None: + """The derivation is pinned: a sim-data change that moves the forms fails here. + + Rice never reaches the chop board (PLAN §5: "raw rice needs no board"), + so `CHOPPED` is unreachable for it; a plate is a prop, so it carries + exactly the clean/dirty/plated states; every other ingredient kind can + be raw, chopped, on the heat, cooked and burnt. + """ + forms = gen_art_index.reachable_item_forms() + assert forms[ItemKind.RICE] == frozenset( + {ItemForm.RAW, ItemForm.COOKING, ItemForm.COOKED, ItemForm.BURNT} + ) + assert forms[ItemKind.PLATE] == frozenset( + {ItemForm.RAW, ItemForm.DIRTY, ItemForm.PLATED} + ) + full = frozenset( + { + ItemForm.RAW, + ItemForm.CHOPPED, + ItemForm.COOKING, + ItemForm.COOKED, + ItemForm.BURNT, + } + ) + for kind in ( + ItemKind.TOMATO, + ItemKind.ONION, + ItemKind.LETTUCE, + ItemKind.DOUGH, + ItemKind.PATTY, + ItemKind.FISH, + ): + assert forms[kind] == full, f"{kind.value}: {sorted(f.value for f in forms[kind])}" + assert ItemForm.CHOPPED not in forms[ItemKind.RICE] + assert set(forms) == set(ItemKind), "the walk must cover every ItemKind" + + +# --------------------------------------------------------------------------- the palette + + +def parse_css_palette(text: str) -> dict[str, dict[str, str]]: + """The ``--uc-: `` pairs per CSS scope, keyed like the JS object. + + The CSS variable `--uc-cook-1` is the JS object's `cook1`: hyphens + are dropped, so the two files compare key-for-key. + """ + scopes: dict[str, dict[str, str]] = {} + for scope, pattern in ( + ("default", r":root\s*\{([^}]*)\}"), + ("colorblind", r'\[data-palette="cb"\]\s*\{([^}]*)\}'), + ): + block = re.search(pattern, text) + assert block is not None, f"palette.css lost its {scope} block" + pairs = re.findall(r"--uc-([\w-]+)\s*:\s*(#[0-9a-fA-F]{3,8})", block.group(1)) + assert len(pairs) == len(set(pairs)), f"duplicate custom property in {scope}" + scopes[scope] = {name.replace("-", ""): hex_value for name, hex_value in pairs} + return scopes + + +def parse_js_palette(text: str) -> dict[str, dict[str, str]]: + """The ``name: ""`` pairs of the two exported palette objects.""" + scopes: dict[str, dict[str, str]] = {} + for scope, export in ( + ("default", "DEFAULT_PALETTE"), + ("colorblind", "COLORBLIND_PALETTE"), + ): + block = re.search( + export + r"\s*=\s*Object\.freeze\(\{([^}]*)\}\)", text + ) + assert block is not None, f"palette.js lost its {export}" + pairs = re.findall(r'(\w+):\s*"(#[0-9a-fA-F]{3,8})"', block.group(1)) + assert len(pairs) == len(set(pairs)), f"duplicate key in {export}" + scopes[scope] = dict(pairs) + return scopes + + +def test_palette_css_and_js_agree() -> None: + """Both files, both scopes. + + The CSS colourblind block is an *override* of its `:root` values, so + the effective colourblind palette is `:root` plus the block; the JS + `COLORBLIND_PALETTE` gets the same merged values from a spread of + `DEFAULT_PALETTE` (or a full entry list, which is equally fine). + """ + js_text = (REPO_ROOT / "app" / "static" / "js" / "render" / "palette.js").read_text( + encoding="utf-8" + ) + css = parse_css_palette((ASSETS_OUT / "palette.css").read_text(encoding="utf-8")) + js = parse_js_palette(js_text) + assert len(css["default"]) >= 8, f"the default palette lost entries: {css['default']}" + assert css["default"] == js["default"], ( + f"default scope disagrees: CSS {css['default']} vs JS {js['default']}" + ) + # The override block only recolors the cooks, and it actually changes + # at least one of them. + assert set(css["colorblind"]) <= {f"cook{n}" for n in (1, 2, 3, 4)}, css["colorblind"] + assert any(css["colorblind"][k] != css["default"][k] for k in css["colorblind"]), ( + "the colourblind block overrides nothing" + ) + # The JS object spreads the default palette (`...DEFAULT_PALETTE`), so + # its effective colourblind values are default-plus-override, exactly + # like the CSS cascade — unless it lists all eight entries outright. + # A block that is neither is a drift. + colorblind_block = re.search( + r"COLORBLIND_PALETTE\s*=\s*Object\.freeze\(\{([^}]*)\}\)", js_text + ) + assert colorblind_block is not None + has_spread = "DEFAULT_PALETTE" in colorblind_block.group(1) + assert has_spread or len(js["colorblind"]) >= 8, ( + "COLORBLIND_PALETTE lists neither the spread nor the full entry set" + ) + effective_css_cb = {**css["default"], **css["colorblind"]} + effective_js_cb = {**js["default"], **js["colorblind"]} + assert effective_css_cb == effective_js_cb, ( + f"colourblind scope disagrees:\n" + f" CSS effective: {effective_css_cb}\n JS effective: {effective_js_cb}" + ) + + +def test_the_default_cook_colours_are_four_distinct_values() -> None: + palette = parse_css_palette((ASSETS_OUT / "palette.css").read_text(encoding="utf-8"))[ + "default" + ] + cooks = [palette[f"cook{n}"] for n in (1, 2, 3, 4)] + assert len(set(cooks)) == 4, cooks + + +# --------------------------------------------------------------------------- the generator + + +def test_generator_writes_the_index_on_a_good_tree(tmp_path: Path) -> None: + src, out = copy_tree(tmp_path) + assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 0 + written = json.loads((out / "index.json").read_text(encoding="utf-8")) + assert written == load_index() + assert len(written) >= 100 + + +def test_generator_defaults_come_from_the_repo( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + src, out = copy_tree(tmp_path) + monkeypatch.setattr(gen_art_index, "DEFAULT_SRC", src) + monkeypatch.setattr(gen_art_index, "DEFAULT_OUT", out) + assert gen_art_index.main([]) == 0 + assert (out / "index.json").is_file() + + +def test_generator_names_a_missing_expected_id( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + src, out = copy_tree(tmp_path) + for name in (src, out): + remove_symbol(name / "items.svg", "item-rice-cooking") + assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1 + report = capsys.readouterr().err + assert "item-rice-cooking" in report, report + assert "missing expected ids" in report + + +def test_generator_names_a_duplicated_symbol_id( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + src, out = copy_tree(tmp_path) + duplicate = '' + for name in (src, out): + path = name / "stations.svg" + text = path.read_text(encoding="utf-8") + path.write_text(text.replace("", f" {duplicate}\n"), encoding="utf-8") + assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1 + report = capsys.readouterr().err + assert "tile-floor" in report and "duplicated" in report, report + + +def test_generator_rejects_a_diverged_served_copy( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + src, out = copy_tree(tmp_path) + drifted = out / "ui.svg" + drifted.write_text(drifted.read_text(encoding="utf-8") + "", encoding="utf-8") + assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1 + report = capsys.readouterr().err + assert "ui.svg" in report and "differs" in report, report + + +def test_generator_rejects_a_missing_served_copy( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + src, out = copy_tree(tmp_path) + (out / "fx.svg").unlink() + assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1 + report = capsys.readouterr().err + assert "fx.svg" in report and "missing" in report, report + + +def test_generator_rejects_a_missing_source_file( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + src, out = copy_tree(tmp_path) + (src / "ui.svg").unlink() + assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1 + report = capsys.readouterr().err + assert "ui.svg" in report and "missing" in report, report + + +def test_generator_rejects_a_symbol_without_an_id( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + src, out = copy_tree(tmp_path) + anonymous = '' + for name in (src, out): + path = name / "fx.svg" + path.write_text( + path.read_text(encoding="utf-8").replace("", f" {anonymous}\n"), + encoding="utf-8", + ) + assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1 + report = capsys.readouterr().err + assert "fx.svg" in report and "has no id" in report, report + + +def test_generator_rejects_a_symbol_without_a_size( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + src, out = copy_tree(tmp_path) + unsized = '' + for name in (src, out): + path = name / "fx.svg" + path.write_text( + path.read_text(encoding="utf-8").replace("", f" {unsized}\n"), + encoding="utf-8", + ) + assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1 + report = capsys.readouterr().err + assert "fx-unsized" in report and "no viewBox" in report, report + + +def test_generator_reports_every_missing_id_not_just_the_first( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + src, out = copy_tree(tmp_path) + for name in (src, out): + remove_symbol(name / "items.svg", "item-tomato-burnt") + remove_symbol(name / "ui.svg", "ui-crown") + assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 1 + report = capsys.readouterr().err + assert "item-tomato-burnt" in report and "ui-crown" in report, report + + +def test_parse_symbols_walks_every_symbol_in_order(tmp_path: Path) -> None: + """The parser is total over the file, and the sizes come from viewBox.""" + symbols = gen_art_index.parse_symbols(ASSETS_SRC / "tiles.svg") + assert [symbol.id for symbol in symbols] == [ + "tile-floor", + "tile-floor-1", + "tile-floor-2", + "tile-floor-3", + "tile-floor-4", + "tile-counter", + "tile-wall", + "tile-void", + ] + assert all((symbol.w, symbol.h) == (32.0, 32.0) for symbol in symbols) + + +def test_symbol_size_falls_back_to_width_height_attributes(tmp_path: Path) -> None: + """A symbol without a viewBox may still be sized by width/height.""" + path = tmp_path / "sized.svg" + path.write_text( + '' + '' + "", + encoding="utf-8", + ) + assert gen_art_index.parse_symbols(path) == [ + gen_art_index.Symbol(id="a", w=16.0, h=8.0), + gen_art_index.Symbol(id="b", w=12.0, h=6.0), + ] + + +def test_expected_ids_cover_every_family() -> None: + """A sanity lock on the derivation: every family is represented.""" + ids = gen_art_index.expected_ids() + assert len(ids) >= 100 + assert any(i.startswith("tile-floor-") for i in ids) + assert any(i.startswith("station-stove-") for i in ids) + assert sum(1 for i in ids if i.startswith("item-")) >= 37 + assert sum(1 for i in ids if i.startswith("cook-1-") or i.startswith("cook-2-") + or i.startswith("cook-3-") or i.startswith("cook-4-")) == 32 + assert "cook-carry" in ids and "cook-stun" in ids + assert any(i.startswith("fx-") for i in ids) + assert any(i.startswith("ui-") for i in ids) + + +def test_the_served_index_and_the_source_tree_are_the_same_build( + tmp_path: Path, +) -> None: + """Re-running the generator on a copy of the committed tree is a no-op.""" + src, out = copy_tree(tmp_path) + assert gen_art_index.main(["--src", str(src), "--out", str(out)]) == 0 + assert (out / "index.json").read_bytes() == INDEX_PATH.read_bytes() diff --git a/tests/unit/test_client_enum_parity.py b/tests/unit/test_client_enum_parity.py new file mode 100644 index 0000000..6d1ac03 --- /dev/null +++ b/tests/unit/test_client_enum_parity.py @@ -0,0 +1,134 @@ +"""Phase 07 task 03 — the JS wire enums must stay equal to the Python ones. + +`app/static/js/shared/enums.js` is the client's vocabulary for the sim's +StrEnums and the protocol's `ErrorCode`. The wire carries the *string +values*; a drift — a value added to the Python side and forgotten in the +JS set, or removed from one side only — would show up in the client as +`undefined` sprites and dead FX. So the parity is a gate, not a +convention: this test reads the JS file (the same source +`app/static/index.html` serves — no copy that could drift), extracts +the frozen sets, and asserts set equality in **both directions** +(mirroring the doc-sync discipline of `tests/unit/test_protocol_doc_sync.py`). + +It checks the seven sets named by the task: `TILE_KIND`, +`STATION_KIND`, `ITEM_KIND`, `ITEM_FORM`, `FACING`, `EVENT_KIND`, +`ERROR_CODE`. Removing a value from either side of one of them makes +this test fail, naming the missing value. +""" + +from __future__ import annotations + +import json +import re +from enum import Enum +from pathlib import Path + +import pytest + +from undercooked.server.protocol import ErrorCode +from undercooked.sim.model import ( + EventKind, + Facing, + ItemForm, + ItemKind, + StationKind, + TileKind, +) + +pytestmark = pytest.mark.unit + +JS_ENUMS_PATH = ( + Path(__file__).resolve().parent.parent.parent + / "app" + / "static" + / "js" + / "shared" + / "enums.js" +) + + +def _js_set(source: str, name: str) -> set[str]: + """One frozen JS array, as a set of string literals (a tiny, stable + parser: `export const NAME = Object.freeze([ "a", "b" ]);`).""" + match = re.search( + rf"export const {re.escape(name)} = Object\.freeze\(\[(.*?)\]\)", + source, + re.DOTALL, + ) + assert match is not None, f"{name} is not a `Object.freeze([...])` export" + body = match.group(1) + assert "'" not in body, ( + f"{name} must use double-quoted literals (the test parses them as JSON)" + ) + # A JS array may end in a trailing comma; JSON may not. + body = re.sub(r",\s*$", "", body.rstrip()) + values = json.loads(f"[{body}]") + assert all(isinstance(v, str) for v in values), f"{name} has a non-string entry" + return set(values) + + +def _js_ordered(source: str, name: str) -> list[str]: + match = re.search( + rf"export const {re.escape(name)} = Object\.freeze\(\[(.*?)\]\)", + source, + re.DOTALL, + ) + assert match is not None, f"{name} is not a `Object.freeze([...])` export" + body = re.sub(r",\s*$", "", match.group(1).rstrip()) + return json.loads(f"[{body}]") + + +def _python_set(enum_cls) -> set[str]: + """A StrEnum's values — or, for a plain constant class (the + `ErrorCode`'s `Final` strings), the uppercase string attributes.""" + if isinstance(enum_cls, type) and issubclass(enum_cls, Enum): + return {member.value for member in enum_cls} + return { + value + for name, value in vars(enum_cls).items() + if name.isupper() and isinstance(value, str) + } + + +@pytest.fixture(scope="module") +def js_source() -> str: + assert JS_ENUMS_PATH.is_file(), f"the shared enum module is missing: {JS_ENUMS_PATH}" + return JS_ENUMS_PATH.read_text(encoding="utf-8") + + +@pytest.mark.parametrize( + ("js_name", "py_name", "enum_cls"), + [ + ("TILE_KIND", "TileKind", TileKind), + ("STATION_KIND", "StationKind", StationKind), + ("ITEM_KIND", "ItemKind", ItemKind), + ("ITEM_FORM", "ItemForm", ItemForm), + ("FACING", "Facing", Facing), + ("EVENT_KIND", "EventKind", EventKind), + ("ERROR_CODE", "ErrorCode", ErrorCode), + ], +) +def test_enum_parity(js_source, js_name, py_name, enum_cls) -> None: + """Set equality in both directions, naming the missing value.""" + js = _js_set(js_source, js_name) + py = _python_set(enum_cls) + only_py = py - js + only_js = js - py + assert only_py == set() and only_js == set(), ( + f"{js_name} (JS) drifted from {py_name} (Python): " + f"missing in the JS set: {sorted(only_py) or '—'}, " + f"missing in Python: {sorted(only_js) or '—'}" + ) + + +def test_facing_order_is_the_wire_index(js_source) -> None: + """The wire's `f` is an *index* into the facing list (PLAN §4: 0-3): + the JS array's order must be the enum's declaration order, not just + its value set — `decode.js`'s `facingName` maps against that order.""" + js_ordered = _js_ordered(js_source, "FACING") + py_ordered = [member.value for member in Facing] + assert js_ordered == py_ordered, ( + f"FACING order drifted (the wire index maps against it): " + f"JS {js_ordered} vs Python {py_ordered}" + ) + assert len(js_ordered) == 4 diff --git a/tests/unit/test_client_sprite_map.py b/tests/unit/test_client_sprite_map.py new file mode 100644 index 0000000..d5f82bc --- /dev/null +++ b/tests/unit/test_client_sprite_map.py @@ -0,0 +1,148 @@ +"""Phase 07 task 04 — the client's sprite map must stay equal to the sim's. + +`app/static/js/render/canvas.js` holds `REACHABLE_ITEM_FORMS` — the +(client's copy of) the (kind, form) pairs the simulation can produce, +used by `itemSpriteId` to pick an atlas id for every carried or +station-held item. The authoritative source is +`undercooked.tools.gen_art_index.reachable_item_forms()` (a fixpoint +walk over the sim's own `ACCEPTS` / `TRANSITIONS` tables — the same +walk that derives the atlas's expected ids). A drift — a sim change +that orphans a pair, or adds one the client forgot — would render as +a missing sprite. So the parity is a gate, not a convention: this test +reads the JS file (the same source `app/static/index.html` serves) and +asserts set equality in **both directions** (mirroring the +`test_client_enum_parity.py` discipline). + +It also pins the two things the renderer's *mapping functions* must +not break: every kind has a `raw` fallback (the unreachable pair's +sprite), and the JS table's kinds are exactly the sim's `ItemKind` +set (no kind left without a table row, no invented kind). +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +import pytest + +from undercooked.sim.model import ItemForm, ItemKind +from undercooked.tools.gen_art_index import reachable_item_forms + +pytestmark = pytest.mark.unit + +JS_CANVAS_PATH = ( + Path(__file__).resolve().parent.parent.parent + / "app" + / "static" + / "js" + / "render" + / "canvas.js" +) + + +def _js_table(source: str) -> dict[str, list[str]]: + """The `REACHABLE_ITEM_FORMS` object, as kind → [forms] (a tiny, + stable parser: `kind: ["raw", "chopped", …],` lines inside the + frozen object).""" + match = re.search( + r"export const REACHABLE_ITEM_FORMS = Object\.freeze\(\{(.*?)\}\);", + source, + re.DOTALL, + ) + assert match is not None, "REACHABLE_ITEM_FORMS is not a `Object.freeze({...})` export" + body = match.group(1) + assert "'" not in body, ( + "the table must use double-quoted literals (the test parses them as JSON)" + ) + table: dict[str, list[str]] = {} + for kind_match in re.finditer(r"(\w+):\s*\[(.*?)\]", body): + kind = kind_match.group(1) + forms = json.loads(f"[{kind_match.group(2)}]") + assert all(isinstance(f, str) for f in forms), f"{kind} has a non-string form" + assert kind not in table, f"duplicate kind {kind} in the table" + table[kind] = forms + return table + + +@pytest.fixture(scope="module") +def js_source() -> str: + assert JS_CANVAS_PATH.is_file(), f"the canvas module is missing: {JS_CANVAS_PATH}" + return JS_CANVAS_PATH.read_text(encoding="utf-8") + + +def test_table_covers_exactly_the_item_kinds(js_source) -> None: + """The table's keys are the sim's `ItemKind` values — no kind + missing a row (its unreachable pairs would fall back with no + `raw` row to lean on), no invented kind.""" + table = _js_table(js_source) + js_kinds = set(table) + py_kinds = {kind.value for kind in ItemKind} + missing = py_kinds - js_kinds + invented = js_kinds - py_kinds + assert not missing and not invented, ( + f"REACHABLE_ITEM_FORMS (JS) kind set drifted from ItemKind (Python): " + f"missing kinds: {sorted(missing) or '—'}, invented kinds: {sorted(invented) or '—'}" + ) + + +def test_forms_match_the_sim_fixpoint(js_source) -> None: + """Set equality per kind, in both directions, naming the pair.""" + table = _js_table(js_source) + sim = reachable_item_forms() + for kind in ItemKind: + js_forms = set(table.get(kind.value, [])) + py_forms = {form.value for form in sim[kind]} + only_py = py_forms - js_forms + only_js = js_forms - py_forms + assert not only_py and not only_js, ( + f"{kind.value} drifted: missing in the JS table: {sorted(only_py) or '—'}, " + f"missing in the sim: {sorted(only_js) or '—'}" + ) + + +def test_every_table_form_is_a_known_form(js_source) -> None: + """A form the sim's `ItemForm` does not define is a mapping bug + (the atlas has no `item--
` for it).""" + table = _js_table(js_source) + known = {form.value for form in ItemForm} + for kind, forms in table.items(): + unknown = set(forms) - known + assert not unknown, f"{kind} names unknown form(s): {sorted(unknown)}" + + +def test_every_kind_has_a_raw_fallback(js_source) -> None: + """`itemSpriteId`'s unreachable-pair fallback is `item--raw` — + every kind must therefore carry `raw` (the sim guarantees it: a + shelf-stocked kind starts raw, and the plate is raw- or + dirty-stocked; the test pins the renderer's contract, not the + sim's).""" + table = _js_table(js_source) + for kind, forms in table.items(): + assert "raw" in forms, f"{kind} lacks the `raw` fallback form" + + +def test_atlas_has_every_reachable_pair() -> None: + """The closure: every (kind, form) the sim can produce has an + atlas id (the `gen_art_index` contract, checked here against the + shipped `index.json` so a new pair without its sprite fails the + build, naming the pair).""" + index_path = ( + Path(__file__).resolve().parent.parent.parent + / "app" + / "static" + / "assets" + / "index.json" + ) + assert index_path.is_file(), f"the atlas index is missing: {index_path}" + index = json.loads(index_path.read_text(encoding="utf-8")) + ids = {entry["id"] if isinstance(entry, dict) else str(entry) for entry in index} + sim = reachable_item_forms() + missing = [ + f"item-{kind.value}-{form.value}" + for kind in ItemKind + for form in sim[kind] + if f"item-{kind.value}-{form.value}" not in ids + ] + assert not missing, f"the atlas lacks sprite(s): {missing}" diff --git a/tests/unit/test_docs_index.py b/tests/unit/test_docs_index.py index 895d073..8dc4794 100644 --- a/tests/unit/test_docs_index.py +++ b/tests/unit/test_docs_index.py @@ -207,6 +207,7 @@ def test_no_doc_file_exists_that_nothing_promises() -> None: def test_the_docs_on_disk_are_the_shipped_documents() -> None: """Pins today's tree so that adding a document is a deliberate, named act.""" assert written_docs() == { + "docs/CLIENT.md", "docs/DESIGN.md", "docs/DEPLOYMENT.md", "docs/LEVELS.md", diff --git a/tests/unit/test_harness.py b/tests/unit/test_harness.py index 585366c..c27719d 100644 --- a/tests/unit/test_harness.py +++ b/tests/unit/test_harness.py @@ -18,6 +18,12 @@ REPO_ROOT = Path(__file__).resolve().parents[2] HARNESS_MARKERS = ("unit", "integration", "e2e", "slow") TEST_SUBDIRS = ("unit", "integration", "sim", "e2e") +#: The technical e2e suites that a phase file names but that belong to no +#: single user story (e.g. the phase-07 art/atlas guard). Every entry is a +#: deliberate, named exception to the story-naming contract in +#: `tests/e2e/README.md`; a new technical suite lands here first. +E2E_TECHNICAL_SUITES = frozenset({"test_atlas_loads.py"}) + def test_package_metadata() -> None: assert undercooked.__version__ == "0.1.0" @@ -59,12 +65,14 @@ def test_fixture_levels_dir_exists(isolated_levels_dir: None) -> None: def test_e2e_tests_follow_the_story_naming_convention() -> None: - """`pytest -m e2e --collect-only` selects only `test_usNN_*.py` story suites. + """`pytest -m e2e --collect-only` selects story suites (`test_usNN_*.py`) and + the allowlisted technical suites only. - Task 01 could only assert "the marker collects nothing yet". Now that the first - browser suite exists the durable invariant is the naming contract in - `tests/e2e/README.md`: a story suite that is not named for its story is one that - nobody can find, and one that cannot be collected is one that never runs. + The durable invariant is the naming contract in `tests/e2e/README.md`: a + story suite that is not named for its story is one that nobody can find, + and a suite that cannot be collected is one that never runs. Technical + suites (guards for a phase's machinery, named by the phase files) are + the documented exception, tracked in `E2E_TECHNICAL_SUITES`. """ result = subprocess.run( # fixed argv, repo's own interpreter [ @@ -92,4 +100,5 @@ def test_e2e_tests_follow_the_story_naming_convention() -> None: assert files, f"the e2e marker selected nothing:\n{result.stdout}" for path in files: assert path.startswith("tests/e2e/"), path - assert re.fullmatch(r"test_us\d{2}_.+\.py", Path(path).name), path + name = Path(path).name + assert re.fullmatch(r"test_us\d{2}_.+\.py", name) or name in E2E_TECHNICAL_SUITES, path diff --git a/tests/unit/test_no_external_assets.py b/tests/unit/test_no_external_assets.py index 67f7377..2df0bfb 100644 --- a/tests/unit/test_no_external_assets.py +++ b/tests/unit/test_no_external_assets.py @@ -257,7 +257,9 @@ def test_shipped_index_html_loads_only_same_origin_assets() -> None: html = (STATIC_DIR / "index.html").read_text(encoding="utf-8") tags = re.findall(r"<(script|link|img)\b", html, re.I) assert len(tags) >= 3, "index.html no longer declares the assets this test guards" - assert 'src="/static/js/app.js"' in html + # The page's single entry module (the composition root, phase 07 task 03; + # `main.js` imports `app.js` and the render/decode modules). + assert 'src="/static/js/main.js"' in html assert scan_element_urls(STATIC_DIR / "index.html", html) == [] @@ -321,6 +323,18 @@ def test_a_planted_html_reference_fails_the_scan( '', "absolute URL", ), + ( + ".svg", + '' + '', + "absolute URL", + ), + ( + ".svg", + '' + '', + "absolute URL", + ), (".html", 'out', "absolute URL"), ], ) @@ -364,6 +378,13 @@ def test_a_planted_hosted_library_token_fails_the_scan( '' '', ), + ( + ".svg", + '' + '', + ), (".js", "// a comment about docs/PROTOCOL.md and a/b/c paths\nexport const ok = 1;\n"), (".js", 'const ws = new WebSocket(`${location.origin}/ws/${code}`);\n'), (".js", 'fetch("/static/api/levels");\n'),