phase: 07_canvas_client_and_hud

**Final report — phase 07 final verification pass**

- **Verified:** all 5 tasks present in `complete/`; app boots (`/healthz` 200, `/` 200, static + atlas + `/api/levels` served); `docs/CLIENT.md` module map matches the on-disk `app/static/` tree; no code defects found.
- **Tests/lint/coverage:** `bash .agents/validate.sh` → ruff clean, pyright 0 errors, `undercooked-levels validate` pass, **full pytest 1838 passed, 97.21% coverage (floor 90%)**, slow sim stage 2 passed, repo hygiene clean. `uv run pytest tests/e2e -q` → **53 passed** (all 8 suites added this phase).
- **Criteria:** (1) two tabs create→join→ready→start→serve one dish, keyboard only — green via `test_us01/02/03/04` ✔; (2) e2e green ✔; (3) canvas non-blank, pixels change after a move — `test_us04_render_smoke` ✔; (4) HUD score within 200 ms of the `served` event — asserted, green ✔; (5) zero console errors + zero off-origin requests — `test_atlas_loads` / `test_us01_landing_surface` ✔; (6) `docs/CLIENT.md` matches layout ✔; (7) 100% suite + 97.21% ✔; (8) docs pass below ✔; (9) no regression in completed phases (all prior suites green) ✔.
- **README.md fixed:** status still said "next pending phase is 07" → now 07 complete, 08 pending; the phase-07 client text buried in the phase-05 bullet → its own "canvas client, art, HUD (phase 07)" bullet; stale "What does not exist yet" (claimed movement/controls missing) → rewritten to phases 08–14; "once the room flow ships" → "live in the browser today"; Layout `app/static/` "renderer arrives with the rest of phase 07" → full shipped-client map; Layout `docs/` "CLIENT lands with phase 07" → `CLIENT.md` listed; added `gen_art_index.py` Layout entry.
- **AGENTS.md fixed:** "the eight screens" (7 listed) → "the seven screens" (+ `dom.js` is the shared builder, not a screen); e2e naming line → notes the non-story guard `test_atlas_loads.py`.
- **Decisions:** no code changes were needed; only the two doc fixes above. Docs re-verified by `test_docs_commands`/`test_docs_index` (44 passed) plus the final full gate.
- **Next pending phase:** `08_client_prediction_and_touch`.
This commit is contained in:
2026-10-10 18:03:52 -04:00
parent f26849c1f3
commit 95ca3fd1fa
78 changed files with 13650 additions and 259 deletions
+7 -5
View File
@@ -130,9 +130,10 @@
inbound field against the room's state (`server/protocol.py`). inbound field against the room's state (`server/protocol.py`).
- **Anonymous by design.** No accounts, no emails, no cookies with personal data. - **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 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`, server mints one, `crypto.getRandomValues` + base64url in
which is the same entropy and the only option in a browser) held in `app/static/js/identity.js`, which is the same entropy and the only option
`sessionStorage`. Names are sanitized, length-capped, non-authoritative. 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 - **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 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 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` | | 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` | | 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` | | 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`) | | 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` | | 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` | | 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` | | 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` | | The pipeline gate itself | `.agents/validate.sh` |
| User-visible flow contract | `.agents/user_stories/US-XX_*.md` | | 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 — 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 plus the replay harness: committed goldens in `tests/goldens/`, scripts in
`tests/fixtures/replays/`, and the `slow` perf budgets — the random-walk `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 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 points `UNDERCOOKED_LEVELS_DIR` at `tests/fixtures/levels/` so no test silently reads the
shipped `levels/`. shipped `levels/`.
+121 -40
View File
@@ -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) serve scoring, freshness and the dirty loop, the round verdict with stars)
and **05** (anonymous lobbies and room lifecycle: the `RoomSupervisor`, and **05** (anonymous lobbies and room lifecycle: the `RoomSupervisor`,
the guarded `Room` FSM, the in-memory session store, the lifecycle 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 `/ws/{code}` transport, the versioned JSON wire contract with inbound
guardrails, input ingestion with sequence discipline, the quantized guardrails, input ingestion with sequence discipline, the quantized
snapshot stream, heartbeats and seat grace — `docs/PROTOCOL.md`) snapshot stream, heartbeats and seat grace — `docs/PROTOCOL.md`) and
are **complete**; the next pending phase is **07** (the canvas client, art and HUD: the in-repo SVG atlas, the
`07_canvas_client_and_hud`. What screen state machine, the snapshot buffer with 100 ms interpolation,
runs today: 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 - **The service.** The Python toolchain is locked (`uv.lock`), configuration is a
single typed, validated `Settings` object (`DEBUGPY` support included), and 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 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` one room's crash closing only itself (the test-only `_test_step_override`
seam on `Room`; contract: `docs/ROOMS.md` "Failure and expiry" and seam on `Room`; contract: `docs/ROOMS.md` "Failure and expiry" and
"The lifecycle walkthrough"). The client buttons in the page still "The lifecycle walkthrough").
answer "Not implemented until phase 07." until the phase-07 client
wiring replaces them.
- **The WebSocket transport (phase 06, task 02).** The room core is on - **The WebSocket transport (phase 06, task 02).** The room core is on
the wire: `GET /api/stats` answers the ops census (`rooms` / `players` the wire: `GET /api/stats` answers the ops census (`rooms` / `players`
— the occupied seats / `sockets` — live WebSocket connections / — the occupied seats / `sockets` — live WebSocket connections /
@@ -334,24 +334,56 @@ runs today:
merged snapshot view (`expect` / `collect` / `send_model`). The merged snapshot view (`expect` / `collect` / `send_model`). The
contract, the test seams and the budget table with the measured contract, the test seams and the budget table with the measured
values are `docs/PROTOCOL.md` ("Testing the protocol"). 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 - **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_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_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 `tests/unit/test_docs_index.py`, and `bash .agents/validate.sh` is the gate the
phase pipeline runs after every task. phase pipeline runs after every task.
What does not exist yet: the hazards (phase 10) and the What does not exist yet: client prediction, reconciliation and the
in-browser touch surface (phase 08), the two-browser co-op E2E with
playable Canvas renderer (07 — the phase-06 WebSocket transport, including contested-resource arbitration (phase 09), the hazards and their
the snapshot stream, is live but the client still answers "Not client FX (phase 10), the rest of the ten-level campaign (phase 11),
implemented until phase 07."). Execute phases with the reconnect resilience and the 200-room stress run (phase 12), endless
`phased-execution` skill mode with the session scoreboard (phase 13), and the accessibility
(`auto-phase.sh`); it always takes the lowest-numbered directory still in polish and hardening pass (phase 14). Execute phases with the
`.agents/phases/todo/`. `phased-execution` skill (`auto-phase.sh`); it always takes the
lowest-numbered directory still in `.agents/phases/todo/`.
## How to play ## 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 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 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 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, 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). 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 The page does that loop end to end: it loads,
shell and the phase-06 WebSocket transport: normalises a typed code to five uppercase alphanumerics (codes
the page loads, normalises a typed code to five uppercase alphanumerics (codes themselves are Crockford base32, minted by the room supervisor —
themselves are Crockford base32, minted by the room supervisor — `server/rooms.py`, `server/rooms.py`, phase 05, also testable over `POST /api/rooms` /
phase 05, which is live and testable over `POST /api/rooms` / `GET /api/rooms/{code}` / `GET /api/rooms/{code}` / `POST /api/rooms/{code}/join` /
`POST /api/rooms/{code}/join` / `GET /api/session`), a `WS /ws/{code}` `GET /api/session`), the landing's **Create lobby** / **Join with code**
client can already create a room over HTTP, handshake (`hello` → `welcome` open a `WS /ws/{code}` socket (`hello` → `welcome` + `lobby`), the lobby
+ `lobby`), ready up and keep alive — the full contract, including the shows the code, the level picker (fed by `/api/levels`), the ready and
guardrails and the close codes, is `docs/PROTOCOL.md` — but the lobby start controls and the seat chips, the 3-2-1 countdown runs, and the
buttons in the page still answer "Not implemented round-end screen reports the verdict — the full contract, including the
until phase 07." until the phase-07 client guardrails and the close codes, is `docs/PROTOCOL.md`, the client's own
wiring replaces them. The ready-up, the 20 Hz tick ownership, the session store contract is `docs/CLIENT.md`. The playable Canvas renderer, the HUD and
(the unlocks and best scores, in memory — a server restart clears all the desktop keyboard controls are live (below; the in-game `?` help
progression) and the lifecycle integration suite are live. Movement keys and overlay renders the same rows) — the touch controls arrive with phase 08.
touch controls are defined by the phase-07/08 client and documented in The ready-up, the
`docs/CLIENT.md` once that phase writes it. 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) ## 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 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 new 03_riverboat # scaffold a level from the recipe book
uv run undercooked-levels axes # escalation-axis table (phase 11's curve) 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 `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, 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 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` [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: 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, src/undercooked/tools/levels.py the `undercooked-levels` author CLI — validate, list,
show [--grid], new, axes; same loader, same rules, no show [--grid], new, axes; same loader, same rules, no
hard-coded content list (docs/LEVELS.md "Authoring workflow") 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 src/undercooked/server/app.py app factory, `/healthz`, `/`, `/static`, mounts the
content API and the room API; installs the supervisor content API and the room API; installs the supervisor
src/undercooked/server/clock.py injectable time: `Clock`, `SystemClock`, `FakeClock` 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 .env.example every configuration variable, with its default
levels/ data-driven content (YAML): the recipe book (A8) and the levels/ data-driven content (YAML): the recipe book (A8) and the
first two campaign levels (01_first_kitchen, 02_double_shift) first two campaign levels (01_first_kitchen, 02_double_shift)
app/static/ browser client: the ES-module shell today; Canvas 2D renderer assets_src/ the hand-authored SVG art (tiles, stations, items, cooks,
and this repo's SVG art arrive with phase 07 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 tests/ unit/, integration/, e2e/ (one Playwright suite per story) and
sim/ (the pure kernel: grid, entities, purity guard, validation, sim/ (the pure kernel: grid, entities, purity guard, validation,
the seeded RNG and the tick; plus the replay harness — committed 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 & SIMULATION.md (the tick contract), ROOMS.md (rooms &
lobby lifecycle), PROTOCOL.md (the v1 wire contract — lobby lifecycle), PROTOCOL.md (the v1 wire contract —
message tables, error/close codes, limits, the full-round message tables, error/close codes, limits, the full-round
sequence, the test seams and the budget table), TESTING.md sequence, the test seams and the budget table), CLIENT.md
(how this project (the client module map, the render loop, the snapshot
is verified), DEPLOYMENT.md; CLIENT lands with buffer, the input, the assets), TESTING.md (how this
phase 07 (the index is PLAN.md §13) project is verified), DEPLOYMENT.md (the index is
PLAN.md §13)
.agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan .agents/ PLAN.md, user_stories/, phases/, validate.sh — the phased-execution plan
``` ```
+116
View File
@@ -0,0 +1,116 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<!-- Undercooked cooks (phase 07, task 01). Four bodies (cook-1..4),
each 4 facings x 2 walk frames; the torso colour is
`currentColor`, so a <use> recolors it: in the DOM through the
page's cook-colour CSS custom properties, in the canvas through
the var() fallback (app/static/assets/palette.css). The carry
overlay and the stun frame are shared. -->
<defs>
<ellipse id="shadow" cx="16" cy="29.5" rx="8" ry="1.8" fill="#1a1713" opacity="0.18"/>
<g id="legs-0">
<rect x="12.4" y="23" width="3.2" height="6" rx="1.4" fill="#4a4238"/>
<rect x="16.4" y="23" width="3.2" height="6" rx="1.4" fill="#4a4238"/>
</g>
<g id="legs-1">
<path d="M13.5 23 L10.8 28.8" stroke="#4a4238" stroke-width="3.2" stroke-linecap="round" fill="none"/>
<path d="M18.5 23 L21.2 28.8" stroke="#4a4238" stroke-width="3.2" stroke-linecap="round" fill="none"/>
</g>
<g id="chef-torso">
<rect x="10" y="14" width="12" height="11" rx="3" fill="currentColor"/>
<path d="M12 18.5 h8 v3.5 a4 3.4 0 0 1 -8 0 z" fill="#f2ede3"/>
</g>
<g id="chef-hat">
<path d="M11.4 8.6 a4.6 4.2 0 0 1 9.2 0 z" fill="#fbf7ee"/>
<circle cx="16" cy="5.4" r="2.8" fill="#fbf7ee"/>
<path d="M11.4 8.6 h9.2" stroke="#e2dbcb" stroke-width="1.2" stroke-linecap="round" fill="none"/>
</g>
<g id="chef-head-down">
<circle cx="16" cy="11" r="4.6" fill="#f2c9a0"/>
<use href="#chef-hat"/>
<circle cx="14.2" cy="10.6" r="0.75" fill="#3a322a"/>
<circle cx="17.8" cy="10.6" r="0.75" fill="#3a322a"/>
<path d="M15.2 13 q0.8 0.7 1.6 0" stroke="#c98a5e" stroke-width="0.9" stroke-linecap="round" fill="none"/>
</g>
<g id="chef-head-up">
<circle cx="16" cy="11" r="4.6" fill="#f2c9a0"/>
<use href="#chef-hat"/>
<path d="M12.6 13.4 q3.4 2 6.8 0" stroke="#e0b184" stroke-width="1" stroke-linecap="round" fill="none"/>
</g>
<g id="chef-head-right">
<circle cx="16" cy="11" r="4.6" fill="#f2c9a0"/>
<use href="#chef-hat"/>
<circle cx="18.9" cy="10.6" r="0.75" fill="#3a322a"/>
<path d="M18.4 13.2 q0.8 0.6 1.6 0" stroke="#c98a5e" stroke-width="0.9" stroke-linecap="round" fill="none"/>
</g>
<g id="chef-head-left">
<circle cx="16" cy="11" r="4.6" fill="#f2c9a0"/>
<use href="#chef-hat"/>
<circle cx="13.1" cy="10.6" r="0.75" fill="#3a322a"/>
<path d="M12 13.2 q0.8 0.6 1.6 0" stroke="#c98a5e" stroke-width="0.9" stroke-linecap="round" fill="none"/>
</g>
<g id="shape-up-0"><use href="#shadow"/><use href="#legs-0"/><use href="#chef-torso"/><use href="#chef-head-up"/></g>
<g id="shape-up-1"><use href="#shadow"/><use href="#legs-1"/><use href="#chef-torso"/><use href="#chef-head-up"/></g>
<g id="shape-right-0"><use href="#shadow"/><use href="#legs-0"/><use href="#chef-torso"/><use href="#chef-head-right"/></g>
<g id="shape-right-1"><use href="#shadow"/><use href="#legs-1"/><use href="#chef-torso"/><use href="#chef-head-right"/></g>
<g id="shape-down-0"><use href="#shadow"/><use href="#legs-0"/><use href="#chef-torso"/><use href="#chef-head-down"/></g>
<g id="shape-down-1"><use href="#shadow"/><use href="#legs-1"/><use href="#chef-torso"/><use href="#chef-head-down"/></g>
<g id="shape-left-0"><use href="#shadow"/><use href="#legs-0"/><use href="#chef-torso"/><use href="#chef-head-left"/></g>
<g id="shape-left-1"><use href="#shadow"/><use href="#legs-1"/><use href="#chef-torso"/><use href="#chef-head-left"/></g>
</defs>
<symbol id="cook-1-up-0" viewBox="0 0 32 32"><use href="#shape-up-0" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-up-1" viewBox="0 0 32 32"><use href="#shape-up-1" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-right-0" viewBox="0 0 32 32"><use href="#shape-right-0" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-right-1" viewBox="0 0 32 32"><use href="#shape-right-1" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-down-0" viewBox="0 0 32 32"><use href="#shape-down-0" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-down-1" viewBox="0 0 32 32"><use href="#shape-down-1" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-left-0" viewBox="0 0 32 32"><use href="#shape-left-0" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-left-1" viewBox="0 0 32 32"><use href="#shape-left-1" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-2-up-0" viewBox="0 0 32 32"><use href="#shape-up-0" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-up-1" viewBox="0 0 32 32"><use href="#shape-up-1" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-right-0" viewBox="0 0 32 32"><use href="#shape-right-0" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-right-1" viewBox="0 0 32 32"><use href="#shape-right-1" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-down-0" viewBox="0 0 32 32"><use href="#shape-down-0" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-down-1" viewBox="0 0 32 32"><use href="#shape-down-1" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-left-0" viewBox="0 0 32 32"><use href="#shape-left-0" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-left-1" viewBox="0 0 32 32"><use href="#shape-left-1" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-3-up-0" viewBox="0 0 32 32"><use href="#shape-up-0" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-up-1" viewBox="0 0 32 32"><use href="#shape-up-1" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-right-0" viewBox="0 0 32 32"><use href="#shape-right-0" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-right-1" viewBox="0 0 32 32"><use href="#shape-right-1" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-down-0" viewBox="0 0 32 32"><use href="#shape-down-0" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-down-1" viewBox="0 0 32 32"><use href="#shape-down-1" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-left-0" viewBox="0 0 32 32"><use href="#shape-left-0" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-left-1" viewBox="0 0 32 32"><use href="#shape-left-1" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-4-up-0" viewBox="0 0 32 32"><use href="#shape-up-0" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-up-1" viewBox="0 0 32 32"><use href="#shape-up-1" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-right-0" viewBox="0 0 32 32"><use href="#shape-right-0" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-right-1" viewBox="0 0 32 32"><use href="#shape-right-1" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-down-0" viewBox="0 0 32 32"><use href="#shape-down-0" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-down-1" viewBox="0 0 32 32"><use href="#shape-down-1" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-left-0" viewBox="0 0 32 32"><use href="#shape-left-0" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-left-1" viewBox="0 0 32 32"><use href="#shape-left-1" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<!-- The carrying overlay: one arm raised with the item slot ring; the
renderer draws the item sprite in the slot. -->
<symbol id="cook-carry" viewBox="0 0 32 32">
<path d="M19 17 l7 -5.5" stroke="#f2c9a0" stroke-width="3" stroke-linecap="round" fill="none"/>
<circle cx="27" cy="11" r="2.4" fill="#f2c9a0"/>
<circle cx="27" cy="9.6" r="4.6" fill="none" stroke="#cfc5b5" stroke-width="1" stroke-dasharray="2.4 2.2"/>
</symbol>
<!-- The stun frame: a dizzy swirl and a star. -->
<symbol id="cook-stun" viewBox="0 0 32 32">
<path d="M19.5 12.5 a3.5 3.5 0 1 1 -3.5 -3.5 a5 5 0 1 0 5 5" fill="none" stroke="#3a322a" stroke-width="1.4" stroke-linecap="round"/>
<path d="M16 2.6 l1 2.2 2.4 0.3 -1.8 1.6 0.5 2.3 -2.1 -1.2 -2.1 1.2 0.5 -2.3 -1.8 -1.6 2.4 -0.3 z" fill="#ffd95e"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 8.3 KiB

+39
View File
@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<!-- Undercooked transient FX (phase 07, task 01). One symbol per event
the sim can emit; the renderer plays them at the event position. -->
<symbol id="fx-chop" viewBox="0 0 32 32">
<path d="M10 16 L4 16 M16 10 L16 4 M22 16 L28 16 M12.5 11.5 L8.5 7.5 M19.5 11.5 L23.5 7.5" stroke="#ffd95e" stroke-width="2" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="fx-steam" viewBox="0 0 32 32">
<path d="M11 22 q1.4 -2.4 0 -4.8 q-1.4 -2.4 0 -4.8" stroke="#e8e2d4" stroke-width="1.8" stroke-linecap="round" fill="none" opacity="0.9"/>
<path d="M16 24 q1.4 -2.4 0 -4.8 q-1.4 -2.4 0 -4.8" stroke="#e8e2d4" stroke-width="1.8" stroke-linecap="round" fill="none" opacity="0.75"/>
<path d="M21 22 q1.4 -2.4 0 -4.8 q-1.4 -2.4 0 -4.8" stroke="#e8e2d4" stroke-width="1.8" stroke-linecap="round" fill="none" opacity="0.9"/>
</symbol>
<symbol id="fx-flame" viewBox="0 0 32 32">
<path d="M16 3.5 c3.4 3 5.4 5.6 5.4 8.6 a5.4 5.4 0 0 1 -10.8 0 c0 -3 2 -5.6 5.4 -8.6 z" fill="#ff5a4e"/>
<path d="M16 9 c1.8 1.8 3 3.2 3 4.8 a3 3 0 0 1 -6 0 c0 -1.6 1.2 -3 3 -4.8 z" fill="#ffa63d"/>
</symbol>
<symbol id="fx-splash" viewBox="0 0 32 32">
<path d="M16 8 c2 2.6 3.4 4.6 3.4 6.6 a3.4 3.4 0 0 1 -6.8 0 c0 -2 1.4 -4 3.4 -6.6 z" fill="#6fa8c9"/>
<path d="M8 18 q-1.5 2 0 3.4 M24 18 q1.5 2 0 3.4 M12 24.5 q-1 1.6 0 2.6 M20 24.5 q1 1.6 0 2.6" stroke="#6fa8c9" stroke-width="1.6" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="fx-deny" viewBox="0 0 32 32">
<path d="M9 9 L23 23 M23 9 L9 23" stroke="#ff5a4e" stroke-width="4" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="fx-score" viewBox="0 0 32 32">
<path d="M16 3 l2.6 6.4 6.9 0.8 -5.2 4.6 1.5 6.8 -5.8 -3.6 -5.8 3.6 1.5 -6.8 -5.2 -4.6 6.9 -0.8 z" fill="#ffd95e"/>
<path d="M16 11.5 v7 M12.8 15 h6.4" stroke="#8a6b45" stroke-width="1.8" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="fx-serve" viewBox="0 0 32 32">
<path d="M10 8 l1.2 2.8 2.8 1.2 -2.8 1.2 L10 16 l-1.2 -2.8 L6 12 l2.8 -1.2 z" fill="#fff7d6"/>
<path d="M22 14 l1 2.4 2.4 1 -2.4 1 L22 20.8 l-1 -2.4 -2.4 -1 2.4 -1 z" fill="#fff7d6"/>
<path d="M17 22 l0.8 1.8 1.8 0.8 -1.8 0.8 L17 27.2 l-0.8 -1.8 -1.8 -0.8 1.8 -0.8 z" fill="#ffd95e"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+527
View File
@@ -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
}
}
+202
View File
@@ -0,0 +1,202 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<!-- Undercooked items (phase 07, task 01). Every ItemKind x reachable
ItemForm the simulation can produce (see tools/gen_art_index.py),
32x32. Shared geometry lives in <defs>; the per-kind colour rides
on `currentColor` so a <use> recolors it. -->
<defs>
<g id="grid-dice">
<rect x="8" y="11" width="7" height="7" rx="1.6" fill="currentColor"/>
<rect x="17" y="11" width="7" height="7" rx="1.6" fill="currentColor"/>
<rect x="8" y="20" width="7" height="7" rx="1.6" fill="currentColor"/>
<rect x="17" y="20" width="7" height="7" rx="1.6" fill="currentColor"/>
</g>
<g id="pot-steam">
<rect x="7" y="13.5" width="18" height="11.5" rx="3" fill="#8b8b8b"/>
<rect x="5.6" y="11.2" width="20.8" height="3.4" rx="1.7" fill="#a3a3a3"/>
<path d="M11 7 q1 -1.6 0 -3 M16 6.4 q1 -1.6 0 -3 M21 7 q1 -1.6 0 -3" stroke="#e8e2d4" stroke-width="1.4" stroke-linecap="round" fill="none" opacity="0.9"/>
</g>
<g id="bowl">
<path d="M6.5 13 h19 c0 5.6 -4 8.6 -9.5 8.6 s-9.5 -3 -9.5 -8.6 z" fill="#f6f1e7"/>
<ellipse cx="16" cy="13" rx="9.5" ry="3" fill="#e2dbcb"/>
</g>
<g id="char">
<path d="M16 11 c4.2 0 7.2 2.7 7.2 6 c0 3.6 -3.2 6.3 -7.2 6.3 s-7.2 -2.7 -7.2 -6.3 c0 -3.3 3 -6 7.2 -6 z" fill="#3b3b3b"/>
<path d="M13.2 8.4 q0.8 -1.4 0 -2.8 M17.8 8 q0.8 -1.4 0 -2.8" stroke="#6b6b6b" stroke-width="1.2" stroke-linecap="round" fill="none"/>
</g>
</defs>
<!-- ============================== tomato ============================== -->
<symbol id="item-tomato-raw" viewBox="0 0 32 32">
<circle cx="16" cy="18" r="8" fill="#e0533d"/>
<ellipse cx="13" cy="15.4" rx="2.6" ry="1.7" fill="#f08a63" opacity="0.8"/>
<path d="M16 11.4 v-2.4" stroke="#5d8a3c" stroke-width="1.6" stroke-linecap="round" fill="none"/>
<path d="M16 10.2 c-2.4 -1.4 -4.6 -1 -6 0.6 c2 0.4 4 0.2 6 -0.6 z" fill="#5d8a3c"/>
<path d="M16 10.2 c2.4 -1.4 4.6 -1 6 0.6 c-2 0.4 -4 0.2 -6 -0.6 z" fill="#5d8a3c"/>
</symbol>
<symbol id="item-tomato-chopped" viewBox="0 0 32 32">
<use href="#grid-dice" style="color: #e0533d"/>
</symbol>
<symbol id="item-tomato-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#e0533d"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-tomato-cooked" viewBox="0 0 32 32">
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#e0533d" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-tomato-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================== onion ============================== -->
<symbol id="item-onion-raw" viewBox="0 0 32 32">
<ellipse cx="16" cy="18.5" rx="8" ry="7.4" fill="#c9a2cf"/>
<path d="M10.4 14.6 c1.8 -3 9.4 -3 11.2 0 M9.4 19 h13.2 M11.4 23.4 c1.8 1.5 7.4 1.5 9.2 0" stroke="#a67fb0" stroke-width="1" fill="none"/>
<path d="M16 11.6 c0 -2 0.8 -3.2 2.2 -3.8" stroke="#8a6b45" stroke-width="1.6" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="item-onion-chopped" viewBox="0 0 32 32">
<use href="#grid-dice" style="color: #c9a2cf"/>
</symbol>
<symbol id="item-onion-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#c9a2cf"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-onion-cooked" viewBox="0 0 32 32">
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#c9a2cf" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-onion-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================= lettuce ============================= -->
<symbol id="item-lettuce-raw" viewBox="0 0 32 32">
<circle cx="12.5" cy="17" r="5.5" fill="#7db85a"/>
<circle cx="20" cy="16.5" r="5.5" fill="#8bc76c"/>
<circle cx="16.5" cy="20.5" r="5.5" fill="#6ea84e"/>
<path d="M10 15 c2 -2.5 8 -2.5 10 0" stroke="#a9dc8f" stroke-width="1.3" fill="none" stroke-linecap="round"/>
</symbol>
<symbol id="item-lettuce-chopped" viewBox="0 0 32 32">
<use href="#grid-dice" style="color: #7db85a"/>
</symbol>
<symbol id="item-lettuce-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#7db85a"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-lettuce-cooked" viewBox="0 0 32 32">
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#7db85a" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-lettuce-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================== dough ============================== -->
<symbol id="item-dough-raw" viewBox="0 0 32 32">
<circle cx="16" cy="18" r="7.5" fill="#d9b97c"/>
<ellipse cx="13.5" cy="15.5" rx="2.4" ry="1.5" fill="#e8d3a5" opacity="0.9"/>
<circle cx="19.5" cy="19" r="0.7" fill="#f2ead8"/>
<circle cx="14.5" cy="20.5" r="0.6" fill="#f2ead8"/>
<circle cx="18.5" cy="14.5" r="0.5" fill="#f2ead8"/>
</symbol>
<symbol id="item-dough-chopped" viewBox="0 0 32 32">
<use href="#grid-dice" style="color: #d9b97c"/>
</symbol>
<symbol id="item-dough-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#d9b97c"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-dough-cooked" viewBox="0 0 32 32">
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#d9b97c" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-dough-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================== patty ============================== -->
<symbol id="item-patty-raw" viewBox="0 0 32 32">
<ellipse cx="16" cy="19" rx="8.5" ry="5.5" fill="#8a5a3a"/>
<ellipse cx="16" cy="17.4" rx="7" ry="4" fill="#a06c48"/>
<path d="M11 16.5 c1.5 -1.5 8.5 -1.5 10 0" stroke="#6f462c" stroke-width="0.9" fill="none"/>
</symbol>
<symbol id="item-patty-chopped" viewBox="0 0 32 32">
<use href="#grid-dice" style="color: #8a5a3a"/>
</symbol>
<symbol id="item-patty-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#8a5a3a"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-patty-cooked" viewBox="0 0 32 32">
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#8a5a3a" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-patty-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================== fish ============================== -->
<symbol id="item-fish-raw" viewBox="0 0 32 32">
<path d="M8 17 c2.5 -3.5 7 -5 11 -3.5 c2 0.8 3.5 2.4 4 3.5 c-0.5 1.1 -2 2.7 -4 3.5 c-4 1.5 -8.5 0 -11 -3.5 z" fill="#7fb2c9"/>
<path d="M23 17 l5 -3.5 v7 z" fill="#7fb2c9"/>
<circle cx="11.5" cy="16" r="1" fill="#3a322a"/>
<path d="M15 14.5 c1.5 1.5 1.5 3.5 0 5 M18 14.8 c1.2 1.2 1.2 3.4 0 4.6" stroke="#5f95ad" stroke-width="0.9" fill="none"/>
</symbol>
<symbol id="item-fish-chopped" viewBox="0 0 32 32">
<use href="#grid-dice" style="color: #7fb2c9"/>
</symbol>
<symbol id="item-fish-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#7fb2c9"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-fish-cooked" viewBox="0 0 32 32">
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#7fb2c9" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-fish-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================== rice ============================== -->
<symbol id="item-rice-raw" viewBox="0 0 32 32">
<ellipse cx="16" cy="19.5" rx="9" ry="5" fill="#e8dfc8"/>
<circle cx="12" cy="18" r="1.8" fill="#f2ead8"/>
<circle cx="16" cy="16" r="1.8" fill="#f2ead8"/>
<circle cx="20" cy="18" r="1.8" fill="#f2ead8"/>
<circle cx="14" cy="20.5" r="1.7" fill="#f2ead8"/>
<circle cx="18.5" cy="20.5" r="1.7" fill="#f2ead8"/>
</symbol>
<symbol id="item-rice-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#f2ead8"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-rice-cooked" viewBox="0 0 32 32">
<ellipse cx="16" cy="12.2" rx="7" ry="3" fill="#f2ead8"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-rice-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================== plate ============================== -->
<symbol id="item-plate-raw" viewBox="0 0 32 32">
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="#f6f1e7"/>
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="none" stroke="#cfc5b5" stroke-width="1.2"/>
<ellipse cx="16" cy="17" rx="5.5" ry="3.4" fill="none" stroke="#e2dbcb" stroke-width="1"/>
</symbol>
<symbol id="item-plate-dirty" viewBox="0 0 32 32">
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="#f6f1e7"/>
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="none" stroke="#cfc5b5" stroke-width="1.2"/>
<ellipse cx="13" cy="15.5" rx="3" ry="1.8" fill="#9a8f7a" opacity="0.85"/>
<ellipse cx="20" cy="18.5" rx="2.4" ry="1.5" fill="#8a7d5a" opacity="0.8"/>
<ellipse cx="17" cy="13.8" rx="1.8" ry="1.1" fill="#7d8a5a" opacity="0.8"/>
</symbol>
<symbol id="item-plate-plated" viewBox="0 0 32 32">
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="#f6f1e7"/>
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="none" stroke="#cfc5b5" stroke-width="1.2"/>
<ellipse cx="16" cy="16.4" rx="6.5" ry="3" fill="#8a5a3a"/>
<path d="M10.5 15.4 c1 -2 8.5 -2 9.5 0 c0.5 1.2 -8.5 2.2 -9.5 0 z" fill="#7db85a"/>
<circle cx="16" cy="13.4" r="2.6" fill="#e0533d"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

+34
View File
@@ -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;
}
+106
View File
@@ -0,0 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<!-- Undercooked station fixtures (phase 07, task 01). 32x32 symbols.
The spawn shelf's stock circle is drawn neutral on purpose: the
renderer tints it per ingredient with the atlas `tint` option. -->
<symbol id="station-spawn_shelf" viewBox="0 0 32 32">
<rect x="4" y="5" width="24" height="4" rx="1.5" fill="#a5825a"/>
<rect x="4" y="22" width="24" height="4" rx="1.5" fill="#a5825a"/>
<rect x="6" y="9" width="3" height="13" fill="#8a6b45"/>
<rect x="23" y="9" width="3" height="13" fill="#8a6b45"/>
<circle cx="16" cy="15.5" r="6" fill="#cfc5b5"/>
<circle cx="16" cy="15.5" r="6" fill="none" stroke="#8a6b45" stroke-width="1.2"/>
</symbol>
<symbol id="station-chop_board" viewBox="0 0 32 32">
<rect x="5" y="9" width="22" height="15" rx="3" fill="#c99e63"/>
<rect x="5" y="9" width="22" height="15" rx="3" fill="none" stroke="#a5825a" stroke-width="1.4"/>
<path d="M9 13 H23 M9 17 H23 M9 21 H23" stroke="#a5825a" stroke-width="0.9" fill="none"/>
<path d="M7 6 L21 4 L21 6 L8 8 Z" fill="#dfe3e6"/>
<rect x="21" y="3.6" width="6" height="2.8" rx="1.2" fill="#6b5a48"/>
</symbol>
<symbol id="station-stove" viewBox="0 0 32 32">
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
<circle cx="16" cy="16.5" r="7" fill="none" stroke="#6b625a" stroke-width="2.4"/>
<circle cx="8.5" cy="24" r="1.4" fill="#6b625a"/>
<circle cx="12.5" cy="24" r="1.4" fill="#6b625a"/>
</symbol>
<symbol id="station-stove-off" viewBox="0 0 32 32">
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
<circle cx="16" cy="16.5" r="7" fill="none" stroke="#6b625a" stroke-width="2.4"/>
<circle cx="8.5" cy="24" r="1.4" fill="#6b625a"/>
</symbol>
<symbol id="station-stove-working" viewBox="0 0 32 32">
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
<path d="M12.5 20.5 q1.5 -2.5 3.5 0 q2 -2.5 3.5 0 z" fill="#ffa63d"/>
<rect x="10" y="11" width="12" height="8" rx="2" fill="#8b8b8b"/>
<rect x="9" y="9.6" width="14" height="2.6" rx="1.3" fill="#a3a3a3"/>
<circle cx="16" cy="8.6" r="1.1" fill="#a3a3a3"/>
</symbol>
<symbol id="station-stove-ready" viewBox="0 0 32 32">
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
<rect x="10" y="11" width="12" height="8" rx="2" fill="#8b8b8b"/>
<rect x="9" y="9.6" width="14" height="2.6" rx="1.3" fill="#a3a3a3"/>
<circle cx="16" cy="8.6" r="1.1" fill="#a3a3a3"/>
<path d="M12 5 q1 -1.6 0 -3 M16 4.6 q1 -1.6 0 -3 M20 5 q1 -1.6 0 -3" stroke="#e8e2d4" stroke-width="1.4" stroke-linecap="round" fill="none" opacity="0.85"/>
</symbol>
<symbol id="station-stove-burning" viewBox="0 0 32 32">
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
<rect x="11" y="12" width="10" height="7" rx="2" fill="#3b3b3b"/>
<path d="M16 4.5 c2.6 2.2 4 4 4 6.2 a4 4 0 0 1 -8 0 c0 -2.2 1.4 -4 4 -6.2 z" fill="#ff5a4e"/>
<path d="M16 8 c1.4 1.4 2.2 2.4 2.2 3.6 a2.2 2.2 0 0 1 -4.4 0 c0 -1.2 0.8 -2.2 2.2 -3.6 z" fill="#ffa63d"/>
</symbol>
<symbol id="station-pot" viewBox="0 0 32 32">
<rect x="8" y="12" width="16" height="11" rx="2.5" fill="#8b8b8b"/>
<rect x="6.6" y="10.4" width="18.8" height="3" rx="1.5" fill="#a3a3a3"/>
<circle cx="16" cy="9.2" r="1.4" fill="#a3a3a3"/>
<rect x="4.6" y="14" width="3.4" height="2.4" rx="1.2" fill="#6f6a63"/>
<rect x="24" y="14" width="3.4" height="2.4" rx="1.2" fill="#6f6a63"/>
</symbol>
<symbol id="station-pan" viewBox="0 0 32 32">
<ellipse cx="14" cy="17" rx="9.5" ry="6" fill="#55504a"/>
<ellipse cx="14" cy="15.8" rx="8" ry="4.6" fill="#6b625a"/>
<rect x="22" y="14.6" width="8" height="3" rx="1.5" fill="#6b5a48"/>
</symbol>
<symbol id="station-prep" viewBox="0 0 32 32">
<rect x="4" y="10" width="24" height="15" rx="2.5" fill="#d8c49a"/>
<rect x="4" y="10" width="24" height="15" rx="2.5" fill="none" stroke="#a5825a" stroke-width="1.4"/>
<ellipse cx="13" cy="17.5" rx="6" ry="3.4" fill="#f6f1e7"/>
<ellipse cx="13" cy="17.5" rx="6" ry="3.4" fill="none" stroke="#cfc5b5" stroke-width="1"/>
<circle cx="24" cy="17.5" r="3.4" fill="#8b8b8b"/>
</symbol>
<symbol id="station-serve" viewBox="0 0 32 32">
<rect x="4" y="8" width="24" height="18" rx="2.5" fill="#d8c49a"/>
<rect x="7" y="12" width="18" height="10" rx="1.8" fill="#4a4238"/>
<path d="M11 17 h10" stroke="#f6f1e7" stroke-width="1.6" stroke-linecap="round" fill="none"/>
<path d="M16 4.5 a3 3 0 0 1 3 3 v1 h-6 v-1 a3 3 0 0 1 3 -3 z" fill="#ffd95e"/>
<circle cx="16" cy="3.4" r="1" fill="#ffd95e"/>
</symbol>
<symbol id="station-sink" viewBox="0 0 32 32">
<rect x="5" y="9" width="22" height="16" rx="2.5" fill="#b9b9b9"/>
<rect x="8" y="12" width="16" height="10" rx="2" fill="#8f8f8f"/>
<circle cx="16" cy="18.5" r="1.5" fill="#6f6f6f"/>
<path d="M22 6 v4 a3 3 0 0 1 -3 3 h-2" stroke="#a3a3a3" stroke-width="2.4" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="station-trash" viewBox="0 0 32 32">
<path d="M10 9 L22 9 L20.5 27 L11.5 27 Z" fill="#7a7f85"/>
<rect x="8.4" y="5.6" width="15.2" height="3.4" rx="1.4" fill="#9aa0a6"/>
<path d="M13.4 12 V24 M16 12 V24 M18.6 12 V24" stroke="#62676d" stroke-width="1.3" fill="none"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

+61
View File
@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<!-- Undercooked tileset (phase 07, task 01). One <symbol> per drawable,
32x32 viewBox, hand-authored vector cartoon. Served from
app/static/assets/ and indexed by index.json. -->
<symbol id="tile-floor" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#b98a5c"/>
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
</symbol>
<symbol id="tile-floor-1" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#b98a5c"/>
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
<ellipse cx="24" cy="5" rx="2.6" ry="1.7" fill="#a1734c"/>
<ellipse cx="24" cy="5" rx="1.1" ry="0.7" fill="#8d6340"/>
</symbol>
<symbol id="tile-floor-2" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#b98a5c"/>
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
<path d="M3 15 L8 15 M25 25 L30 25 M5 27 L10 27" stroke="#ab7d52" stroke-width="1.4" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="tile-floor-3" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#b98a5c"/>
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
<rect x="13" y="13.5" width="6" height="6" rx="1.6" fill="#c4976b"/>
</symbol>
<symbol id="tile-floor-4" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#b98a5c"/>
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
<circle cx="6" cy="6" r="0.9" fill="#ab7d52"/>
<circle cx="27" cy="16" r="0.9" fill="#ab7d52"/>
<circle cx="12" cy="27" r="0.9" fill="#ab7d52"/>
<circle cx="24" cy="28" r="0.7" fill="#ab7d52"/>
</symbol>
<symbol id="tile-counter" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#d8c49a"/>
<rect y="24" width="32" height="8" fill="#a5825a"/>
<rect y="22" width="32" height="3" fill="#8a6b45"/>
<path d="M3 3 H29" stroke="#c4b088" stroke-width="1.4" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="tile-wall" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#6b5a48"/>
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#5a4b3b" stroke-width="1.5" fill="none"/>
<path d="M16 0 V10.5 M8 10.5 V21.5 M24 10.5 V21.5 M16 21.5 V32" stroke="#5a4b3b" stroke-width="1.5" fill="none"/>
</symbol>
<symbol id="tile-void" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#241f1a"/>
<path d="M-4 36 L36 -4 M-4 24 L24 -4 M8 36 L36 8 M20 36 L36 20" stroke="#3a322a" stroke-width="1.4" fill="none"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+41
View File
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<!-- Undercooked HUD symbols (phase 07, task 01): the order ticket,
the result stars, the host crown, the ready check, the
disconnected-cook ghost and the streak flame. -->
<symbol id="ui-ticket" viewBox="0 0 32 32">
<path d="M7 5 h18 v22 l-3 -2.4 -3 2.4 -3 -2.4 -3 2.4 -3 -2.4 -3 2.4 z" fill="#f6f1e7"/>
<path d="M7 5 h18 v5 H7 z" fill="#ffd95e"/>
<path d="M10.5 15 h11 M10.5 19 h11 M10.5 23 h7" stroke="#9a8f7a" stroke-width="1.5" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="ui-star" viewBox="0 0 32 32">
<path d="M16 3.5 l3.8 7.7 8.5 1.2 -6.2 6 1.5 8.4 -7.6 -4 -7.6 4 1.5 -8.4 -6.2 -6 8.5 -1.2 z" fill="#ffd95e"/>
<path d="M16 3.5 l3.8 7.7 8.5 1.2 -6.2 6 1.5 8.4 -7.6 -4 -7.6 4 1.5 -8.4 -6.2 -6 8.5 -1.2 z" fill="none" stroke="#d9a92e" stroke-width="1.2"/>
</symbol>
<symbol id="ui-crown" viewBox="0 0 32 32">
<path d="M6 22 L8 10 l5 5 3 -7 3 7 5 -5 2 12 z" fill="#ffd95e"/>
<rect x="6" y="22" width="20" height="4" rx="1.6" fill="#d9a92e"/>
<circle cx="8" cy="9.4" r="1.5" fill="#d9a92e"/>
<circle cx="16" cy="6.4" r="1.5" fill="#d9a92e"/>
<circle cx="24" cy="9.4" r="1.5" fill="#d9a92e"/>
</symbol>
<symbol id="ui-ready" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="11" fill="#67d16c"/>
<path d="M10.5 16.5 L14.5 20.5 L21.5 12.5" stroke="#f6f1e7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</symbol>
<symbol id="ui-ghost" viewBox="0 0 32 32">
<path d="M9 26 V15 a7 7 0 0 1 14 0 v11 l-2.3 -2 -2.4 2 -2.3 -2 -2.3 2 -2.4 -2 z" fill="#f6f1e7" opacity="0.55"/>
<circle cx="13.5" cy="14.5" r="1.4" fill="#4a4238"/>
<circle cx="18.5" cy="14.5" r="1.4" fill="#4a4238"/>
</symbol>
<symbol id="ui-streak" viewBox="0 0 32 32">
<path d="M16 5 c3 2.8 4.8 5.2 4.8 8 a4.8 4.8 0 0 1 -9.6 0 c0 -2.8 1.8 -5.2 4.8 -8 z" fill="#ffa63d"/>
<path d="M16 10 c1.5 1.6 2.6 2.9 2.6 4.3 a2.6 2.6 0 0 1 -5.2 0 c0 -1.4 1.1 -2.7 2.6 -4.3 z" fill="#fff7d6"/>
<path d="M9 22.5 q-1.4 1.8 0 3 M23 22.5 q1.4 1.8 0 3" stroke="#ffa63d" stroke-width="1.6" stroke-linecap="round" fill="none"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+662
View File
@@ -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;
}
}
+8 -42
View File
@@ -13,6 +13,7 @@
while keeping every byte same-origin (PLAN.md A12). --> while keeping every byte same-origin (PLAN.md A12). -->
<link rel="icon" href="data:," /> <link rel="icon" href="data:," />
<link rel="stylesheet" href="/static/css/base.css" /> <link rel="stylesheet" href="/static/css/base.css" />
<link rel="stylesheet" href="/static/css/screens.css" />
</head> </head>
<body> <body>
<main id="app" class="shell"> <main id="app" class="shell">
@@ -21,47 +22,12 @@
<p class="tagline">Anonymous co-op kitchen chaos. No accounts, no install, 1&ndash;4 cooks.</p> <p class="tagline">Anonymous co-op kitchen chaos. No accounts, no install, 1&ndash;4 cooks.</p>
</header> </header>
<section id="screen-lobby" class="panel" aria-labelledby="lobby-heading"> <!-- The screen state machine (app/static/js/app.js) mounts exactly one
<h2 id="lobby-heading">Kitchen lobby</h2> screen here at a time: landing, connecting, lobby, countdown,
<p id="lobby-help"> playing, round_end or error. The playing screen owns #kcanvas
Create a kitchen and read its 5-character code out loud, or join a kitchen that is (hidden until the phase-07 task-04 renderer sizes and unhides it)
already open. Nothing is saved and nothing is asked for. and the #hud root. -->
</p> <div id="screen-root" class="screen-root"></div>
<button id="create-lobby" class="btn btn-primary" type="button">Create lobby</button>
<form id="join-form" class="join" autocomplete="off">
<label for="join-code">Room code</label>
<div class="join-row">
<input
id="join-code"
name="code"
type="text"
inputmode="text"
autocapitalize="characters"
spellcheck="false"
size="5"
maxlength="5"
placeholder="ABCDE"
aria-describedby="join-hint"
required
/>
<button class="btn" type="submit">Join</button>
</div>
</form>
<p id="join-hint" class="hint">
Five characters, letters and digits, never <code>I</code>, <code>L</code>, <code>O</code>
or <code>U</code>.
</p>
<div id="status" class="status" role="status" aria-live="polite">
Pick a kitchen to start.
</div>
</section>
<!-- The renderer (phase 07) sizes and unhides this canvas; it stays out of
the layout and the a11y tree until then. -->
<canvas id="kcanvas" hidden aria-hidden="true"></canvas>
</main> </main>
<footer class="colophon"> <footer class="colophon">
@@ -71,6 +37,6 @@
</p> </p>
</footer> </footer>
<script type="module" src="/static/js/app.js"></script> <script type="module" src="/static/js/main.js"></script>
</body> </body>
</html> </html>
+408 -109
View File
@@ -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 // `createApp()` owns the one client-side state machine: an explicit
// room shell (supervisor, room FSM, session store); phase 06 adds the WebSocket // screen enum (`LANDING, CONNECTING, LOBBY, COUNTDOWN, PLAYING,
// transport and phase 07 replaces the placeholder button handlers with the lobby // ROUND_END, ERROR`), `show(screen, data)`, and the message reducer
// flow and adds render/ (Canvas 2D). What this file owns today: // that maps the wire contract (docs/PROTOCOL.md) onto screens —
// * the anonymous per-tab session token (PLAN.md A16), // `welcome`/`lobby` by the room's state, `countdown` → COUNTDOWN,
// * the last room code typed in this tab, so a reload can rejoin fast, // `snap` → PLAYING, `round` → ROUND_END, fatal `err` codes → ERROR.
// * mounting the lobby screen and announcing state into #status. // Every screen is a module exporting `{ mount, unmount }` (plus an
// It is a plain ES module served from /static/ — no bundler, no framework, no // optional same-screen `update`); no global DOM lives outside them.
// remote import (PLAN.md A10/A12). //
// `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"; import { createConnection, FATAL_ERR_CODES } from "./net/connection.js";
const LAST_CODE_KEY = "uc.lastCode"; import { getToken, lastCode, rememberCode, randomToken } from "./identity.js";
const CODE_LENGTH = 5; import * as landingScreen from "./screens/landing.js";
const NOT_IMPLEMENTED = "Not implemented until phase 07."; import * as connectingScreen from "./screens/connecting.js";
const READY_MESSAGE = "Pick a kitchen to start."; 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. */ export { randomToken, getToken, lastCode, rememberCode };
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). */ /** The screen enum (the task's list, frozen). */
export function randomToken() { export const SCREENS = Object.freeze({
const bytes = new Uint8Array(24); LANDING: "landing",
globalThis.crypto.getRandomValues(bytes); CONNECTING: "connecting",
return base64url(bytes); LOBBY: "lobby",
} COUNTDOWN: "countdown",
PLAYING: "playing",
ROUND_END: "round_end",
ERROR: "error",
});
function read(storage, key) { const SCREEN_MODULES = {
try { landing: landingScreen,
return storage.getItem(key); connecting: connectingScreen,
} catch { lobby: lobbyScreen,
return null; // private browsing / storage disabled: stay functional for this tab only countdown: countdownScreen,
} playing: playScreen,
} round_end: roundEndScreen,
error: errorScreen,
};
function write(storage, key, value) { /** The room-state value → screen (the `welcome`/`lobby` mapping half). */
try { const SCREEN_BY_ROOM_STATE = {
storage.setItem(key, value); lobby: SCREENS.LOBBY,
} catch { countdown: SCREENS.COUNTDOWN,
/* nothing to persist to; the in-memory value still works */ playing: SCREENS.PLAYING,
} round_end: SCREENS.ROUND_END,
} closed: SCREENS.ERROR,
};
/** The tab's session token, created once per tab and kept in sessionStorage. */ const LOG_CAP = 2000;
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 createApp(root = document, options = {}) {
export function normalizeCode(value) { const { onWireMessage = null } = options;
return String(value ?? "") const screenRoot = root.getElementById("screen-root");
.toUpperCase() const app = {
.replace(/[^0-9A-Z]/g, "") token: getToken(),
.slice(0, CODE_LENGTH); 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) || ""; * 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
export function rememberCode(code, storage = globalThis.sessionStorage) { * (every 20 encoded frames) plus the fresh-connection full keeps this
write(storage, LAST_CODE_KEY, normalizeCode(code)); * resyncable; a client-side drift is what task 03's decoder handles.
}
/** 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) { function makeView() {
const token = getToken(); return { players: {}, stations: {}, orders: {} };
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);
});
} }
if (joinForm) { function stationKey(entry) {
joinForm.addEventListener("submit", (event) => { return `${entry.k}@${entry.x},${entry.y}`;
event.preventDefault(); }
const code = normalizeCode(joinInput ? joinInput.value : "");
if (joinInput) joinInput.value = code; function applySnapshot(raw) {
if (code.length !== CODE_LENGTH) { app.lastSnapshot = raw;
announce(`A room code is ${CODE_LENGTH} characters.`, root, "error"); const view = app.view;
if (joinInput) joinInput.focus(); 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; return;
} }
rememberCode(code); const delta = raw.d ?? {};
// Phase 07 (on the phase-06 transport): join the room over the for (const entry of delta.p ?? []) {
// WebSocket. if (entry.gone) delete view.players[entry.i];
announce(NOT_IMPLEMENTED, root); 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);
}
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;
}
function failToLanding(banner) {
app.banner = banner;
show(SCREENS.LANDING, {});
}
// ------------------------------------------------------------- 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 });
} }
if (!lastCode() && root.getElementById("status")) { function join(code) {
announce(READY_MESSAGE, root); app.leaving = false;
app.roomCode = code;
rememberCode(code);
show(SCREENS.CONNECTING, { what: "joining", code });
connection.connect(code, { token: app.token });
} }
return { token, code: lastCode() }; 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 // Auto-mount when loaded directly as the page's module; stay
// therefore testable) without a lobby in the document. // importable (and therefore testable) without one. The instance is
if (typeof document !== "undefined" && document.getElementById("screen-lobby")) { // 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") { if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => mount(), { once: true }); document.addEventListener("DOMContentLoaded", boot, { once: true });
} else { } else {
mount(); boot();
} }
} }
+70
View File
@@ -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 };
+317
View File
@@ -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 `<summary>` 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;
},
};
}
+9
View File
@@ -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;
+208
View File
@@ -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();
}
+189
View File
@@ -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;
},
};
}
+169
View File
@@ -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 };
}
+161
View File
@@ -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 }),
};
}
+317
View File
@@ -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 : [],
};
}
+312
View File
@@ -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
// `<img>` 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 `<use>` 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 `<use>`, 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 `<use>` 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
* `<g>` (a `<symbol>` renders nothing on its own). `<use>` 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 <use> 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 `<img>` 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
* `<img>`, 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();
}
+197
View File
@@ -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,
}),
};
}
+648
View File
@@ -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,
};
}
+361
View File
@@ -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,
}),
};
}
+144
View File
@@ -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,
};
}
+52
View File
@@ -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,
});
+32
View File
@@ -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 */
}
+38
View File
@@ -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;
}
+72
View File
@@ -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;
+87
View File
@@ -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 */
}
+84
View File
@@ -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.";
}
+292
View File
@@ -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");
}
+94
View File
@@ -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;
}
+143
View File
@@ -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;
}
+137
View File
@@ -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"]);
+118
View File
@@ -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 };
}
+273
View File
@@ -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 `<img>` 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 `<img>`). 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 <article>
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 };
}
+116
View File
@@ -0,0 +1,116 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<!-- Undercooked cooks (phase 07, task 01). Four bodies (cook-1..4),
each 4 facings x 2 walk frames; the torso colour is
`currentColor`, so a <use> recolors it: in the DOM through the
page's cook-colour CSS custom properties, in the canvas through
the var() fallback (app/static/assets/palette.css). The carry
overlay and the stun frame are shared. -->
<defs>
<ellipse id="shadow" cx="16" cy="29.5" rx="8" ry="1.8" fill="#1a1713" opacity="0.18"/>
<g id="legs-0">
<rect x="12.4" y="23" width="3.2" height="6" rx="1.4" fill="#4a4238"/>
<rect x="16.4" y="23" width="3.2" height="6" rx="1.4" fill="#4a4238"/>
</g>
<g id="legs-1">
<path d="M13.5 23 L10.8 28.8" stroke="#4a4238" stroke-width="3.2" stroke-linecap="round" fill="none"/>
<path d="M18.5 23 L21.2 28.8" stroke="#4a4238" stroke-width="3.2" stroke-linecap="round" fill="none"/>
</g>
<g id="chef-torso">
<rect x="10" y="14" width="12" height="11" rx="3" fill="currentColor"/>
<path d="M12 18.5 h8 v3.5 a4 3.4 0 0 1 -8 0 z" fill="#f2ede3"/>
</g>
<g id="chef-hat">
<path d="M11.4 8.6 a4.6 4.2 0 0 1 9.2 0 z" fill="#fbf7ee"/>
<circle cx="16" cy="5.4" r="2.8" fill="#fbf7ee"/>
<path d="M11.4 8.6 h9.2" stroke="#e2dbcb" stroke-width="1.2" stroke-linecap="round" fill="none"/>
</g>
<g id="chef-head-down">
<circle cx="16" cy="11" r="4.6" fill="#f2c9a0"/>
<use href="#chef-hat"/>
<circle cx="14.2" cy="10.6" r="0.75" fill="#3a322a"/>
<circle cx="17.8" cy="10.6" r="0.75" fill="#3a322a"/>
<path d="M15.2 13 q0.8 0.7 1.6 0" stroke="#c98a5e" stroke-width="0.9" stroke-linecap="round" fill="none"/>
</g>
<g id="chef-head-up">
<circle cx="16" cy="11" r="4.6" fill="#f2c9a0"/>
<use href="#chef-hat"/>
<path d="M12.6 13.4 q3.4 2 6.8 0" stroke="#e0b184" stroke-width="1" stroke-linecap="round" fill="none"/>
</g>
<g id="chef-head-right">
<circle cx="16" cy="11" r="4.6" fill="#f2c9a0"/>
<use href="#chef-hat"/>
<circle cx="18.9" cy="10.6" r="0.75" fill="#3a322a"/>
<path d="M18.4 13.2 q0.8 0.6 1.6 0" stroke="#c98a5e" stroke-width="0.9" stroke-linecap="round" fill="none"/>
</g>
<g id="chef-head-left">
<circle cx="16" cy="11" r="4.6" fill="#f2c9a0"/>
<use href="#chef-hat"/>
<circle cx="13.1" cy="10.6" r="0.75" fill="#3a322a"/>
<path d="M12 13.2 q0.8 0.6 1.6 0" stroke="#c98a5e" stroke-width="0.9" stroke-linecap="round" fill="none"/>
</g>
<g id="shape-up-0"><use href="#shadow"/><use href="#legs-0"/><use href="#chef-torso"/><use href="#chef-head-up"/></g>
<g id="shape-up-1"><use href="#shadow"/><use href="#legs-1"/><use href="#chef-torso"/><use href="#chef-head-up"/></g>
<g id="shape-right-0"><use href="#shadow"/><use href="#legs-0"/><use href="#chef-torso"/><use href="#chef-head-right"/></g>
<g id="shape-right-1"><use href="#shadow"/><use href="#legs-1"/><use href="#chef-torso"/><use href="#chef-head-right"/></g>
<g id="shape-down-0"><use href="#shadow"/><use href="#legs-0"/><use href="#chef-torso"/><use href="#chef-head-down"/></g>
<g id="shape-down-1"><use href="#shadow"/><use href="#legs-1"/><use href="#chef-torso"/><use href="#chef-head-down"/></g>
<g id="shape-left-0"><use href="#shadow"/><use href="#legs-0"/><use href="#chef-torso"/><use href="#chef-head-left"/></g>
<g id="shape-left-1"><use href="#shadow"/><use href="#legs-1"/><use href="#chef-torso"/><use href="#chef-head-left"/></g>
</defs>
<symbol id="cook-1-up-0" viewBox="0 0 32 32"><use href="#shape-up-0" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-up-1" viewBox="0 0 32 32"><use href="#shape-up-1" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-right-0" viewBox="0 0 32 32"><use href="#shape-right-0" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-right-1" viewBox="0 0 32 32"><use href="#shape-right-1" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-down-0" viewBox="0 0 32 32"><use href="#shape-down-0" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-down-1" viewBox="0 0 32 32"><use href="#shape-down-1" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-left-0" viewBox="0 0 32 32"><use href="#shape-left-0" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-1-left-1" viewBox="0 0 32 32"><use href="#shape-left-1" style="color: var(--uc-cook-1, #e0533d)"/></symbol>
<symbol id="cook-2-up-0" viewBox="0 0 32 32"><use href="#shape-up-0" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-up-1" viewBox="0 0 32 32"><use href="#shape-up-1" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-right-0" viewBox="0 0 32 32"><use href="#shape-right-0" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-right-1" viewBox="0 0 32 32"><use href="#shape-right-1" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-down-0" viewBox="0 0 32 32"><use href="#shape-down-0" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-down-1" viewBox="0 0 32 32"><use href="#shape-down-1" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-left-0" viewBox="0 0 32 32"><use href="#shape-left-0" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-2-left-1" viewBox="0 0 32 32"><use href="#shape-left-1" style="color: var(--uc-cook-2, #3d84a8)"/></symbol>
<symbol id="cook-3-up-0" viewBox="0 0 32 32"><use href="#shape-up-0" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-up-1" viewBox="0 0 32 32"><use href="#shape-up-1" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-right-0" viewBox="0 0 32 32"><use href="#shape-right-0" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-right-1" viewBox="0 0 32 32"><use href="#shape-right-1" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-down-0" viewBox="0 0 32 32"><use href="#shape-down-0" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-down-1" viewBox="0 0 32 32"><use href="#shape-down-1" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-left-0" viewBox="0 0 32 32"><use href="#shape-left-0" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-3-left-1" viewBox="0 0 32 32"><use href="#shape-left-1" style="color: var(--uc-cook-3, #7da84b)"/></symbol>
<symbol id="cook-4-up-0" viewBox="0 0 32 32"><use href="#shape-up-0" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-up-1" viewBox="0 0 32 32"><use href="#shape-up-1" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-right-0" viewBox="0 0 32 32"><use href="#shape-right-0" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-right-1" viewBox="0 0 32 32"><use href="#shape-right-1" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-down-0" viewBox="0 0 32 32"><use href="#shape-down-0" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-down-1" viewBox="0 0 32 32"><use href="#shape-down-1" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-left-0" viewBox="0 0 32 32"><use href="#shape-left-0" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<symbol id="cook-4-left-1" viewBox="0 0 32 32"><use href="#shape-left-1" style="color: var(--uc-cook-4, #b0699a)"/></symbol>
<!-- The carrying overlay: one arm raised with the item slot ring; the
renderer draws the item sprite in the slot. -->
<symbol id="cook-carry" viewBox="0 0 32 32">
<path d="M19 17 l7 -5.5" stroke="#f2c9a0" stroke-width="3" stroke-linecap="round" fill="none"/>
<circle cx="27" cy="11" r="2.4" fill="#f2c9a0"/>
<circle cx="27" cy="9.6" r="4.6" fill="none" stroke="#cfc5b5" stroke-width="1" stroke-dasharray="2.4 2.2"/>
</symbol>
<!-- The stun frame: a dizzy swirl and a star. -->
<symbol id="cook-stun" viewBox="0 0 32 32">
<path d="M19.5 12.5 a3.5 3.5 0 1 1 -3.5 -3.5 a5 5 0 1 0 5 5" fill="none" stroke="#3a322a" stroke-width="1.4" stroke-linecap="round"/>
<path d="M16 2.6 l1 2.2 2.4 0.3 -1.8 1.6 0.5 2.3 -2.1 -1.2 -2.1 1.2 0.5 -2.3 -1.8 -1.6 2.4 -0.3 z" fill="#ffd95e"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 8.3 KiB

