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:
@@ -0,0 +1,189 @@
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user