// Undercooked — the WebSocket connection (phase 07 task 02). // // A thin, protocol-only layer (hardened into the rejoin/backoff machine // in phase 08): it opens `/ws/{code}`, sends the `hello` (PLAN A14 — // the version travels in the hello only), dispatches every server frame // to a single `onMessage` handler, sends client frames, and classifies // how the socket died so the app's state machine can pick the right // screen (the close-code decision table, docs/PROTOCOL.md "Heartbeats, // drops, and seat grace"). // // Deliberate gaps in this task: no reconnect/backoff (phase 08), no // snapshot buffering (task 03), no input prediction (task 08). The // `protocol_mismatch` path *is* final in this task: a `p` the server // refuses means this client must not retry — it stops and the app // shows the error screen without a retry affordance. const PROTOCOL_VERSION = 1; /** The close codes the client can act on (docs/PROTOCOL.md "Close codes"). */ export const CLOSE_CODES = Object.freeze({ normal: 1000, protocol_mismatch: 4400, room_gone: 4401, room_full: 4402, evicted: 4403, abuse: 4404, }); /** The `err` codes that end the session on this socket (fatal for the app). */ export const FATAL_ERR_CODES = Object.freeze([ "room_gone", "room_full", "protocol_mismatch", "evicted", "internal", ]); /** * Build a connection. The returned object's surface: * * connect(code, { token, name }) — open `/ws/{code}` and `hello` * send(model) — one JSON frame out (false when closed) * sendInput(move, act) — one `input` frame, `seq` owned here * onMessage(frame) — the single reducer the app installs * onStatus(status) — "idle" | "connecting" | "open" | "closed" | "mismatch" * lastCloseCode — the close code of the last death (1006 if the * transport died without a close frame) * noteError(frame) — feed an `err` frame; `protocol_mismatch` * latches the mismatch (no retry, ever) * close() — a clean close (1000) */ export function createConnection() { let socket = null; let status = "idle"; let lastCloseCode = 1006; let mismatchLatched = false; let seq = 0; let pingTimer = null; let pingClock = 0; let onMessage = null; let onStatus = null; function setStatus(next) { if (status === next) return; status = next; if (onStatus) onStatus(status); } function stopPing() { if (pingTimer !== null) clearInterval(pingTimer); pingTimer = null; } function send(model) { if (socket === null || socket.readyState !== 1 /* OPEN */) return false; socket.send(JSON.stringify(model)); return true; } function connect(code, { token = null, name = null } = {}) { if (socket !== null && socket.readyState !== 3 /* CLOSED */) close(); lastCloseCode = 1006; seq = 0; // a fresh socket: the server's `last_seq` starts at 0, so the // first `input` frame must be seq >= 1 const scheme = location.protocol === "https:" ? "wss" : "ws"; const next = new WebSocket( `${scheme}://${location.host}/ws/${encodeURIComponent(code)}`, ); socket = next; setStatus("connecting"); next.onopen = () => { // `extra="forbid"` on the server: only `p`, `token`, `name` may ride // the hello, and nulls are omitted rather than sent. const hello = { type: "hello", p: PROTOCOL_VERSION }; if (token !== null) hello.token = token; if (name !== null) hello.name = name; send(hello); setStatus("open"); // The keep-alive (docs/PROTOCOL.md "Heartbeats"): any inbound frame // resets the server's 12 s dead window; a 1 s `ping` keeps a half-open // TCP honest. Phase 08 raises this to the documented 5 Hz RTT probe. stopPing(); pingTimer = setInterval(() => { send({ type: "ping", t: pingClock += 1 }); }, 1000); }; next.onmessage = (event) => { let frame = null; try { frame = JSON.parse(event.data); } catch { return; // the server must not send non-JSON; if it does, ignore } if (frame !== null && onMessage !== null) onMessage(frame); }; next.onerror = () => { /* the close event follows; classified there */ }; next.onclose = (event) => { // A replaced socket's close is stale (a `connect()` swapped it // out): only the current socket's death is the connection's. if (socket !== next) return; stopPing(); lastCloseCode = event.wasClean ? event.code : 1006; if (mismatchLatched) setStatus("mismatch"); else setStatus("closed"); }; return next; } function sendInput(move, act) { seq += 1; const x = move === null ? 0 : move.x; const y = move === null ? 0 : move.y; // `face: null` = "derive from move" (docs/PROTOCOL.md "Input rules"); // an explicit facing is task 05's (the keyboard module). return send({ type: "input", seq, move: { x, y }, act: Boolean(act), face: null, }); } function noteError(frame) { if (frame !== null && frame.code === "protocol_mismatch") { mismatchLatched = true; if (status !== "mismatch") setStatus("mismatch"); } } function close() { stopPing(); const current = socket; socket = null; if (current !== null && current.readyState !== 3) { try { current.close(1000); } catch { /* already gone */ } } if (status !== "mismatch" && status !== "idle") setStatus("closed"); } return { connect, send, sendInput, close, noteError, get status() { return status; }, get lastCloseCode() { return lastCloseCode; }, get mismatchLatched() { return mismatchLatched; }, set onMessage(handler) { onMessage = handler; }, set onStatus(handler) { onStatus = handler; }, }; }