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