Files
undercooked/app/static/js/screens/round_end.js
T
ducoterra 95ca3fd1fa 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`.
2026-10-10 18:03:52 -04:00

144 lines
4.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}