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,143 @@
|
||||
// Undercooked — the result screen (phase 07 task 02).
|
||||
//
|
||||
// The verdict, as the `round` frame carries it: the result word, the
|
||||
// stars (1–3, the pop is decorative and `prefers-reduced-motion`-gated),
|
||||
// score against target, the session's best for the level that was
|
||||
// played, the per-player contribution list (display only — PLAN §5:
|
||||
// never competitive gatekeeping), the counters (`served` / `missed` /
|
||||
// `wasted`, the longest streak), and the three exits — *Next level*
|
||||
// (only when `round.unlocked` names a level), *Retry* (`next_level`)
|
||||
// and *Back to lobby* (`to_lobby`). The outcome is announced to screen
|
||||
// readers through an `aria-live="polite"` region.
|
||||
|
||||
import { el, frag } from "./dom.js";
|
||||
|
||||
let bestEl = null;
|
||||
let bannerEl = null;
|
||||
|
||||
function starsFor(stars) {
|
||||
const count = Math.max(0, Math.min(3, stars));
|
||||
const spans = [];
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
spans.push(
|
||||
el("span", { class: i < count ? "star filled" : "star", "aria-hidden": "true" },
|
||||
i < count ? "★" : "☆"),
|
||||
);
|
||||
}
|
||||
return el("p", {
|
||||
id: "stars",
|
||||
class: "stars",
|
||||
"aria-label": `${count} of 3 stars`,
|
||||
}, frag(...spans));
|
||||
}
|
||||
|
||||
function resultWord(result) {
|
||||
if (result === "won") return "Won";
|
||||
if (result === "lost") return "Lost";
|
||||
if (result === "aborted") return "Aborted";
|
||||
return "Round over";
|
||||
}
|
||||
|
||||
function announcement(result, score, target, stars) {
|
||||
const word = result === "won" ? "won" : result === "lost" ? "lost" : "ended";
|
||||
return `You ${word} ${score} of ${target} points with ${stars} star${stars === 1 ? "" : "s"}.`;
|
||||
}
|
||||
|
||||
export function mount(target, _data, app) {
|
||||
const round = app.round ?? {};
|
||||
const result = round.result ?? null;
|
||||
const stars = round.stars ?? 0;
|
||||
const score = round.score ?? 0;
|
||||
const targetScore = round.target ?? 0;
|
||||
|
||||
const players = (round.players ?? []).map((player) =>
|
||||
el("li", { class: "result-player" },
|
||||
el("span", { class: "seat-name" }, player.name),
|
||||
el("span", {}, `${player.score} pts · ${player.dishes_served} dish${player.dishes_served === 1 ? "" : "es"}`),
|
||||
),
|
||||
);
|
||||
const counters = round.counters ?? {};
|
||||
bestEl = el("p", { id: "best-line" }, "Best: loading…");
|
||||
bannerEl = el("p", { class: "hint", id: "result-banner", hidden: true, role: "status", "aria-live": "polite" });
|
||||
|
||||
const actions = el("div", { class: "result-actions" },
|
||||
el("button", {
|
||||
id: "retry-btn",
|
||||
class: "btn",
|
||||
type: "button",
|
||||
onClick: () => app.nextLevel(),
|
||||
}, "Retry"),
|
||||
el("button", {
|
||||
id: "to-lobby-btn",
|
||||
class: "btn btn-primary",
|
||||
type: "button",
|
||||
onClick: () => app.toLobby(),
|
||||
}, "Back to lobby"),
|
||||
);
|
||||
if (round.unlocked !== null && round.unlocked !== undefined) {
|
||||
actions.prepend(
|
||||
el("button", {
|
||||
id: "next-level-btn",
|
||||
class: "btn btn-primary",
|
||||
type: "button",
|
||||
onClick: () => app.nextLevel(),
|
||||
}, "Next level"),
|
||||
);
|
||||
}
|
||||
|
||||
target.append(
|
||||
el("div", { class: "panel result" },
|
||||
el("h2", { id: "result-word" }, resultWord(result)),
|
||||
starsFor(stars),
|
||||
el("p", { id: "score-line" }, `Score ${score} of ${targetScore}`),
|
||||
bestEl,
|
||||
el("h3", { class: "section-label" }, "Cooks"),
|
||||
players.length === 0
|
||||
? el("p", { class: "hint" }, "no players")
|
||||
: el("ul", { id: "result-players", class: "seat-list" }, frag(...players)),
|
||||
el("p", { id: "counters-line" },
|
||||
`Served ${counters.served ?? 0} · Missed ${counters.missed ?? 0} · Wasted ${counters.wasted ?? 0} · Longest streak ${round.longest_streak ?? 0}`),
|
||||
actions,
|
||||
bannerEl,
|
||||
),
|
||||
el("p", {
|
||||
id: "result-announce",
|
||||
class: "visually-hidden",
|
||||
role: "status",
|
||||
"aria-live": "polite",
|
||||
}, announcement(result, score, targetScore, stars)),
|
||||
);
|
||||
// The best for the level that was played (the session update lands
|
||||
// with the `round` frame — `round_over` records before it broadcasts).
|
||||
void app.fetchSession().then(() => {
|
||||
if (bestEl !== null) renderBest(app);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderBest(app) {
|
||||
if (bestEl === null) return;
|
||||
const round = app.round ?? {};
|
||||
const session = app.session;
|
||||
const best = session !== null && round.level_id !== undefined ? session.best?.[round.level_id] : undefined;
|
||||
bestEl.textContent =
|
||||
best !== undefined
|
||||
? `Best: ${best.score} pts, ${"★".repeat(best.stars)}${"☆".repeat(3 - best.stars)}`
|
||||
: "Best: —";
|
||||
}
|
||||
|
||||
/** Same-screen re-show: re-render the best line (a `round` re-broadcast
|
||||
* or a session refresh). */
|
||||
export function update(_data, app) {
|
||||
if (app !== undefined) renderBest(app);
|
||||
}
|
||||
|
||||
export function setBanner(message) {
|
||||
if (bannerEl === null) return;
|
||||
bannerEl.textContent = message ?? "";
|
||||
bannerEl.hidden = message === null || message === "";
|
||||
}
|
||||
|
||||
export function unmount() {
|
||||
bestEl = null;
|
||||
bannerEl = null;
|
||||
}
|
||||
Reference in New Issue
Block a user