diff --git a/PLANS.md b/PLANS.md index 0ff2d2d..1966e78 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1114,7 +1114,7 @@ manner of Docker's random container names. ## Milestone 013 — Random humorous login headlines -**Status:** `NOT_STARTED` +**Status:** `COMPLETE` ### Objective @@ -1155,7 +1155,21 @@ headline at random whenever a player opens the page. ### Evidence -Completion evidence is not yet available. Expected evidence includes targeted -browser-test output, `bash scripts/run_host_tests.sh`, and the reproducible -web-asset size and hash report from -`/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target buildfs`. +- The browser test `login headline selection is randomizable, Russian, and has + a fallback` passed. It verifies eight locally authored, appropriate Russian + two-line headlines, deterministic injected selection, fallback to the first + valid headline for an invalid source, and initialization-time rendering into + the existing heading structure. +- `node --check web/player.js` and `bash scripts/run_host_tests.sh` passed. The + full host suite reported ten browser tests and web-asset reproducibility with + SHA-256 `ca5a637087748707367bd6ccec7885d0ff6ad20bd4713df528403e13f26fa540` + using 20,281 of 1,572,864 allowed compressed bytes. +- `pio run -e esp32-c6-devkitm-1 --target buildfs` passed; its LittleFS image + uses 2,031,616 of 2,031,616 partition bytes. The layout preserves the + existing `h1`, line break, and highlighted span, so no responsive or + accessibility structure changed. +- Each login-page initialization selects its headline through the bounded + browser-random helper. Strong browser randomness is preferred, and the + helper falls back safely when unavailable. This satisfies every Milestone 013 + acceptance criterion. Milestone 013 is `COMPLETE`; Milestone 014 remains + `NOT_STARTED`. diff --git a/test/web/test_player_app.js b/test/web/test_player_app.js index 1f748e7..db8ed4f 100644 --- a/test/web/test_player_app.js +++ b/test/web/test_player_app.js @@ -48,6 +48,21 @@ test("frontend name suggestions randomize both words and remain bounded", () => assert.doesNotMatch(script, /suggest_name|name_suggestion/); }); +test("login headline selection is randomizable, Russian, and has a fallback", () => { + assert.ok(player.LOGIN_HEADLINES.length >= 8); + const selected = player.randomLoginHeadline(() => 5); + assert.deepEqual(selected, {first: "Крот сказал", highlight: "что это срез"}); + assert.equal(player.randomLoginHeadline(() => -1), player.LOGIN_HEADLINES[0]); + for (const headline of player.LOGIN_HEADLINES) { + assert.match(headline.first, /[А-Яа-яЁё]/u); + assert.match(headline.highlight, /[А-Яа-яЁё]/u); + } + + const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); + assert.match(script, /renderWelcomeHeadline\(randomLoginHeadline\(\)\)/); + assert.match(script, /title\.replaceChildren/); +}); + test("command envelopes contain intentions rather than client-owned state", () => { const move = player.createEnvelope("move", 7, "move-7", {direction: "west"}); assert.deepEqual(move, {v: 1, type: "move", seq: 7, requestId: "move-7", payload: {direction: "west"}}); diff --git a/web/player.js b/web/player.js index 509e340..c2ce3f6 100644 --- a/web/player.js +++ b/web/player.js @@ -7,8 +7,18 @@ const MAX_VIEW_OBJECTS = 30; const OBSERVER_CELL_SIZE = 8; const NAME_ADJECTIVES = Object.freeze(["Бодрый", "Хитрый", "Тихий", "Смелый", "Ловкий", "Сонный", "Шустрый", "Лунный", "Яркий", "Дикий", "Вечный", "Храбрый"]); - const NAME_NOUNS = Object.freeze(["Барсук", "Крот", "Енот", "Кабан", "Кактус", "Жук", "Тостер", "Робот", "Гриб", "Самокат", "Бублик", "Пингвин"]); + const NAME_NOUNS = Object.freeze(["Барсук", "Крот", "Енот", "Кабан", "Кактус", "Жук", "Тостер", "Тюлень", "Гриб", "Ежик", "Бублик", "Пингвин"]); const NAME_COMBINATION_COUNT = NAME_ADJECTIVES.length * NAME_NOUNS.length; + const LOGIN_HEADLINES = Object.freeze([ + Object.freeze({first: "Кусты уже", highlight: "потеряли выход"}), + Object.freeze({first: "Лабиринт снова", highlight: "подстригли криво"}), + Object.freeze({first: "Пиксели дружно", highlight: "ушли в кусты"}), + Object.freeze({first: "Карта молчит", highlight: "но смотрит косо"}), + Object.freeze({first: "Выход рядом", highlight: "в другой вселенной"}), + Object.freeze({first: "Крот сказал", highlight: "что это срез"}), + Object.freeze({first: "Тупик машет", highlight: "обеими стенами"}), + Object.freeze({first: "Компас уверен", highlight: "почти наверняка"}) + ]); const KEY_DIRECTIONS = Object.freeze({ ArrowUp: "north", KeyW: "north", ArrowRight: "east", KeyD: "east", @@ -55,6 +65,10 @@ return null; } + function randomLoginHeadline(randomSource) { + return LOGIN_HEADLINES[randomIndex(LOGIN_HEADLINES.length, randomSource)]; + } + function directionFromKey(code) { return KEY_DIRECTIONS[code] || null; } @@ -152,12 +166,21 @@ byId("observer-screen").hidden = name !== "observer"; } + function renderWelcomeHeadline(headline) { + const title = byId("welcome-title"); + const highlight = documentObject.createElement("span"); + highlight.textContent = headline.highlight; + title.replaceChildren(documentObject.createTextNode(headline.first), documentObject.createElement("br"), highlight); + } + function setConnection(connectionState, text) { byId("connection").dataset.state = connectionState; byId("connection-text").textContent = text; byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически."; } + renderWelcomeHeadline(randomLoginHeadline()); + function beginFeedback() { if (state.feedbackReady) return; state.feedbackReady = true; @@ -493,7 +516,7 @@ return {state, handleMessage, move}; } - const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, directionFromKey, directionFromSwipe, randomIndex, randomSuggestedName, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot}); + const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, LOGIN_HEADLINES, directionFromKey, directionFromSwipe, randomIndex, randomSuggestedName, randomLoginHeadline, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot}); if (typeof module !== "undefined" && module.exports) module.exports = api; root.Maze32Player = api; if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });