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`.
This commit is contained in:
2026-10-10 18:03:52 -04:00
parent f26849c1f3
commit 95ca3fd1fa
78 changed files with 13650 additions and 259 deletions
+189
View File
@@ -0,0 +1,189 @@
// Undercooked — the WebSocket connection (phase 07 task 02).
//
// A thin, protocol-only layer (hardened into the rejoin/backoff machine
// in phase 08): it opens `/ws/{code}`, sends the `hello` (PLAN A14 —
// the version travels in the hello only), dispatches every server frame
// to a single `onMessage` handler, sends client frames, and classifies
// how the socket died so the app's state machine can pick the right
// screen (the close-code decision table, docs/PROTOCOL.md "Heartbeats,
// drops, and seat grace").
//
// Deliberate gaps in this task: no reconnect/backoff (phase 08), no
// snapshot buffering (task 03), no input prediction (task 08). The
// `protocol_mismatch` path *is* final in this task: a `p` the server
// refuses means this client must not retry — it stops and the app
// shows the error screen without a retry affordance.
const PROTOCOL_VERSION = 1;
/** The close codes the client can act on (docs/PROTOCOL.md "Close codes"). */
export const CLOSE_CODES = Object.freeze({
normal: 1000,
protocol_mismatch: 4400,
room_gone: 4401,
room_full: 4402,
evicted: 4403,
abuse: 4404,
});
/** The `err` codes that end the session on this socket (fatal for the app). */
export const FATAL_ERR_CODES = Object.freeze([
"room_gone",
"room_full",
"protocol_mismatch",
"evicted",
"internal",
]);
/**
* Build a connection. The returned object's surface:
*
* connect(code, { token, name }) — open `/ws/{code}` and `hello`
* send(model) — one JSON frame out (false when closed)
* sendInput(move, act) — one `input` frame, `seq` owned here
* onMessage(frame) — the single reducer the app installs
* onStatus(status) — "idle" | "connecting" | "open" | "closed" | "mismatch"
* lastCloseCode — the close code of the last death (1006 if the
* transport died without a close frame)
* noteError(frame) — feed an `err` frame; `protocol_mismatch`
* latches the mismatch (no retry, ever)
* close() — a clean close (1000)
*/
export function createConnection() {
let socket = null;
let status = "idle";
let lastCloseCode = 1006;
let mismatchLatched = false;
let seq = 0;
let pingTimer = null;
let pingClock = 0;
let onMessage = null;
let onStatus = null;
function setStatus(next) {
if (status === next) return;
status = next;
if (onStatus) onStatus(status);
}
function stopPing() {
if (pingTimer !== null) clearInterval(pingTimer);
pingTimer = null;
}
function send(model) {
if (socket === null || socket.readyState !== 1 /* OPEN */) return false;
socket.send(JSON.stringify(model));
return true;
}
function connect(code, { token = null, name = null } = {}) {
if (socket !== null && socket.readyState !== 3 /* CLOSED */) close();
lastCloseCode = 1006;
seq = 0; // a fresh socket: the server's `last_seq` starts at 0, so the
// first `input` frame must be seq >= 1
const scheme = location.protocol === "https:" ? "wss" : "ws";
const next = new WebSocket(
`${scheme}://${location.host}/ws/${encodeURIComponent(code)}`,
);
socket = next;
setStatus("connecting");
next.onopen = () => {
// `extra="forbid"` on the server: only `p`, `token`, `name` may ride
// the hello, and nulls are omitted rather than sent.
const hello = { type: "hello", p: PROTOCOL_VERSION };
if (token !== null) hello.token = token;
if (name !== null) hello.name = name;
send(hello);
setStatus("open");
// The keep-alive (docs/PROTOCOL.md "Heartbeats"): any inbound frame
// resets the server's 12 s dead window; a 1 s `ping` keeps a half-open
// TCP honest. Phase 08 raises this to the documented 5 Hz RTT probe.
stopPing();
pingTimer = setInterval(() => {
send({ type: "ping", t: pingClock += 1 });
}, 1000);
};
next.onmessage = (event) => {
let frame = null;
try {
frame = JSON.parse(event.data);
} catch {
return; // the server must not send non-JSON; if it does, ignore
}
if (frame !== null && onMessage !== null) onMessage(frame);
};
next.onerror = () => {
/* the close event follows; classified there */
};
next.onclose = (event) => {
// A replaced socket's close is stale (a `connect()` swapped it
// out): only the current socket's death is the connection's.
if (socket !== next) return;
stopPing();
lastCloseCode = event.wasClean ? event.code : 1006;
if (mismatchLatched) setStatus("mismatch");
else setStatus("closed");
};
return next;
}
function sendInput(move, act) {
seq += 1;
const x = move === null ? 0 : move.x;
const y = move === null ? 0 : move.y;
// `face: null` = "derive from move" (docs/PROTOCOL.md "Input rules");
// an explicit facing is task 05's (the keyboard module).
return send({
type: "input",
seq,
move: { x, y },
act: Boolean(act),
face: null,
});
}
function noteError(frame) {
if (frame !== null && frame.code === "protocol_mismatch") {
mismatchLatched = true;
if (status !== "mismatch") setStatus("mismatch");
}
}
function close() {
stopPing();
const current = socket;
socket = null;
if (current !== null && current.readyState !== 3) {
try {
current.close(1000);
} catch {
/* already gone */
}
}
if (status !== "mismatch" && status !== "idle") setStatus("closed");
}
return {
connect,
send,
sendInput,
close,
noteError,
get status() {
return status;
},
get lastCloseCode() {
return lastCloseCode;
},
get mismatchLatched() {
return mismatchLatched;
},
set onMessage(handler) {
onMessage = handler;
},
set onStatus(handler) {
onStatus = handler;
},
};
}
+169
View File
@@ -0,0 +1,169 @@
// Undercooked — the wire decoder (phase 07 task 03).
//
// The client-side counterpart of `server/encoding.py`. `applyDelta(frame,
// prev)` reconstructs the full frame the server would have sent from a
// `full: false` frame and the previous frame (a full one or a delta one
// — the mirror of the encoder's `_prev_entries`: a delta frame's entity
// lists live under `d`, and its `gone` tombstones are *not* part of the
// previous state). The enum mapping goes through `shared/enums.js` (the
// wire's string values and the facing index, parity-guarded by
// `tests/unit/test_client_enum_parity.py`).
//
// The delta contract (docs/PROTOCOL.md "The `snap` frame"): `d` carries
// *only the changed entities* — new, moved, or state-changed since
// `prev` (an entity back at its `prev` values is out) — plus a
// `{"gone": true}` tombstone for each entity that vanished. The scalars
// (`t`, `s`, `tgt`, `tl`) and the tick's events (`e`) ride every frame,
// full or delta, so a decoded frame takes them from the frame itself.
//
// The identity keys mirror the encoder's: the player's `i`, the station's
// `k` + coordinates (its internal id never appears on the wire), the
// order's `id`, the hazard's `t` + coordinates.
import { FACING, EVENT_KIND, STATION_KIND, ITEM_KIND, ITEM_FORM } from "../shared/enums.js";
const COLLECTIONS = [
["p", "player"],
["st", "station"],
["o", "order"],
["hz", "hazard"],
];
/** One entity's identity key (the encoder's `_key_of_*`, as a string). */
export function keyOf(entity, kind) {
switch (kind) {
case "player":
return `p:${entity.i}`;
case "station":
return `st:${entity.k}@${entity.x},${entity.y}`;
case "order":
return `o:${entity.id}`;
case "hazard":
return `hz:${entity.t}@${entity.x},${entity.y}`;
default:
throw new Error(`unknown entity kind ${String(kind)}`);
}
}
function isEntry(value) {
return typeof value === "object" && value !== null;
}
/**
* The frame's entity entries for one collection (the mirror of
* `_prev_entries`): top-level on a full frame, under `d` on a delta
* frame; `gone` tombstones filtered out — they are not part of the
* state the delta is computed against.
*/
export function effectiveEntries(frame, key) {
if (!isEntry(frame)) return [];
const delta = frame.d;
const raw =
typeof delta === "object" && delta !== null ? delta[key] : frame[key];
if (!Array.isArray(raw)) return [];
return raw.filter((entry) => isEntry(entry) && entry.gone !== true);
}
/**
* Upsert / tombstone one collection: start from `previous` (in its
* order), replace a changed entry in place, append a new one, drop a
* tombstoned one. Deterministic — the same (prev, delta) pair always
* yields the same list.
*/
function applyCollection(previous, entries, kind) {
const out = previous.slice();
for (const entry of entries) {
if (!isEntry(entry)) continue;
const k = keyOf(entry, kind);
const idx = out.findIndex((existing) => keyOf(existing, kind) === k);
if (entry.gone === true) {
if (idx !== -1) out.splice(idx, 1); // the tombstone: gone
continue;
}
if (idx === -1) out.push(entry); // new: appended
else out[idx] = entry; // changed: replaced in place
}
return out;
}
function normalizeFull(frame) {
const list = (key) => (Array.isArray(frame[key]) ? frame[key] : []);
return {
full: true,
t: frame.t,
s: frame.s,
tgt: frame.tgt,
tl: frame.tl,
p: list("p"),
st: list("st"),
o: list("o"),
hz: list("hz"),
e: Array.isArray(frame.e) ? frame.e : [],
};
}
/**
* One raw `snap` frame in, one decoded full frame out.
*
* A `full: true` frame passes through (normalized: the entity arrays and
* `e` always present). A delta is applied against `prev` (the previous
* decoded full — or a raw full/delta frame, the encoder's own rule;
* `null` applies the delta's `d` against the empty state, best effort).
* The result is a full frame: `full: true`, the entity arrays top-level,
* the scalars and events from the frame itself.
*/
export function applyDelta(frame, prev = null) {
if (!isEntry(frame)) throw new Error("applyDelta: the frame is not an object");
if (frame.full === true) return normalizeFull(frame);
const d = typeof frame.d === "object" && frame.d !== null ? frame.d : {};
const base = isEntry(prev) ? prev : {};
const out = {
full: true,
t: frame.t,
s: frame.s,
tgt: frame.tgt,
tl: frame.tl,
e: Array.isArray(frame.e) ? frame.e : [],
};
for (const [key, kind] of COLLECTIONS) {
out[key] = applyCollection(
effectiveEntries(base, key),
Array.isArray(d[key]) ? d[key] : [],
kind,
);
}
return out;
}
// ------------------------------------------------------------ enum mapping
/** The wire's `f` (an index) → the facing's string value, or undefined. */
export function facingName(f) {
return Number.isInteger(f) && f >= 0 && f < FACING.length ? FACING[f] : undefined;
}
/** Whether a string is a known event kind (the FX queue's admission gate). */
export function isEventKind(k) {
return typeof k === "string" && EVENT_KIND.includes(k);
}
/** Whether a string is a known station kind (the renderer's sprite gate). */
export function isStationKind(k) {
return typeof k === "string" && STATION_KIND.includes(k);
}
/**
* A short item form (`"tomato_chopped"`, the wire's `h` / `g`) →
* `{kind, form}`; null when the shape is not a `_`-joined pair of known
* values (a carried `plate_plated` is the plated dish; a null short is a
* nothing-in-hand, which the caller handles before calling this).
*/
export function splitItemShort(short) {
if (typeof short !== "string" || short === "") return null;
const idx = short.lastIndexOf("_");
if (idx <= 0 || idx === short.length - 1) return null;
const kind = short.slice(0, idx);
const form = short.slice(idx + 1);
if (!ITEM_KIND.includes(kind) || !ITEM_FORM.includes(form)) return null;
return { kind, form };
}
+161
View File
@@ -0,0 +1,161 @@
// 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 }),
};
}
+317
View File
@@ -0,0 +1,317 @@
// 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 : [],
};
}