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`.
@@ -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/`.
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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–4 cooks.</p>
|
<p class="tagline">Anonymous co-op kitchen chaos. No accounts, no install, 1–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>
|
||||||
|
|||||||
@@ -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.
|
||||||
}
|
*/
|
||||||
|
function makeView() {
|
||||||
/** Write the live region. `kind` is "info" | "error" and only changes styling. */
|
return { players: {}, stations: {}, orders: {} };
|
||||||
export function announce(message, root = document, kind = "info") {
|
|
||||||
const region = root.getElementById("status");
|
|
||||||
if (!region) return;
|
|
||||||
region.textContent = message;
|
|
||||||
region.dataset.kind = kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wire the lobby screen. Returns the tab identity so later phases can reuse it.
|
|
||||||
*/
|
|
||||||
export function mount(root = document) {
|
|
||||||
const token = getToken();
|
|
||||||
const createButton = root.getElementById("create-lobby");
|
|
||||||
const joinForm = root.getElementById("join-form");
|
|
||||||
const joinInput = root.getElementById("join-code");
|
|
||||||
|
|
||||||
const remembered = lastCode();
|
|
||||||
if (joinInput && remembered) joinInput.value = remembered;
|
|
||||||
|
|
||||||
if (createButton) {
|
|
||||||
createButton.addEventListener("click", () => {
|
|
||||||
// Phase 07 (on the phase-06 transport): POST to the room supervisor,
|
|
||||||
// open /ws/{code}.
|
|
||||||
announce(NOT_IMPLEMENTED, root);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
return;
|
view.players = {};
|
||||||
|
view.stations = {};
|
||||||
|
view.orders = {};
|
||||||
|
for (const entry of raw.p ?? []) view.players[entry.i] = entry;
|
||||||
|
for (const entry of raw.st ?? []) view.stations[stationKey(entry)] = entry;
|
||||||
|
for (const entry of raw.o ?? []) view.orders[entry.id] = entry;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const delta = raw.d ?? {};
|
||||||
|
for (const entry of delta.p ?? []) {
|
||||||
|
if (entry.gone) delete view.players[entry.i];
|
||||||
|
else view.players[entry.i] = entry;
|
||||||
|
}
|
||||||
|
for (const entry of delta.st ?? []) {
|
||||||
|
const key = stationKey(entry);
|
||||||
|
if (entry.gone) delete view.stations[key];
|
||||||
|
else view.stations[key] = entry;
|
||||||
|
}
|
||||||
|
for (const entry of delta.o ?? []) {
|
||||||
|
if (entry.gone) delete view.orders[entry.id];
|
||||||
|
else view.orders[entry.id] = entry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushLog(frame) {
|
||||||
|
app.messageLog.push(frame);
|
||||||
|
if (app.messageLog.length > LOG_CAP) app.messageLog.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show one screen. An unknown screen name (a bug) logs once and
|
||||||
|
* becomes ERROR; the same screen re-shown in place calls the
|
||||||
|
* module's `update` (a re-mount would drop focus off live controls).
|
||||||
|
*/
|
||||||
|
function show(screen, data = {}) {
|
||||||
|
if (!(screen in SCREEN_MODULES)) {
|
||||||
|
if (!warnedUnknown.has(screen)) {
|
||||||
|
console.warn(
|
||||||
|
`undercooked: unknown screen "${String(screen)}" — going to the error screen`,
|
||||||
|
);
|
||||||
|
warnedUnknown.add(screen);
|
||||||
}
|
}
|
||||||
rememberCode(code);
|
screen = SCREENS.ERROR;
|
||||||
// Phase 07 (on the phase-06 transport): join the room over the
|
}
|
||||||
// WebSocket.
|
app.data = data;
|
||||||
announce(NOT_IMPLEMENTED, root);
|
if (app.screen === screen && mounted !== null) {
|
||||||
});
|
if (typeof mounted.update === "function") mounted.update(data, app);
|
||||||
|
return screen;
|
||||||
|
}
|
||||||
|
if (mounted !== null && typeof mounted.unmount === "function") mounted.unmount();
|
||||||
|
mounted = null;
|
||||||
|
app.screen = screen;
|
||||||
|
mounted = SCREEN_MODULES[screen];
|
||||||
|
// The previous screen's DOM is the previous screen's problem no
|
||||||
|
// more: a screen module appends, it does not replace, so the
|
||||||
|
// machine clears the root before mounting (a re-mount would stack
|
||||||
|
// a second copy of the screen on top of the first).
|
||||||
|
screenRoot.replaceChildren();
|
||||||
|
if (typeof mounted.mount === "function") mounted.mount(screenRoot, data, app);
|
||||||
|
return screen;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!lastCode() && root.getElementById("status")) {
|
function failToLanding(banner) {
|
||||||
announce(READY_MESSAGE, root);
|
app.banner = banner;
|
||||||
|
show(SCREENS.LANDING, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token, code: lastCode() };
|
// ------------------------------------------------------------- verbs
|
||||||
|
|
||||||
|
async function createLobby({ name = null } = {}) {
|
||||||
|
app.leaving = false;
|
||||||
|
show(SCREENS.CONNECTING, { what: "creating" });
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch("/api/rooms", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: app.token,
|
||||||
|
...(name !== null && name !== "" ? { name } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
failToLanding("Cannot reach the server — check the address and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.status === 503) {
|
||||||
|
failToLanding("The server is at capacity — try again in a moment.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.status !== 201) {
|
||||||
|
failToLanding("The server refused the room. Try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await response.json().catch(() => null);
|
||||||
|
if (body === null || typeof body.room_code !== "string") {
|
||||||
|
failToLanding("The server answered something unrecognisable. Try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.roomCode = body.room_code;
|
||||||
|
rememberCode(body.room_code);
|
||||||
|
connection.connect(body.room_code, { token: app.token });
|
||||||
|
}
|
||||||
|
|
||||||
|
function join(code) {
|
||||||
|
app.leaving = false;
|
||||||
|
app.roomCode = code;
|
||||||
|
rememberCode(code);
|
||||||
|
show(SCREENS.CONNECTING, { what: "joining", code });
|
||||||
|
connection.connect(code, { token: app.token });
|
||||||
|
}
|
||||||
|
|
||||||
|
function leave() {
|
||||||
|
app.leaving = true;
|
||||||
|
connection.close();
|
||||||
|
show(SCREENS.LANDING, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToLanding() {
|
||||||
|
app.leaving = true;
|
||||||
|
connection.close();
|
||||||
|
app.banner = null;
|
||||||
|
show(SCREENS.LANDING, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryJoin() {
|
||||||
|
if (app.roomCode === "") return;
|
||||||
|
app.leaving = false;
|
||||||
|
show(SCREENS.CONNECTING, { what: "joining", code: app.roomCode });
|
||||||
|
connection.connect(app.roomCode, { token: app.token });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ready() {
|
||||||
|
connection.send({ type: "ready" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
connection.send({ type: "start" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLevel(level) {
|
||||||
|
connection.send({ type: "set_level", level });
|
||||||
|
}
|
||||||
|
|
||||||
|
function emote(id) {
|
||||||
|
connection.send({ type: "emote", id });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLobby() {
|
||||||
|
connection.send({ type: "to_lobby" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextLevel() {
|
||||||
|
connection.send({ type: "next_level" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendInput(move, act) {
|
||||||
|
connection.sendInput(move, act);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSession() {
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/session", {
|
||||||
|
headers: { "X-Undercooked-Token": app.token },
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
app.session = await response.json();
|
||||||
|
if (mounted !== null && typeof mounted.renderBest === "function") {
|
||||||
|
mounted.renderBest(app);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* offline: the previous session stays */
|
||||||
|
}
|
||||||
|
return app.session;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLevels() {
|
||||||
|
if (app.levels !== null) return app.levels;
|
||||||
|
try {
|
||||||
|
const response = await fetch("/api/levels");
|
||||||
|
app.levels = response.ok ? await response.json() : [];
|
||||||
|
} catch {
|
||||||
|
app.levels = [];
|
||||||
|
}
|
||||||
|
return app.levels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The merged view's own half (a `snap` landed, delta or full). */
|
||||||
|
function orderOpen() {
|
||||||
|
return Object.keys(app.view.orders).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showReconnecting(visible) {
|
||||||
|
if (mounted !== null && typeof mounted.showReconnecting === "function") {
|
||||||
|
mounted.showReconnecting(visible);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- reducer
|
||||||
|
|
||||||
|
connection.onMessage = (frame) => {
|
||||||
|
// The composition root's half (phase 07 task 03): the `snap` frames
|
||||||
|
// also feed the render buffer before the reducer's DOM view.
|
||||||
|
if (onWireMessage !== null) onWireMessage(frame);
|
||||||
|
pushLog(frame);
|
||||||
|
switch (frame.type) {
|
||||||
|
case "welcome": {
|
||||||
|
app.welcome = frame;
|
||||||
|
app.roomCode = frame.room;
|
||||||
|
app.you = frame.you;
|
||||||
|
// A fresh token in a non-lobby room is a spectator (a rejoin
|
||||||
|
// reclaims its seat, so `rejoined` is the discriminator).
|
||||||
|
app.spectator = frame.state !== "lobby" && frame.rejoined !== true;
|
||||||
|
show(SCREEN_BY_ROOM_STATE[frame.state] ?? SCREENS.ERROR, { code: frame.room });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "lobby": {
|
||||||
|
app.lobby = frame;
|
||||||
|
app.roomCode = frame.code;
|
||||||
|
show(
|
||||||
|
frame.state === "lobby" ? SCREENS.LOBBY : SCREEN_BY_ROOM_STATE[frame.state] ?? SCREENS.ERROR,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "countdown":
|
||||||
|
show(SCREENS.COUNTDOWN, { n: frame.n });
|
||||||
|
break;
|
||||||
|
case "snap":
|
||||||
|
if (frame.raw !== undefined) applySnapshot(frame.raw);
|
||||||
|
show(SCREENS.PLAYING, {});
|
||||||
|
break;
|
||||||
|
case "round":
|
||||||
|
app.round = frame;
|
||||||
|
show(SCREENS.ROUND_END, {});
|
||||||
|
break;
|
||||||
|
case "err": {
|
||||||
|
connection.noteError(frame);
|
||||||
|
if (FATAL_ERR_CODES.includes(frame.code)) {
|
||||||
|
app.error = frame;
|
||||||
|
show(SCREENS.ERROR, { code: frame.code, msg: frame.msg });
|
||||||
|
} else if (mounted !== null && typeof mounted.setBanner === "function") {
|
||||||
|
app.banner = frame.msg;
|
||||||
|
mounted.setBanner(frame.msg);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "pong":
|
||||||
|
break; // the keep-alive's answer: nothing to render
|
||||||
|
case "ev":
|
||||||
|
break; // FX/log (task 04's renderer consumes these)
|
||||||
|
default:
|
||||||
|
break; // unknown server types are ignored (patch-compatible)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.onStatus = (status) => {
|
||||||
|
if (status === "mismatch") {
|
||||||
|
show(SCREENS.ERROR, { code: "protocol_mismatch" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (status !== "closed") return;
|
||||||
|
if (app.leaving) return; // an intentional close: no error screen
|
||||||
|
const code = connection.lastCloseCode;
|
||||||
|
if (code === 1000 && app.screen === SCREENS.PLAYING) {
|
||||||
|
// A drop mid-round: the seat is kept for the grace; phase 08
|
||||||
|
// reconnects. This task's half is the overlay.
|
||||||
|
showReconnecting(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
show(SCREENS.ERROR, { code: code === 1006 ? "unreachable" : "closed" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// The public verbs — assigned onto `app` itself so the screens (which
|
||||||
|
// hold `app`) and the e2e seam (`window.__undercooked`) see one and
|
||||||
|
// the same live object: a `show()` later is visible through the seam.
|
||||||
|
Object.assign(app, {
|
||||||
|
connection,
|
||||||
|
show,
|
||||||
|
createLobby,
|
||||||
|
join,
|
||||||
|
leave,
|
||||||
|
backToLanding,
|
||||||
|
retryJoin,
|
||||||
|
ready,
|
||||||
|
start,
|
||||||
|
setLevel,
|
||||||
|
emote,
|
||||||
|
toLobby,
|
||||||
|
nextLevel,
|
||||||
|
sendInput,
|
||||||
|
fetchSession,
|
||||||
|
fetchLevels,
|
||||||
|
orderOpen,
|
||||||
|
showReconnecting,
|
||||||
|
});
|
||||||
|
|
||||||
|
show(SCREENS.LANDING, {});
|
||||||
|
|
||||||
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-mount when loaded as the page's module, but stay importable (and
|
// 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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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 */
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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 */
|
||||||
|
}
|
||||||
@@ -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.";
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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).
|
||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||