+39
View File
@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<!-- Undercooked transient FX (phase 07, task 01). One symbol per event
the sim can emit; the renderer plays them at the event position. -->
<symbol id="fx-chop" viewBox="0 0 32 32">
<path d="M10 16 L4 16 M16 10 L16 4 M22 16 L28 16 M12.5 11.5 L8.5 7.5 M19.5 11.5 L23.5 7.5" stroke="#ffd95e" stroke-width="2" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="fx-steam" viewBox="0 0 32 32">
<path d="M11 22 q1.4 -2.4 0 -4.8 q-1.4 -2.4 0 -4.8" stroke="#e8e2d4" stroke-width="1.8" stroke-linecap="round" fill="none" opacity="0.9"/>
<path d="M16 24 q1.4 -2.4 0 -4.8 q-1.4 -2.4 0 -4.8" stroke="#e8e2d4" stroke-width="1.8" stroke-linecap="round" fill="none" opacity="0.75"/>
<path d="M21 22 q1.4 -2.4 0 -4.8 q-1.4 -2.4 0 -4.8" stroke="#e8e2d4" stroke-width="1.8" stroke-linecap="round" fill="none" opacity="0.9"/>
</symbol>
<symbol id="fx-flame" viewBox="0 0 32 32">
<path d="M16 3.5 c3.4 3 5.4 5.6 5.4 8.6 a5.4 5.4 0 0 1 -10.8 0 c0 -3 2 -5.6 5.4 -8.6 z" fill="#ff5a4e"/>
<path d="M16 9 c1.8 1.8 3 3.2 3 4.8 a3 3 0 0 1 -6 0 c0 -1.6 1.2 -3 3 -4.8 z" fill="#ffa63d"/>
</symbol>
<symbol id="fx-splash" viewBox="0 0 32 32">
<path d="M16 8 c2 2.6 3.4 4.6 3.4 6.6 a3.4 3.4 0 0 1 -6.8 0 c0 -2 1.4 -4 3.4 -6.6 z" fill="#6fa8c9"/>
<path d="M8 18 q-1.5 2 0 3.4 M24 18 q1.5 2 0 3.4 M12 24.5 q-1 1.6 0 2.6 M20 24.5 q1 1.6 0 2.6" stroke="#6fa8c9" stroke-width="1.6" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="fx-deny" viewBox="0 0 32 32">
<path d="M9 9 L23 23 M23 9 L9 23" stroke="#ff5a4e" stroke-width="4" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="fx-score" viewBox="0 0 32 32">
<path d="M16 3 l2.6 6.4 6.9 0.8 -5.2 4.6 1.5 6.8 -5.8 -3.6 -5.8 3.6 1.5 -6.8 -5.2 -4.6 6.9 -0.8 z" fill="#ffd95e"/>
<path d="M16 11.5 v7 M12.8 15 h6.4" stroke="#8a6b45" stroke-width="1.8" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="fx-serve" viewBox="0 0 32 32">
<path d="M10 8 l1.2 2.8 2.8 1.2 -2.8 1.2 L10 16 l-1.2 -2.8 L6 12 l2.8 -1.2 z" fill="#fff7d6"/>
<path d="M22 14 l1 2.4 2.4 1 -2.4 1 L22 20.8 l-1 -2.4 -2.4 -1 2.4 -1 z" fill="#fff7d6"/>
<path d="M17 22 l0.8 1.8 1.8 0.8 -1.8 0.8 L17 27.2 l-0.8 -1.8 -1.8 -0.8 1.8 -0.8 z" fill="#ffd95e"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+202
View File
@@ -0,0 +1,202 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<!-- Undercooked items (phase 07, task 01). Every ItemKind x reachable
ItemForm the simulation can produce (see tools/gen_art_index.py),
32x32. Shared geometry lives in <defs>; the per-kind colour rides
on `currentColor` so a <use> recolors it. -->
<defs>
<g id="grid-dice">
<rect x="8" y="11" width="7" height="7" rx="1.6" fill="currentColor"/>
<rect x="17" y="11" width="7" height="7" rx="1.6" fill="currentColor"/>
<rect x="8" y="20" width="7" height="7" rx="1.6" fill="currentColor"/>
<rect x="17" y="20" width="7" height="7" rx="1.6" fill="currentColor"/>
</g>
<g id="pot-steam">
<rect x="7" y="13.5" width="18" height="11.5" rx="3" fill="#8b8b8b"/>
<rect x="5.6" y="11.2" width="20.8" height="3.4" rx="1.7" fill="#a3a3a3"/>
<path d="M11 7 q1 -1.6 0 -3 M16 6.4 q1 -1.6 0 -3 M21 7 q1 -1.6 0 -3" stroke="#e8e2d4" stroke-width="1.4" stroke-linecap="round" fill="none" opacity="0.9"/>
</g>
<g id="bowl">
<path d="M6.5 13 h19 c0 5.6 -4 8.6 -9.5 8.6 s-9.5 -3 -9.5 -8.6 z" fill="#f6f1e7"/>
<ellipse cx="16" cy="13" rx="9.5" ry="3" fill="#e2dbcb"/>
</g>
<g id="char">
<path d="M16 11 c4.2 0 7.2 2.7 7.2 6 c0 3.6 -3.2 6.3 -7.2 6.3 s-7.2 -2.7 -7.2 -6.3 c0 -3.3 3 -6 7.2 -6 z" fill="#3b3b3b"/>
<path d="M13.2 8.4 q0.8 -1.4 0 -2.8 M17.8 8 q0.8 -1.4 0 -2.8" stroke="#6b6b6b" stroke-width="1.2" stroke-linecap="round" fill="none"/>
</g>
</defs>
<!-- ============================== tomato ============================== -->
<symbol id="item-tomato-raw" viewBox="0 0 32 32">
<circle cx="16" cy="18" r="8" fill="#e0533d"/>
<ellipse cx="13" cy="15.4" rx="2.6" ry="1.7" fill="#f08a63" opacity="0.8"/>
<path d="M16 11.4 v-2.4" stroke="#5d8a3c" stroke-width="1.6" stroke-linecap="round" fill="none"/>
<path d="M16 10.2 c-2.4 -1.4 -4.6 -1 -6 0.6 c2 0.4 4 0.2 6 -0.6 z" fill="#5d8a3c"/>
<path d="M16 10.2 c2.4 -1.4 4.6 -1 6 0.6 c-2 0.4 -4 0.2 -6 -0.6 z" fill="#5d8a3c"/>
</symbol>
<symbol id="item-tomato-chopped" viewBox="0 0 32 32">
<use href="#grid-dice" style="color: #e0533d"/>
</symbol>
<symbol id="item-tomato-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#e0533d"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-tomato-cooked" viewBox="0 0 32 32">
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#e0533d" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-tomato-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================== onion ============================== -->
<symbol id="item-onion-raw" viewBox="0 0 32 32">
<ellipse cx="16" cy="18.5" rx="8" ry="7.4" fill="#c9a2cf"/>
<path d="M10.4 14.6 c1.8 -3 9.4 -3 11.2 0 M9.4 19 h13.2 M11.4 23.4 c1.8 1.5 7.4 1.5 9.2 0" stroke="#a67fb0" stroke-width="1" fill="none"/>
<path d="M16 11.6 c0 -2 0.8 -3.2 2.2 -3.8" stroke="#8a6b45" stroke-width="1.6" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="item-onion-chopped" viewBox="0 0 32 32">
<use href="#grid-dice" style="color: #c9a2cf"/>
</symbol>
<symbol id="item-onion-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#c9a2cf"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-onion-cooked" viewBox="0 0 32 32">
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#c9a2cf" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-onion-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================= lettuce ============================= -->
<symbol id="item-lettuce-raw" viewBox="0 0 32 32">
<circle cx="12.5" cy="17" r="5.5" fill="#7db85a"/>
<circle cx="20" cy="16.5" r="5.5" fill="#8bc76c"/>
<circle cx="16.5" cy="20.5" r="5.5" fill="#6ea84e"/>
<path d="M10 15 c2 -2.5 8 -2.5 10 0" stroke="#a9dc8f" stroke-width="1.3" fill="none" stroke-linecap="round"/>
</symbol>
<symbol id="item-lettuce-chopped" viewBox="0 0 32 32">
<use href="#grid-dice" style="color: #7db85a"/>
</symbol>
<symbol id="item-lettuce-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#7db85a"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-lettuce-cooked" viewBox="0 0 32 32">
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#7db85a" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-lettuce-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================== dough ============================== -->
<symbol id="item-dough-raw" viewBox="0 0 32 32">
<circle cx="16" cy="18" r="7.5" fill="#d9b97c"/>
<ellipse cx="13.5" cy="15.5" rx="2.4" ry="1.5" fill="#e8d3a5" opacity="0.9"/>
<circle cx="19.5" cy="19" r="0.7" fill="#f2ead8"/>
<circle cx="14.5" cy="20.5" r="0.6" fill="#f2ead8"/>
<circle cx="18.5" cy="14.5" r="0.5" fill="#f2ead8"/>
</symbol>
<symbol id="item-dough-chopped" viewBox="0 0 32 32">
<use href="#grid-dice" style="color: #d9b97c"/>
</symbol>
<symbol id="item-dough-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#d9b97c"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-dough-cooked" viewBox="0 0 32 32">
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#d9b97c" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-dough-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================== patty ============================== -->
<symbol id="item-patty-raw" viewBox="0 0 32 32">
<ellipse cx="16" cy="19" rx="8.5" ry="5.5" fill="#8a5a3a"/>
<ellipse cx="16" cy="17.4" rx="7" ry="4" fill="#a06c48"/>
<path d="M11 16.5 c1.5 -1.5 8.5 -1.5 10 0" stroke="#6f462c" stroke-width="0.9" fill="none"/>
</symbol>
<symbol id="item-patty-chopped" viewBox="0 0 32 32">
<use href="#grid-dice" style="color: #8a5a3a"/>
</symbol>
<symbol id="item-patty-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#8a5a3a"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-patty-cooked" viewBox="0 0 32 32">
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#8a5a3a" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-patty-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================== fish ============================== -->
<symbol id="item-fish-raw" viewBox="0 0 32 32">
<path d="M8 17 c2.5 -3.5 7 -5 11 -3.5 c2 0.8 3.5 2.4 4 3.5 c-0.5 1.1 -2 2.7 -4 3.5 c-4 1.5 -8.5 0 -11 -3.5 z" fill="#7fb2c9"/>
<path d="M23 17 l5 -3.5 v7 z" fill="#7fb2c9"/>
<circle cx="11.5" cy="16" r="1" fill="#3a322a"/>
<path d="M15 14.5 c1.5 1.5 1.5 3.5 0 5 M18 14.8 c1.2 1.2 1.2 3.4 0 4.6" stroke="#5f95ad" stroke-width="0.9" fill="none"/>
</symbol>
<symbol id="item-fish-chopped" viewBox="0 0 32 32">
<use href="#grid-dice" style="color: #7fb2c9"/>
</symbol>
<symbol id="item-fish-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#7fb2c9"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-fish-cooked" viewBox="0 0 32 32">
<path d="M10 12.4 c1 -2.2 11 -2.2 12 0" stroke="#7fb2c9" stroke-width="2.6" stroke-linecap="round" fill="none"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-fish-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================== rice ============================== -->
<symbol id="item-rice-raw" viewBox="0 0 32 32">
<ellipse cx="16" cy="19.5" rx="9" ry="5" fill="#e8dfc8"/>
<circle cx="12" cy="18" r="1.8" fill="#f2ead8"/>
<circle cx="16" cy="16" r="1.8" fill="#f2ead8"/>
<circle cx="20" cy="18" r="1.8" fill="#f2ead8"/>
<circle cx="14" cy="20.5" r="1.7" fill="#f2ead8"/>
<circle cx="18.5" cy="20.5" r="1.7" fill="#f2ead8"/>
</symbol>
<symbol id="item-rice-cooking" viewBox="0 0 32 32">
<circle cx="16" cy="10.8" r="2.8" fill="#f2ead8"/>
<use href="#pot-steam"/>
</symbol>
<symbol id="item-rice-cooked" viewBox="0 0 32 32">
<ellipse cx="16" cy="12.2" rx="7" ry="3" fill="#f2ead8"/>
<use href="#bowl"/>
</symbol>
<symbol id="item-rice-burnt" viewBox="0 0 32 32">
<use href="#char"/>
</symbol>
<!-- ============================== plate ============================== -->
<symbol id="item-plate-raw" viewBox="0 0 32 32">
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="#f6f1e7"/>
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="none" stroke="#cfc5b5" stroke-width="1.2"/>
<ellipse cx="16" cy="17" rx="5.5" ry="3.4" fill="none" stroke="#e2dbcb" stroke-width="1"/>
</symbol>
<symbol id="item-plate-dirty" viewBox="0 0 32 32">
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="#f6f1e7"/>
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="none" stroke="#cfc5b5" stroke-width="1.2"/>
<ellipse cx="13" cy="15.5" rx="3" ry="1.8" fill="#9a8f7a" opacity="0.85"/>
<ellipse cx="20" cy="18.5" rx="2.4" ry="1.5" fill="#8a7d5a" opacity="0.8"/>
<ellipse cx="17" cy="13.8" rx="1.8" ry="1.1" fill="#7d8a5a" opacity="0.8"/>
</symbol>
<symbol id="item-plate-plated" viewBox="0 0 32 32">
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="#f6f1e7"/>
<ellipse cx="16" cy="17" rx="10" ry="6.5" fill="none" stroke="#cfc5b5" stroke-width="1.2"/>
<ellipse cx="16" cy="16.4" rx="6.5" ry="3" fill="#8a5a3a"/>
<path d="M10.5 15.4 c1 -2 8.5 -2 9.5 0 c0.5 1.2 -8.5 2.2 -9.5 0 z" fill="#7db85a"/>
<circle cx="16" cy="13.4" r="2.6" fill="#e0533d"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

