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,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 };
|
||||
}
|
||||
Reference in New Issue
Block a user