// 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 : [], }; }