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:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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 : [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user