Files
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

318 lines
11 KiB
JavaScript

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