**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`.
725 lines
31 KiB
Python
725 lines
31 KiB
Python
"""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()
|