feat(login): randomize Russian welcome headlines

This commit is contained in:
2026-09-08 21:36:26 +03:00
parent 4b1ac6ca5a
commit 5346f9296f
3 changed files with 59 additions and 7 deletions
+19 -5
View File
@@ -1114,7 +1114,7 @@ manner of Docker's random container names.
## Milestone 013 — Random humorous login headlines ## Milestone 013 — Random humorous login headlines
**Status:** `NOT_STARTED` **Status:** `COMPLETE`
### Objective ### Objective
@@ -1155,7 +1155,21 @@ headline at random whenever a player opens the page.
### Evidence ### Evidence
Completion evidence is not yet available. Expected evidence includes targeted - The browser test `login headline selection is randomizable, Russian, and has
browser-test output, `bash scripts/run_host_tests.sh`, and the reproducible a fallback` passed. It verifies eight locally authored, appropriate Russian
web-asset size and hash report from two-line headlines, deterministic injected selection, fallback to the first
`/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target buildfs`. 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`.
+15
View File
@@ -48,6 +48,21 @@ test("frontend name suggestions randomize both words and remain bounded", () =>
assert.doesNotMatch(script, /suggest_name|name_suggestion/); 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", () => { test("command envelopes contain intentions rather than client-owned state", () => {
const move = player.createEnvelope("move", 7, "move-7", {direction: "west"}); 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"}}); assert.deepEqual(move, {v: 1, type: "move", seq: 7, requestId: "move-7", payload: {direction: "west"}});
+25 -2
View File
@@ -7,8 +7,18 @@
const MAX_VIEW_OBJECTS = 30; const MAX_VIEW_OBJECTS = 30;
const OBSERVER_CELL_SIZE = 8; const OBSERVER_CELL_SIZE = 8;
const NAME_ADJECTIVES = Object.freeze(["Бодрый", "Хитрый", "Тихий", "Смелый", "Ловкий", "Сонный", "Шустрый", "Лунный", "Яркий", "Дикий", "Вечный", "Храбрый"]); 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 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({ const KEY_DIRECTIONS = Object.freeze({
ArrowUp: "north", KeyW: "north", ArrowUp: "north", KeyW: "north",
ArrowRight: "east", KeyD: "east", ArrowRight: "east", KeyD: "east",
@@ -55,6 +65,10 @@
return null; return null;
} }
function randomLoginHeadline(randomSource) {
return LOGIN_HEADLINES[randomIndex(LOGIN_HEADLINES.length, randomSource)];
}
function directionFromKey(code) { function directionFromKey(code) {
return KEY_DIRECTIONS[code] || null; return KEY_DIRECTIONS[code] || null;
} }
@@ -152,12 +166,21 @@
byId("observer-screen").hidden = name !== "observer"; 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) { function setConnection(connectionState, text) {
byId("connection").dataset.state = connectionState; byId("connection").dataset.state = connectionState;
byId("connection-text").textContent = text; byId("connection-text").textContent = text;
byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически."; byId("details-text").textContent = connectionState === "online" ? "WebSocket подключён к локальному ESP." : "Команды временно не отправляются. Повторяем подключение автоматически.";
} }
renderWelcomeHeadline(randomLoginHeadline());
function beginFeedback() { function beginFeedback() {
if (state.feedbackReady) return; if (state.feedbackReady) return;
state.feedbackReady = true; state.feedbackReady = true;
@@ -493,7 +516,7 @@
return {state, handleMessage, move}; 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; if (typeof module !== "undefined" && module.exports) module.exports = api;
root.Maze32Player = api; root.Maze32Player = api;
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); }); if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });