**Phase 01 final verification pass — all criteria green**
- Verified (nothing left to implement; all 4 task files already in `complete/`): toolchain lock, typed `Settings`+`.env.example`, debugpy bootstrap, app factory, CLI (`--host/--port/--reload/--levels-dir`, `python -m undercooked`), static shell + `/static`, security headers, no-CDN/doc guards, `validate.sh`.
- `uv sync --extra dev` → exit 0; `uv.lock` present (untracked → harness commits it with the phase).
- `uv run undercooked --host 127.0.0.1 --port 8000` → `curl /healthz` = 200 `{"status":"ok","version":"0.1.0","rooms":0}` (superset of the criterion's body — now documented in README); `curl /` = 200 shell HTML; static css/js 200; CSP/nosniff/no-referrer/DENY on 200, 404 and 405 too.
- `DEBUGPY=1`: port 5678 is held on this machine by the user's own podman dev-container debugpy adapter — **no live change made/killed**; verified via `DEBUGPY=1 DEBUGPY_PORT=5679` → listener opens, `/healthz` still 200; the occupied-port case degraded gracefully as designed (warning, server serves). Bad config → one line + exit 2 ✓.
- `bash .agents/validate.sh` (root **and** `.agents/`) → exit 0: `ruff check .` 0 errors, `pyright` 0 errors, levels-CLI notice-skip (matches README prose), **284 passed** full suite incl. 10 Playwright e2e, coverage **100.00%** (floor 90), repo hygiene clean. `uv run pytest -m e2e` → 10 passed.
- Docs fixed: README Status → phase 01 complete + next pending 02 (was pointing at `todo/01…`), documented real `/healthz` body, precedence now "CLI flags > env > .env > default", added the PLAN §13-promised **How to play** section (honest about shell-only today), Layout rows marked what exists vs phases 02/07. AGENTS.md: recorded the `data:`-URL exemption in the no-CDN rule (test allows it) and matched the session-token rule to the browser's WebCrypto/base64url implementation. Also corrected a stale "session-wide chromium" docstring in `tests/e2e/conftest.py` (fixtures are function-scoped).
- Deviations: no container build run (needs an image pull; not a phase criterion — `Containerfile`/`compose.yaml` reviewed and consistent with `docs/DEPLOYMENT.md`); `HEAD /` is 405 (routes are GET-only by design; health checks use GET). No git index/history commands run.
- Next pending phase: `02_level_content_and_validation`.
132 lines
4.3 KiB
JavaScript
132 lines
4.3 KiB
JavaScript
// Undercooked — client shell entry point (phase 01).
|
|
//
|
|
// There is no game logic here on purpose. Phase 05 replaces the placeholder
|
|
// button handlers with the lobby flow, phase 06 adds net/ (WebSocket + snapshots),
|
|
// phase 07 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).
|
|
|
|
const TOKEN_KEY = "uc.token";
|
|
const LAST_CODE_KEY = "uc.lastCode";
|
|
const CODE_LENGTH = 5;
|
|
const NOT_IMPLEMENTED = "Not implemented until phase 05.";
|
|
const READY_MESSAGE = "Pick a kitchen to start.";
|
|
|
|
/** 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(/=+$/, "");
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
|
|
function read(storage, key) {
|
|
try {
|
|
return storage.getItem(key);
|
|
} catch {
|
|
return null; // private browsing / storage disabled: stay functional for this tab only
|
|
}
|
|
}
|
|
|
|
function write(storage, key, value) {
|
|
try {
|
|
storage.setItem(key, value);
|
|
} catch {
|
|
/* nothing to persist to; the in-memory value still works */
|
|
}
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
/** 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 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 05: POST to the room supervisor and open /ws/{code}.
|
|
announce(NOT_IMPLEMENTED, root);
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
rememberCode(code);
|
|
// Phase 05: join the room over the WebSocket.
|
|
announce(NOT_IMPLEMENTED, root);
|
|
});
|
|
}
|
|
|
|
if (!lastCode() && root.getElementById("status")) {
|
|
announce(READY_MESSAGE, root);
|
|
}
|
|
|
|
return { token, code: lastCode() };
|
|
}
|
|
|
|
// 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")) {
|
|
if (document.readyState === "loading") {
|
|
document.addEventListener("DOMContentLoaded", () => mount(), { once: true });
|
|
} else {
|
|
mount();
|
|
}
|
|
}
|