Files
undercooked/app/static/js/net/events.js
T
ducoterra 95ca3fd1fa 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`.
2026-10-10 18:03:52 -04:00

162 lines
5.4 KiB
JavaScript

// 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 }),
};
}