// 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(); } }