**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`.
162 lines
5.4 KiB
JavaScript
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 }),
|
|
};
|
|
}
|