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:
2026-10-10 18:03:52 -04:00
parent f26849c1f3
commit 95ca3fd1fa
78 changed files with 13650 additions and 259 deletions
+411 -112
View File
@@ -1,134 +1,433 @@
// Undercooked — client shell entry point (phase 01).
// Undercooked — the screen state machine (phase 07 task 02; the
// composition root — URL params, the snapshot buffer, the render
// loop, the wire routing — is `main.js`, phase 07 task 03).
//
// There is no game logic here on purpose. Phase 05 shipped the server-side
// room shell (supervisor, room FSM, session store); phase 06 adds the WebSocket
// transport and phase 07 replaces the placeholder button handlers with the lobby
// flow and adds render/ (Canvas 2D). What this file owns today:
// * the anonymous per-tab session token (PLAN.md A16),
// * the last room code typed in this tab, so a reload can rejoin fast,
// * mounting the lobby screen and announcing state into #status.
// It is a plain ES module served from /static/ — no bundler, no framework, no
// remote import (PLAN.md A10/A12).
// `createApp()` owns the one client-side state machine: an explicit
// screen enum (`LANDING, CONNECTING, LOBBY, COUNTDOWN, PLAYING,
// ROUND_END, ERROR`), `show(screen, data)`, and the message reducer
// that maps the wire contract (docs/PROTOCOL.md) onto screens —
// `welcome`/`lobby` by the room's state, `countdown` → COUNTDOWN,
// `snap` → PLAYING, `round` → ROUND_END, fatal `err` codes → ERROR.
// Every screen is a module exporting `{ mount, unmount }` (plus an
// optional same-screen `update`); no global DOM lives outside them.
//
// `identity.js` keeps the per-tab identity (PLAN A16): the anonymous
// session token and the last room code, both in `sessionStorage`.
// The app instance is exposed as `window.__undercooked` — the documented test seam the
// e2e suites drive (and the task-05 keyboard module will feed).
//
// It is a plain ES module served from /static/ — no bundler, no
// framework, no remote import (PLAN A10/A12).
const TOKEN_KEY = "uc.token";
const LAST_CODE_KEY = "uc.lastCode";
const CODE_LENGTH = 5;
const NOT_IMPLEMENTED = "Not implemented until phase 07.";
const READY_MESSAGE = "Pick a kitchen to start.";
import { createConnection, FATAL_ERR_CODES } from "./net/connection.js";
import { getToken, lastCode, rememberCode, randomToken } from "./identity.js";
import * as landingScreen from "./screens/landing.js";
import * as connectingScreen from "./screens/connecting.js";
import * as lobbyScreen from "./screens/lobby.js";
import * as countdownScreen from "./screens/countdown.js";
import * as playScreen from "./screens/play.js";
import * as roundEndScreen from "./screens/round_end.js";
import * as errorScreen from "./screens/error.js";
/** Base64url without padding, so the token is safe in a query string or header. */
function base64url(bytes) {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export { randomToken, getToken, lastCode, rememberCode };
/** A random, unguessable id for this browser tab (identity, not an account). */
export function randomToken() {
const bytes = new Uint8Array(24);
globalThis.crypto.getRandomValues(bytes);
return base64url(bytes);
}
/** The screen enum (the task's list, frozen). */
export const SCREENS = Object.freeze({
LANDING: "landing",
CONNECTING: "connecting",
LOBBY: "lobby",
COUNTDOWN: "countdown",
PLAYING: "playing",
ROUND_END: "round_end",
ERROR: "error",
});
function read(storage, key) {
try {
return storage.getItem(key);
} catch {
return null; // private browsing / storage disabled: stay functional for this tab only
}
}
const SCREEN_MODULES = {
landing: landingScreen,
connecting: connectingScreen,
lobby: lobbyScreen,
countdown: countdownScreen,
playing: playScreen,
round_end: roundEndScreen,
error: errorScreen,
};
function write(storage, key, value) {
try {
storage.setItem(key, value);
} catch {
/* nothing to persist to; the in-memory value still works */
}
}
/** The room-state value → screen (the `welcome`/`lobby` mapping half). */
const SCREEN_BY_ROOM_STATE = {
lobby: SCREENS.LOBBY,
countdown: SCREENS.COUNTDOWN,
playing: SCREENS.PLAYING,
round_end: SCREENS.ROUND_END,
closed: SCREENS.ERROR,
};
/** The tab's session token, created once per tab and kept in sessionStorage. */
export function getToken(storage = globalThis.sessionStorage) {
const existing = read(storage, TOKEN_KEY);
if (typeof existing === "string" && existing.length > 0) return existing;
const token = randomToken();
write(storage, TOKEN_KEY, token);
return token;
}
const LOG_CAP = 2000;
/** Uppercase what the user typed and keep only characters a code can contain. */
export function normalizeCode(value) {
return String(value ?? "")
.toUpperCase()
.replace(/[^0-9A-Z]/g, "")
.slice(0, CODE_LENGTH);
}
export function createApp(root = document, options = {}) {
const { onWireMessage = null } = options;
const screenRoot = root.getElementById("screen-root");
const app = {
token: getToken(),
lastCode: lastCode(),
screen: null, // set by the first show()
data: {},
roomCode: "",
welcome: null,
you: null, // the viewer's seat (YouWire)
spectator: false, // joined mid-round (or the token holds no seat)
lobby: null, // the latest `lobby` frame
round: null, // the latest `round` frame
lastSnapshot: null, // the latest `snap` raw body (the render path is task 03's buffer; this view is the DOM path the screens and the choreography read)
session: null, // the `/api/session` projection
levels: null, // the `/api/levels` cards (null = not fetched yet)
view: makeView(), // the merged snapshot view (full frames reset, deltas patch)
error: null, // the fatal `err` frame, when one caused ERROR
banner: null, // non-fatal `err` text for the mounted screen
messageLog: [], // bounded wire-census for the e2e suites
leaving: false, // we closed the socket on purpose
};
const connection = createConnection();
const warnedUnknown = new Set();
let mounted = null;
export function lastCode(storage = globalThis.sessionStorage) {
return read(storage, LAST_CODE_KEY) || "";
}
export function rememberCode(code, storage = globalThis.sessionStorage) {
write(storage, LAST_CODE_KEY, normalizeCode(code));
}
/** Write the live region. `kind` is "info" | "error" and only changes styling. */
export function announce(message, root = document, kind = "info") {
const region = root.getElementById("status");
if (!region) return;
region.textContent = message;
region.dataset.kind = kind;
}
/**
* Wire the lobby screen. Returns the tab identity so later phases can reuse it.
*/
export function mount(root = document) {
const token = getToken();
const createButton = root.getElementById("create-lobby");
const joinForm = root.getElementById("join-form");
const joinInput = root.getElementById("join-code");
const remembered = lastCode();
if (joinInput && remembered) joinInput.value = remembered;
if (createButton) {
createButton.addEventListener("click", () => {
// Phase 07 (on the phase-06 transport): POST to the room supervisor,
// open /ws/{code}.
announce(NOT_IMPLEMENTED, root);
});
/**
* The merged snapshot view — the task-02 seed of task 03's buffer: a
* full frame resets the entity maps, a delta patches them (an upsert on
* the identity key, a `gone` tombstone drops). The stream's full cadence
* (every 20 encoded frames) plus the fresh-connection full keeps this
* resyncable; a client-side drift is what task 03's decoder handles.
*/
function makeView() {
return { players: {}, stations: {}, orders: {} };
}
if (joinForm) {
joinForm.addEventListener("submit", (event) => {
event.preventDefault();
const code = normalizeCode(joinInput ? joinInput.value : "");
if (joinInput) joinInput.value = code;
if (code.length !== CODE_LENGTH) {
announce(`A room code is ${CODE_LENGTH} characters.`, root, "error");
if (joinInput) joinInput.focus();
return;
function stationKey(entry) {
return `${entry.k}@${entry.x},${entry.y}`;
}
function applySnapshot(raw) {
app.lastSnapshot = raw;
const view = app.view;
if (raw.full) {
view.players = {};
view.stations = {};
view.orders = {};
for (const entry of raw.p ?? []) view.players[entry.i] = entry;
for (const entry of raw.st ?? []) view.stations[stationKey(entry)] = entry;
for (const entry of raw.o ?? []) view.orders[entry.id] = entry;
return;
}
const delta = raw.d ?? {};
for (const entry of delta.p ?? []) {
if (entry.gone) delete view.players[entry.i];
else view.players[entry.i] = entry;
}
for (const entry of delta.st ?? []) {
const key = stationKey(entry);
if (entry.gone) delete view.stations[key];
else view.stations[key] = entry;
}
for (const entry of delta.o ?? []) {
if (entry.gone) delete view.orders[entry.id];
else view.orders[entry.id] = entry;
}
}
function pushLog(frame) {
app.messageLog.push(frame);
if (app.messageLog.length > LOG_CAP) app.messageLog.shift();
}
/**
* Show one screen. An unknown screen name (a bug) logs once and
* becomes ERROR; the same screen re-shown in place calls the
* module's `update` (a re-mount would drop focus off live controls).
*/
function show(screen, data = {}) {
if (!(screen in SCREEN_MODULES)) {
if (!warnedUnknown.has(screen)) {
console.warn(
`undercooked: unknown screen "${String(screen)}" — going to the error screen`,
);
warnedUnknown.add(screen);
}
rememberCode(code);
// Phase 07 (on the phase-06 transport): join the room over the
// WebSocket.
announce(NOT_IMPLEMENTED, root);
});
screen = SCREENS.ERROR;
}
app.data = data;
if (app.screen === screen && mounted !== null) {
if (typeof mounted.update === "function") mounted.update(data, app);
return screen;
}
if (mounted !== null && typeof mounted.unmount === "function") mounted.unmount();
mounted = null;
app.screen = screen;
mounted = SCREEN_MODULES[screen];
// The previous screen's DOM is the previous screen's problem no
// more: a screen module appends, it does not replace, so the
// machine clears the root before mounting (a re-mount would stack
// a second copy of the screen on top of the first).
screenRoot.replaceChildren();
if (typeof mounted.mount === "function") mounted.mount(screenRoot, data, app);
return screen;
}
if (!lastCode() && root.getElementById("status")) {
announce(READY_MESSAGE, root);
function failToLanding(banner) {
app.banner = banner;
show(SCREENS.LANDING, {});
}
return { token, code: lastCode() };
// ------------------------------------------------------------- verbs
async function createLobby({ name = null } = {}) {
app.leaving = false;
show(SCREENS.CONNECTING, { what: "creating" });
let response;
try {
response = await fetch("/api/rooms", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
token: app.token,
...(name !== null && name !== "" ? { name } : {}),
}),
});
} catch {
failToLanding("Cannot reach the server — check the address and try again.");
return;
}
if (response.status === 503) {
failToLanding("The server is at capacity — try again in a moment.");
return;
}
if (response.status !== 201) {
failToLanding("The server refused the room. Try again.");
return;
}
const body = await response.json().catch(() => null);
if (body === null || typeof body.room_code !== "string") {
failToLanding("The server answered something unrecognisable. Try again.");
return;
}
app.roomCode = body.room_code;
rememberCode(body.room_code);
connection.connect(body.room_code, { token: app.token });
}
function join(code) {
app.leaving = false;
app.roomCode = code;
rememberCode(code);
show(SCREENS.CONNECTING, { what: "joining", code });
connection.connect(code, { token: app.token });
}
function leave() {
app.leaving = true;
connection.close();
show(SCREENS.LANDING, {});
}
function backToLanding() {
app.leaving = true;
connection.close();
app.banner = null;
show(SCREENS.LANDING, {});
}
function retryJoin() {
if (app.roomCode === "") return;
app.leaving = false;
show(SCREENS.CONNECTING, { what: "joining", code: app.roomCode });
connection.connect(app.roomCode, { token: app.token });
}
function ready() {
connection.send({ type: "ready" });
}
function start() {
connection.send({ type: "start" });
}
function setLevel(level) {
connection.send({ type: "set_level", level });
}
function emote(id) {
connection.send({ type: "emote", id });
}
function toLobby() {
connection.send({ type: "to_lobby" });
}
function nextLevel() {
connection.send({ type: "next_level" });
}
function sendInput(move, act) {
connection.sendInput(move, act);
}
async function fetchSession() {
try {
const response = await fetch("/api/session", {
headers: { "X-Undercooked-Token": app.token },
});
if (response.ok) {
app.session = await response.json();
if (mounted !== null && typeof mounted.renderBest === "function") {
mounted.renderBest(app);
}
}
} catch {
/* offline: the previous session stays */
}
return app.session;
}
async function fetchLevels() {
if (app.levels !== null) return app.levels;
try {
const response = await fetch("/api/levels");
app.levels = response.ok ? await response.json() : [];
} catch {
app.levels = [];
}
return app.levels;
}
/** The merged view's own half (a `snap` landed, delta or full). */
function orderOpen() {
return Object.keys(app.view.orders).length > 0;
}
function showReconnecting(visible) {
if (mounted !== null && typeof mounted.showReconnecting === "function") {
mounted.showReconnecting(visible);
}
}
// ------------------------------------------------------------- reducer
connection.onMessage = (frame) => {
// The composition root's half (phase 07 task 03): the `snap` frames
// also feed the render buffer before the reducer's DOM view.
if (onWireMessage !== null) onWireMessage(frame);
pushLog(frame);
switch (frame.type) {
case "welcome": {
app.welcome = frame;
app.roomCode = frame.room;
app.you = frame.you;
// A fresh token in a non-lobby room is a spectator (a rejoin
// reclaims its seat, so `rejoined` is the discriminator).
app.spectator = frame.state !== "lobby" && frame.rejoined !== true;
show(SCREEN_BY_ROOM_STATE[frame.state] ?? SCREENS.ERROR, { code: frame.room });
break;
}
case "lobby": {
app.lobby = frame;
app.roomCode = frame.code;
show(
frame.state === "lobby" ? SCREENS.LOBBY : SCREEN_BY_ROOM_STATE[frame.state] ?? SCREENS.ERROR,
{},
);
break;
}
case "countdown":
show(SCREENS.COUNTDOWN, { n: frame.n });
break;
case "snap":
if (frame.raw !== undefined) applySnapshot(frame.raw);
show(SCREENS.PLAYING, {});
break;
case "round":
app.round = frame;
show(SCREENS.ROUND_END, {});
break;
case "err": {
connection.noteError(frame);
if (FATAL_ERR_CODES.includes(frame.code)) {
app.error = frame;
show(SCREENS.ERROR, { code: frame.code, msg: frame.msg });
} else if (mounted !== null && typeof mounted.setBanner === "function") {
app.banner = frame.msg;
mounted.setBanner(frame.msg);
}
break;
}
case "pong":
break; // the keep-alive's answer: nothing to render
case "ev":
break; // FX/log (task 04's renderer consumes these)
default:
break; // unknown server types are ignored (patch-compatible)
}
};
connection.onStatus = (status) => {
if (status === "mismatch") {
show(SCREENS.ERROR, { code: "protocol_mismatch" });
return;
}
if (status !== "closed") return;
if (app.leaving) return; // an intentional close: no error screen
const code = connection.lastCloseCode;
if (code === 1000 && app.screen === SCREENS.PLAYING) {
// A drop mid-round: the seat is kept for the grace; phase 08
// reconnects. This task's half is the overlay.
showReconnecting(true);
return;
}
show(SCREENS.ERROR, { code: code === 1006 ? "unreachable" : "closed" });
};
// The public verbs — assigned onto `app` itself so the screens (which
// hold `app`) and the e2e seam (`window.__undercooked`) see one and
// the same live object: a `show()` later is visible through the seam.
Object.assign(app, {
connection,
show,
createLobby,
join,
leave,
backToLanding,
retryJoin,
ready,
start,
setLevel,
emote,
toLobby,
nextLevel,
sendInput,
fetchSession,
fetchLevels,
orderOpen,
showReconnecting,
});
show(SCREENS.LANDING, {});
return app;
}
// Auto-mount when loaded as the page's module, but stay importable (and
// therefore testable) without a lobby in the document.
if (typeof document !== "undefined" && document.getElementById("screen-lobby")) {
// Auto-mount when loaded directly as the page's module; stay
// importable (and therefore testable) without one. The instance is
// the e2e test seam. `main.js` (the composition root) sets
// `globalThis.__uc_main` before importing this module — when it is
// set, main.js boots the app and this auto-boot stays out of the way.
if (
typeof document !== "undefined" &&
document.getElementById("screen-root") &&
!globalThis.__uc_main
) {
const boot = () => {
globalThis.__undercooked = createApp();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => mount(), { once: true });
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
mount();
boot();
}
}