+106
View File
@@ -0,0 +1,106 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<!-- Undercooked station fixtures (phase 07, task 01). 32x32 symbols.
The spawn shelf's stock circle is drawn neutral on purpose: the
renderer tints it per ingredient with the atlas `tint` option. -->
<symbol id="station-spawn_shelf" viewBox="0 0 32 32">
<rect x="4" y="5" width="24" height="4" rx="1.5" fill="#a5825a"/>
<rect x="4" y="22" width="24" height="4" rx="1.5" fill="#a5825a"/>
<rect x="6" y="9" width="3" height="13" fill="#8a6b45"/>
<rect x="23" y="9" width="3" height="13" fill="#8a6b45"/>
<circle cx="16" cy="15.5" r="6" fill="#cfc5b5"/>
<circle cx="16" cy="15.5" r="6" fill="none" stroke="#8a6b45" stroke-width="1.2"/>
</symbol>
<symbol id="station-chop_board" viewBox="0 0 32 32">
<rect x="5" y="9" width="22" height="15" rx="3" fill="#c99e63"/>
<rect x="5" y="9" width="22" height="15" rx="3" fill="none" stroke="#a5825a" stroke-width="1.4"/>
<path d="M9 13 H23 M9 17 H23 M9 21 H23" stroke="#a5825a" stroke-width="0.9" fill="none"/>
<path d="M7 6 L21 4 L21 6 L8 8 Z" fill="#dfe3e6"/>
<rect x="21" y="3.6" width="6" height="2.8" rx="1.2" fill="#6b5a48"/>
</symbol>
<symbol id="station-stove" viewBox="0 0 32 32">
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
<circle cx="16" cy="16.5" r="7" fill="none" stroke="#6b625a" stroke-width="2.4"/>
<circle cx="8.5" cy="24" r="1.4" fill="#6b625a"/>
<circle cx="12.5" cy="24" r="1.4" fill="#6b625a"/>
</symbol>
<symbol id="station-stove-off" viewBox="0 0 32 32">
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
<circle cx="16" cy="16.5" r="7" fill="none" stroke="#6b625a" stroke-width="2.4"/>
<circle cx="8.5" cy="24" r="1.4" fill="#6b625a"/>
</symbol>
<symbol id="station-stove-working" viewBox="0 0 32 32">
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
<path d="M12.5 20.5 q1.5 -2.5 3.5 0 q2 -2.5 3.5 0 z" fill="#ffa63d"/>
<rect x="10" y="11" width="12" height="8" rx="2" fill="#8b8b8b"/>
<rect x="9" y="9.6" width="14" height="2.6" rx="1.3" fill="#a3a3a3"/>
<circle cx="16" cy="8.6" r="1.1" fill="#a3a3a3"/>
</symbol>
<symbol id="station-stove-ready" viewBox="0 0 32 32">
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
<rect x="10" y="11" width="12" height="8" rx="2" fill="#8b8b8b"/>
<rect x="9" y="9.6" width="14" height="2.6" rx="1.3" fill="#a3a3a3"/>
<circle cx="16" cy="8.6" r="1.1" fill="#a3a3a3"/>
<path d="M12 5 q1 -1.6 0 -3 M16 4.6 q1 -1.6 0 -3 M20 5 q1 -1.6 0 -3" stroke="#e8e2d4" stroke-width="1.4" stroke-linecap="round" fill="none" opacity="0.85"/>
</symbol>
<symbol id="station-stove-burning" viewBox="0 0 32 32">
<rect x="3" y="6" width="26" height="21" rx="3" fill="#4a4238"/>
<rect x="3" y="6" width="26" height="21" rx="3" fill="none" stroke="#3a322a" stroke-width="1.4"/>
<rect x="11" y="12" width="10" height="7" rx="2" fill="#3b3b3b"/>
<path d="M16 4.5 c2.6 2.2 4 4 4 6.2 a4 4 0 0 1 -8 0 c0 -2.2 1.4 -4 4 -6.2 z" fill="#ff5a4e"/>
<path d="M16 8 c1.4 1.4 2.2 2.4 2.2 3.6 a2.2 2.2 0 0 1 -4.4 0 c0 -1.2 0.8 -2.2 2.2 -3.6 z" fill="#ffa63d"/>
</symbol>
<symbol id="station-pot" viewBox="0 0 32 32">
<rect x="8" y="12" width="16" height="11" rx="2.5" fill="#8b8b8b"/>
<rect x="6.6" y="10.4" width="18.8" height="3" rx="1.5" fill="#a3a3a3"/>
<circle cx="16" cy="9.2" r="1.4" fill="#a3a3a3"/>
<rect x="4.6" y="14" width="3.4" height="2.4" rx="1.2" fill="#6f6a63"/>
<rect x="24" y="14" width="3.4" height="2.4" rx="1.2" fill="#6f6a63"/>
</symbol>
<symbol id="station-pan" viewBox="0 0 32 32">
<ellipse cx="14" cy="17" rx="9.5" ry="6" fill="#55504a"/>
<ellipse cx="14" cy="15.8" rx="8" ry="4.6" fill="#6b625a"/>
<rect x="22" y="14.6" width="8" height="3" rx="1.5" fill="#6b5a48"/>
</symbol>
<symbol id="station-prep" viewBox="0 0 32 32">
<rect x="4" y="10" width="24" height="15" rx="2.5" fill="#d8c49a"/>
<rect x="4" y="10" width="24" height="15" rx="2.5" fill="none" stroke="#a5825a" stroke-width="1.4"/>
<ellipse cx="13" cy="17.5" rx="6" ry="3.4" fill="#f6f1e7"/>
<ellipse cx="13" cy="17.5" rx="6" ry="3.4" fill="none" stroke="#cfc5b5" stroke-width="1"/>
<circle cx="24" cy="17.5" r="3.4" fill="#8b8b8b"/>
</symbol>
<symbol id="station-serve" viewBox="0 0 32 32">
<rect x="4" y="8" width="24" height="18" rx="2.5" fill="#d8c49a"/>
<rect x="7" y="12" width="18" height="10" rx="1.8" fill="#4a4238"/>
<path d="M11 17 h10" stroke="#f6f1e7" stroke-width="1.6" stroke-linecap="round" fill="none"/>
<path d="M16 4.5 a3 3 0 0 1 3 3 v1 h-6 v-1 a3 3 0 0 1 3 -3 z" fill="#ffd95e"/>
<circle cx="16" cy="3.4" r="1" fill="#ffd95e"/>
</symbol>
<symbol id="station-sink" viewBox="0 0 32 32">
<rect x="5" y="9" width="22" height="16" rx="2.5" fill="#b9b9b9"/>
<rect x="8" y="12" width="16" height="10" rx="2" fill="#8f8f8f"/>
<circle cx="16" cy="18.5" r="1.5" fill="#6f6f6f"/>
<path d="M22 6 v4 a3 3 0 0 1 -3 3 h-2" stroke="#a3a3a3" stroke-width="2.4" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="station-trash" viewBox="0 0 32 32">
<path d="M10 9 L22 9 L20.5 27 L11.5 27 Z" fill="#7a7f85"/>
<rect x="8.4" y="5.6" width="15.2" height="3.4" rx="1.4" fill="#9aa0a6"/>
<path d="M13.4 12 V24 M16 12 V24 M18.6 12 V24" stroke="#62676d" stroke-width="1.3" fill="none"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

