**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`.
434 lines
14 KiB
JavaScript
434 lines
14 KiB
JavaScript
// 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).
|
|
//
|
|
// `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).
|
|
|
|
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";
|
|
|
|
export { randomToken, getToken, lastCode, rememberCode };
|
|
|
|
/** 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",
|
|
});
|
|
|
|
const SCREEN_MODULES = {
|
|
landing: landingScreen,
|
|
connecting: connectingScreen,
|
|
lobby: lobbyScreen,
|
|
countdown: countdownScreen,
|
|
playing: playScreen,
|
|
round_end: roundEndScreen,
|
|
error: errorScreen,
|
|
};
|
|
|
|
/** 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,
|
|
};
|
|
|
|
const LOG_CAP = 2000;
|
|
|
|
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;
|
|
|
|
/**
|
|
* 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: {} };
|
|
}
|
|
|
|
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);
|
|
}
|
|
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;
|
|
}
|
|
|
|
function failToLanding(banner) {
|
|
app.banner = banner;
|
|
show(SCREENS.LANDING, {});
|
|
}
|
|
|
|
// ------------------------------------------------------------- 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 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", boot, { once: true });
|
|
} else {
|
|
boot();
|
|
}
|
|
}
|