**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`.
170 lines
5.9 KiB
JavaScript
170 lines
5.9 KiB
JavaScript
// 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 };
|
|
}
|