+61
View File
@@ -0,0 +1,61 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<!-- Undercooked tileset (phase 07, task 01). One <symbol> per drawable,
32x32 viewBox, hand-authored vector cartoon. Served from
app/static/assets/ and indexed by index.json. -->
<symbol id="tile-floor" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#b98a5c"/>
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
</symbol>
<symbol id="tile-floor-1" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#b98a5c"/>
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
<ellipse cx="24" cy="5" rx="2.6" ry="1.7" fill="#a1734c"/>
<ellipse cx="24" cy="5" rx="1.1" ry="0.7" fill="#8d6340"/>
</symbol>
<symbol id="tile-floor-2" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#b98a5c"/>
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
<path d="M3 15 L8 15 M25 25 L30 25 M5 27 L10 27" stroke="#ab7d52" stroke-width="1.4" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="tile-floor-3" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#b98a5c"/>
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
<rect x="13" y="13.5" width="6" height="6" rx="1.6" fill="#c4976b"/>
</symbol>
<symbol id="tile-floor-4" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#b98a5c"/>
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#a1734c" stroke-width="1.5" fill="none"/>
<path d="M9.5 0 V10.5 M23 10.5 V21.5 M14.5 21.5 V32" stroke="#a1734c" stroke-width="1" fill="none"/>
<circle cx="6" cy="6" r="0.9" fill="#ab7d52"/>
<circle cx="27" cy="16" r="0.9" fill="#ab7d52"/>
<circle cx="12" cy="27" r="0.9" fill="#ab7d52"/>
<circle cx="24" cy="28" r="0.7" fill="#ab7d52"/>
</symbol>
<symbol id="tile-counter" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#d8c49a"/>
<rect y="24" width="32" height="8" fill="#a5825a"/>
<rect y="22" width="32" height="3" fill="#8a6b45"/>
<path d="M3 3 H29" stroke="#c4b088" stroke-width="1.4" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="tile-wall" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#6b5a48"/>
<path d="M0 10.5 H32 M0 21.5 H32" stroke="#5a4b3b" stroke-width="1.5" fill="none"/>
<path d="M16 0 V10.5 M8 10.5 V21.5 M24 10.5 V21.5 M16 21.5 V32" stroke="#5a4b3b" stroke-width="1.5" fill="none"/>
</symbol>
<symbol id="tile-void" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#241f1a"/>
<path d="M-4 36 L36 -4 M-4 24 L24 -4 M8 36 L36 8 M20 36 L36 20" stroke="#3a322a" stroke-width="1.4" fill="none"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+41
View File
@@ -0,0 +1,41 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32">
<!-- Undercooked HUD symbols (phase 07, task 01): the order ticket,
the result stars, the host crown, the ready check, the
disconnected-cook ghost and the streak flame. -->
<symbol id="ui-ticket" viewBox="0 0 32 32">
<path d="M7 5 h18 v22 l-3 -2.4 -3 2.4 -3 -2.4 -3 2.4 -3 -2.4 -3 2.4 z" fill="#f6f1e7"/>
<path d="M7 5 h18 v5 H7 z" fill="#ffd95e"/>
<path d="M10.5 15 h11 M10.5 19 h11 M10.5 23 h7" stroke="#9a8f7a" stroke-width="1.5" stroke-linecap="round" fill="none"/>
</symbol>
<symbol id="ui-star" viewBox="0 0 32 32">
<path d="M16 3.5 l3.8 7.7 8.5 1.2 -6.2 6 1.5 8.4 -7.6 -4 -7.6 4 1.5 -8.4 -6.2 -6 8.5 -1.2 z" fill="#ffd95e"/>
<path d="M16 3.5 l3.8 7.7 8.5 1.2 -6.2 6 1.5 8.4 -7.6 -4 -7.6 4 1.5 -8.4 -6.2 -6 8.5 -1.2 z" fill="none" stroke="#d9a92e" stroke-width="1.2"/>
</symbol>
<symbol id="ui-crown" viewBox="0 0 32 32">
<path d="M6 22 L8 10 l5 5 3 -7 3 7 5 -5 2 12 z" fill="#ffd95e"/>
<rect x="6" y="22" width="20" height="4" rx="1.6" fill="#d9a92e"/>
<circle cx="8" cy="9.4" r="1.5" fill="#d9a92e"/>
<circle cx="16" cy="6.4" r="1.5" fill="#d9a92e"/>
<circle cx="24" cy="9.4" r="1.5" fill="#d9a92e"/>
</symbol>
<symbol id="ui-ready" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="11" fill="#67d16c"/>
<path d="M10.5 16.5 L14.5 20.5 L21.5 12.5" stroke="#f6f1e7" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</symbol>
<symbol id="ui-ghost" viewBox="0 0 32 32">
<path d="M9 26 V15 a7 7 0 0 1 14 0 v11 l-2.3 -2 -2.4 2 -2.3 -2 -2.3 2 -2.4 -2 z" fill="#f6f1e7" opacity="0.55"/>
<circle cx="13.5" cy="14.5" r="1.4" fill="#4a4238"/>
<circle cx="18.5" cy="14.5" r="1.4" fill="#4a4238"/>
</symbol>
<symbol id="ui-streak" viewBox="0 0 32 32">
<path d="M16 5 c3 2.8 4.8 5.2 4.8 8 a4.8 4.8 0 0 1 -9.6 0 c0 -2.8 1.8 -5.2 4.8 -8 z" fill="#ffa63d"/>
<path d="M16 10 c1.5 1.6 2.6 2.9 2.6 4.3 a2.6 2.6 0 0 1 -5.2 0 c0 -1.4 1.1 -2.7 2.6 -4.3 z" fill="#fff7d6"/>
<path d="M9 22.5 q-1.4 1.8 0 3 M23 22.5 q1.4 1.8 0 3" stroke="#ffa63d" stroke-width="1.6" stroke-linecap="round" fill="none"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

