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