+516
View File
@@ -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/<file>.svg` (one `<symbol id>` 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-<body>-<facing>-<frame>` — 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-<kind>` 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>-<facing>-<frame>` (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-<type>` (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-<body>-<facing>-
<frame>`). 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).
+10
View File
@@ -169,6 +169,7 @@ __all__ = [
"ROOM_EVENTS", "ROOM_EVENTS",
"ROOM_TRANSITIONS", "ROOM_TRANSITIONS",
"SEAT_GRACE_S", "SEAT_GRACE_S",
"TEST_MODE_SEED",
"IllegalTransition", "IllegalTransition",
"Room", "Room",
"RoomState", "RoomState",
@@ -200,6 +201,15 @@ _SEED_MODULUS: Final[int] = 2**63
_SEED_MULTIPLIER: Final[int] = 6364136223847 _SEED_MULTIPLIER: Final[int] = 6364136223847
_SEED_INCREMENT: Final[int] = 1442695040888963407 _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): class RoomState(StrEnum):
"""The room's lifecycle state (PLAN §6 FSM).""" """The room's lifecycle state (PLAN §6 FSM)."""
+6 -1
View File
@@ -47,7 +47,7 @@ from contextlib import suppress
from undercooked.config import Settings from undercooked.config import Settings
from undercooked.server.clock import Clock from undercooked.server.clock import Clock
from undercooked.server.codes import new_code, normalize_code 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.seats import NAME_MAX_LEN, sanitize_name
from undercooked.server.sessions import SessionRecord, SessionStore from undercooked.server.sessions import SessionRecord, SessionStore
from undercooked.sim.content import ContentBundle from undercooked.sim.content import ContentBundle
@@ -202,6 +202,11 @@ class RoomSupervisor:
settings=self._settings, settings=self._settings,
clock=self._clock, clock=self._clock,
level_id=level_id, 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, sleep=self._sleep,
content=self._content, content=self._content,
sessions=self.session_for, sessions=self.session_for,
+18 -2
View File
@@ -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 """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): for conn in registry.for_room(room.code):
if conn is exclude:
continue
conn.enqueue(LobbyMsg.model_validate(room.lobby_payload(conn.token))) conn.enqueue(LobbyMsg.model_validate(room.lobby_payload(conn.token)))
@@ -1005,6 +1012,15 @@ async def _seat_or_spectate(
await evict(previous) await evict(previous)
conn.enqueue(_welcome(room, token, rejoin=was_ghost)) conn.enqueue(_welcome(room, token, rejoin=was_ghost))
conn.enqueue(LobbyMsg.model_validate(room.lobby_payload(token))) 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( async def _reseat(
+307
View File
@@ -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:
{ "<id>": {"file": "<name>.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 `<symbol>` 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 <symbol> 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())
+11 -1
View File
@@ -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.
+219 -5
View File
@@ -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 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 `uc.token`, `uc.lastCode`) is scoped to it, so tests cannot observe each other's
identity. 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 __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 import pytest
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Iterator
from playwright.sync_api import Browser, BrowserContext, Page, ViewportSize from playwright.sync_api import Browser, BrowserContext, Page, ViewportSize
# The default desktop viewport; story tests that care about small screens call # The default desktop viewport; story tests that care about small screens call
# `page.set_viewport_size` before navigating. # `page.set_viewport_size` before navigating.
VIEWPORT: ViewportSize = {"width": 1280, "height": 720} 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 @pytest.fixture
def browser() -> Iterator[Browser]: 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 `sync_playwright()` is open — so a session- or module-scoped fixture makes every
later `async def` test in the run die with later `async def` test in the run die with
`RuntimeError: Runner.run() cannot be called from a running event loop` `RuntimeError: Runner.run() cannot be called from a running event loop`
(pytest-asyncio, `asyncio_mode = "auto"`). Closing the whole stack inside the (pytest-asyncio, `asyncio_mode = "auto"`). Closing the whole stack inside
test leaves the main thread clean for whatever runs next. the test leaves the main thread clean for whatever runs next.
""" """
with sync_playwright() as playwright: with sync_playwright() as playwright:
launched = playwright.chromium.launch(headless=True) launched = playwright.chromium.launch(headless=True)
@@ -64,3 +95,186 @@ def page(context: BrowserContext) -> Iterator[Page]:
yield opened yield opened
finally: finally:
opened.close() 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")
+278
View File
@@ -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
+247
View File
@@ -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)
+95 -22
View File
@@ -1,9 +1,13 @@
"""US-01 (landing surface) e2e: a real browser loads the shell, cleanly. """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 The shell is now the phase-07 screen machine (task 02 replaced phase 01's
`TestClient` cannot: the document parses, the ES module runs without a single placeholder): loading the page runs the module graph without a single
console error, the lobby controls are reachable, and — the point of PLAN A12 — console error, the landing controls are reachable and 44 px, and — the
loading and clicking the page makes requests to *nothing* but this process. 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 from __future__ import annotations
@@ -21,10 +25,9 @@ if TYPE_CHECKING:
pytestmark = pytest.mark.e2e pytestmark = pytest.mark.e2e
# Strings a player sees, kept in sync with app/static/index.html and # Strings a player sees, kept in sync with app/static/js/screens/*.js by
# app/static/js/app.js by hand: changing one must be a deliberate act. # hand: changing one must be a deliberate act.
READY = "Pick a kitchen to start." READY = "Pick a kitchen to start."
NOT_IMPLEMENTED = "Not implemented until phase 07."
SHORT_CODE = "A room code is 5 characters." SHORT_CODE = "A room code is 5 characters."
TOKEN_PATTERN = re.compile(r"^[A-Za-z0-9_-]{20,}$") 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: 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") 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 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] page: Page, live_server: str, console_errors: list[str]
) -> None: ) -> 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) open_shell(page, live_server)
status = page.locator("#status")
page.get_by_role("button", name="Create lobby").click() 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.locator("#join-code").press_sequentially("abcde")
page.get_by_role("button", name="Join").click() page.get_by_role("button", name="Join").click()
expect(status).to_have_text(NOT_IMPLEMENTED) # The error screen names the refusal and the code it tried, normalised.
# The code is normalised for the player: uppercase, five characters. expect(page.locator("#error-title")).to_have_text("That kitchen is gone")
assert page.locator("#join-code").input_value() == "ABCDE" expect(page.locator("#error-code")).to_contain_text("Room code: ABCDE")
assert console_errors == [] 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: 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] = [] requested: list[str] = []
page.on("request", lambda request: requested.append(request.url)) page.on("request", lambda request: requested.append(request.url))
open_shell(page, live_server) open_shell(page, live_server)
page.get_by_role("button", name="Create lobby").click() 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)] == [] 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. # …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}/") == [ 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/base.css",
f"{live_server}/static/css/screens.css",
f"{live_server}/static/js/app.js", 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()}; 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 == [] 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) open_shell(page, live_server)
page.locator("#join-code").press_sequentially("k7xyz") page.locator("#join-code").press_sequentially("k7xyz")
page.get_by_role("button", name="Join").click() 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) open_shell(page, live_server)
assert page.evaluate("sessionStorage.getItem('uc.lastCode')") == "K7XYZ" assert page.evaluate("sessionStorage.getItem('uc.lastCode')") == "K7XYZ"
assert page.locator("#join-code").input_value() == "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: def test_the_canvas_is_absent_outside_the_play_screen(page: Page, live_server: str) -> None:
"""Phase 07 unhides `#kcanvas`; until then it is out of the layout and a11y tree.""" """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) 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") canvas = page.locator("#kcanvas")
expect(canvas).to_be_hidden() expect(canvas).to_be_hidden()
assert canvas.get_attribute("aria-hidden") == "true" assert canvas.get_attribute("aria-hidden") == "true"
+175
View File
@@ -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()
+406
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+724
View File
@@ -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()
+750
View File
@@ -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)
+404
View File
@@ -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()
+44
View File
@@ -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
+80
View File
@@ -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]
+32 -13
View File
@@ -111,25 +111,30 @@ def test_root_serves_the_shell_html(client: TestClient) -> None:
assert response.headers["content-type"].startswith("text/html") assert response.headers["content-type"].startswith("text/html")
assert int(response.headers["content-length"]) > 500 # streamed from the file assert int(response.headers["content-length"]) > 500 # streamed from the file
html = response.text 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 'href="/static/css/base.css"' in html
assert 'id="screen-lobby"' in html assert 'href="/static/css/screens.css"' in html
assert 'aria-live="polite"' in html assert 'id="screen-root"' in html
assert ( assert (
'name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"' in html 'name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"' in html
) )
def test_shell_offers_the_lobby_controls(client: TestClient) -> None: 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 html = client.get("/").text
assert 'id="create-lobby"' in html assert 'id="screen-root"' in html
assert 'id="join-form"' in html landing = client.get("/static/js/screens/landing.js")
assert 'id="join-code"' in html assert landing.status_code == 200
assert 'inputmode="text"' in html text = landing.text
assert 'autocapitalize="characters"' in html assert 'id: "create-lobby"' in text
assert 'size="5"' in html # five characters wide: no 20-char intrinsic box assert 'id: "join-form"' in text
assert 'maxlength="5"' in html assert 'id: "join-code"' in text
assert 'for="join-code"' in html # the code input has a real label 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: 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 css.headers["content-type"].startswith("text/css")
assert "--uc-hit: 44px" in css.text # the 44px hit-target contract lives in 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.status_code == 200
assert script.headers["content-type"].startswith("text/javascript") 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: def test_unknown_path_is_404(client: TestClient) -> None:
+24 -1
View File
@@ -21,7 +21,7 @@ import pytest
from undercooked.config import Settings from undercooked.config import Settings
from undercooked.server.clock import FakeClock from undercooked.server.clock import FakeClock
from undercooked.server.codes import CROCKFORD 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 ( from undercooked.server.rooms import (
GC_INTERVAL_S, GC_INTERVAL_S,
SEAT_GRACE_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): with pytest.raises(asyncio.CancelledError):
await next(iter(loop_task)) await next(iter(loop_task))
assert clock.now() == 3 * GC_INTERVAL_S # exactly three 5-second cycles 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
+31
View File
@@ -195,6 +195,37 @@ async def test_happy_handshake_welcome_and_lobby(ws_server: Any) -> None:
assert len(lobby2["seats"]) == 2 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: async def test_ping_before_hello_gets_pong(ws_server: Any) -> None:
with ws_server() as (url, _app): with ws_server() as (url, _app):
code, _ = _create_room(url) code, _ = _create_room(url)
+16 -8
View File
@@ -243,14 +243,23 @@ def _join(url: str, code: str, token: str) -> None:
async def _start_playing( async def _start_playing(
url: str, app: Any, ws_client: Any 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 """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 supervisor = app.state.supervisor
code = _create_room(url, "tok-a") code = _create_room(url, "tok-a")
_join(url, code, "tok-b") _join(url, code, "tok-b")
a = await ws_client(url, code, "tok-a", name="A") a = await ws_client(url, code, "tok-a", name="A")
b = await ws_client(url, code, "tok-b", name="B") b = await ws_client(url, code, "tok-b", name="B")
keepalive = asyncio.create_task(_keepalive((a, b)))
for client in (a, b): for client in (a, b):
welcome = await client.expect("welcome") welcome = await client.expect("welcome")
assert welcome["state"] == "lobby" assert welcome["state"] == "lobby"
@@ -286,7 +295,7 @@ async def _start_playing(
# they arrived before the countdown frames, so the queue is in order. # they arrived before the countdown frames, so the queue is in order.
for client in (a, b): for client in (a, b):
await client.collect("lobby", 3, wait_s=10.0) 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: 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 live_server: Any, ws_client: Any
) -> None: ) -> None:
with live_server(levels_dir=str(ROUND_LEVELS_DIR)) as (url, app): 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() seq_a, seq_b = _Sequencer(), _Sequencer()
# --- the countdown 3 -> 1, then the snap stream # --- 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)): for name, frames in (("a", snaps_a), ("b", snaps_b)):
_assert_snap_ticks(name, frames) _assert_snap_ticks(name, frames)
# --- the inputs: A the scripted soup, B the walking loop, both # --- the inputs: A the scripted soup, B the walking loop
# kept alive past the heartbeat dead window
keepalive = asyncio.create_task(_keepalive((a, b)))
soup = asyncio.create_task(_drive_soup(a, "tok-a", seq_a)) soup = asyncio.create_task(_drive_soup(a, "tok-a", seq_a))
try: try:
await _wander(b, "tok-b", seq_b) # B's inputs, while the soup runs 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 live_server: Any, ws_client: Any
) -> None: ) -> None:
with live_server(levels_dir=str(ROUND_LEVELS_DIR)) as (url, app): 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() seq_a, seq_b = _Sequencer(), _Sequencer()
await a.collect("countdown", 3, wait_s=20.0) 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 c.close()
await a.close() await a.close()
await b.close() await b.close()
keepalive.cancel()
+485
View File
@@ -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
`<symbol>` 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 `<symbol>` 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 `<symbol id=...>...</symbol>` block from a file's text."""
text = path.read_text(encoding="utf-8")
start = text.index(f'<symbol id="{symbol_id}"')
end = text.index("</symbol>", start) + len("</symbol>")
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 `<symbol>` 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 <symbol id={symbol_id!r}> 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-<name>: <hex>`` 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: "<hex>"`` 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 = '<symbol id="tile-floor" viewBox="0 0 8 8"><rect width="4" height="4"/></symbol>'
for name in (src, out):
path = name / "stations.svg"
text = path.read_text(encoding="utf-8")
path.write_text(text.replace("</svg>", f" {duplicate}\n</svg>"), 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") + "<!-- drifted -->", 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 = '<symbol viewBox="0 0 8 8"><rect width="4" height="4"/></symbol>'
for name in (src, out):
path = name / "fx.svg"
path.write_text(
path.read_text(encoding="utf-8").replace("</svg>", f" {anonymous}\n</svg>"),
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 = '<symbol id="fx-unsized"><rect width="4" height="4"/></symbol>'
for name in (src, out):
path = name / "fx.svg"
path.write_text(
path.read_text(encoding="utf-8").replace("</svg>", f" {unsized}\n</svg>"),
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(
'<svg xmlns="http://www.w3.org/2000/svg">'
'<symbol id="a" width="16" height="8"><rect/></symbol>'
"<symbol id=\"b\" viewBox=\"0 0 12 6\"><rect/></symbol></svg>",
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()
+134
View File
@@ -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
+148
View File
@@ -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-<kind>-<form>` 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-<kind>-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}"
+1
View File
@@ -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: 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.""" """Pins today's tree so that adding a document is a deliberate, named act."""
assert written_docs() == { assert written_docs() == {
"docs/CLIENT.md",
"docs/DESIGN.md", "docs/DESIGN.md",
"docs/DEPLOYMENT.md", "docs/DEPLOYMENT.md",
"docs/LEVELS.md", "docs/LEVELS.md",
+15 -6
View File
@@ -18,6 +18,12 @@ REPO_ROOT = Path(__file__).resolve().parents[2]
HARNESS_MARKERS = ("unit", "integration", "e2e", "slow") HARNESS_MARKERS = ("unit", "integration", "e2e", "slow")
TEST_SUBDIRS = ("unit", "integration", "sim", "e2e") 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: def test_package_metadata() -> None:
assert undercooked.__version__ == "0.1.0" 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: 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 The durable invariant is the naming contract in `tests/e2e/README.md`: a
browser suite exists the durable invariant is the naming contract in story suite that is not named for its story is one that nobody can find,
`tests/e2e/README.md`: a story suite that is not named for its story is one that and a suite that cannot be collected is one that never runs. Technical
nobody can find, and one that cannot be collected is one that never runs. 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 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}" assert files, f"the e2e marker selected nothing:\n{result.stdout}"
for path in files: for path in files:
assert path.startswith("tests/e2e/"), path 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
+22 -1
View File
@@ -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") html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
tags = re.findall(r"<(script|link|img)\b", html, re.I) 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 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) == [] assert scan_element_urls(STATIC_DIR / "index.html", html) == []
@@ -321,6 +323,18 @@ def test_a_planted_html_reference_fails_the_scan(
'<image href="https://x.example.com/a.png"/></svg>', '<image href="https://x.example.com/a.png"/></svg>',
"absolute URL", "absolute URL",
), ),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
'<use xlink:href="https://evil.example/x.svg"/></svg>',
"absolute URL",
),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
'<image xlink:href="https://evil.example/x.svg"/></svg>',
"absolute URL",
),
(".html", '<a href="https://external.example.com">out</a>', "absolute URL"), (".html", '<a href="https://external.example.com">out</a>', "absolute URL"),
], ],
) )
@@ -364,6 +378,13 @@ def test_a_planted_hosted_library_token_fails_the_scan(
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">' '<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">'
'<use xlink:href="#tile"/></svg>', '<use xlink:href="#tile"/></svg>',
), ),
(
".svg",
'<svg xmlns="http://www.w3.org/2000/svg"'
' xmlns:xml="http://www.w3.org/XML/1998/namespace"'
' xmlns:xhtml="http://www.w3.org/1999/xhtml">'
'<rect width="16" height="16"/></svg>',
),
(".js", "// a comment about docs/PROTOCOL.md and a/b/c paths\nexport const ok = 1;\n"), (".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", 'const ws = new WebSocket(`${location.origin}/ws/${code}`);\n'),
(".js", 'fetch("/static/api/levels");\n'), (".js", 'fetch("/static/api/levels");\n'),