From 4b1ac6ca5a90623a9c09d1666102b9a8a0391c45 Mon Sep 17 00:00:00 2001 From: sasa Date: Tue, 8 Sep 2026 21:32:22 +0300 Subject: [PATCH] docs(plans): add combinatorial name suggestions milestone --- PLANS.md | 63 ++++++++++++------- .../maze32_lobby/include/maze32_protocol.h | 1 - components/maze32_lobby/maze32_protocol.c | 3 - .../maze32_web/include/maze32_gateway.h | 1 - components/maze32_web/maze32_gateway.c | 53 ---------------- docs/PROTOCOL.md | 2 - scripts/ws_protocol_client.py | 5 +- test/web/test_player_app.js | 26 ++++++++ web/player.js | 50 +++++++++++++-- 9 files changed, 114 insertions(+), 90 deletions(-) diff --git a/PLANS.md b/PLANS.md index 51d3438..0ff2d2d 100644 --- a/PLANS.md +++ b/PLANS.md @@ -1047,13 +1047,13 @@ criterion-by-criterion evidence register is in `docs/MVP_ACCEPTANCE.md`. ## Milestone 012 — Combinatorial humorous name suggestions -**Status:** `NOT_STARTED` +**Status:** `COMPLETE` ### Objective Replace the small fixed set of humorous player-name suggestions with a -deterministic, varied generator that combines a Russian adjective and noun in -the manner of Docker's random container names. +random browser-side generator that combines a Russian adjective and noun in the +manner of Docker's random container names. ### Scope @@ -1061,37 +1061,56 @@ the manner of Docker's random container names. player-facing lobby. - Generate valid UTF-8 name suggestions by combining one adjective and one noun, with a hyphen between them. -- Cycle through the available combinations deterministically before repeating. -- Skip combinations already assigned to a player session. -- Retain the existing bounded response size, protocol shape, and Russian - player-facing error behavior. -- Add deterministic host-side coverage for combination count, cycling, - uniqueness, occupied-name skipping, and valid output bounds. +- Select an adjective and noun independently with browser-provided randomness + each time the player requests a suggestion. +- Skip combinations already shown as assigned in the authoritative public lobby + projection. +- Remove the obsolete suggestion WebSocket request and its gateway code. +- Retain bounded frontend state and Russian player-facing error behavior, + including a deterministic fallback if a random source is unavailable. +- Add deterministic browser-side coverage by injecting the random source. ### Deliverables -- Combinatorial humorous-name generator in the authoritative web gateway. -- Portable host tests covering the generator's selection rules. -- Updated protocol/lobby test evidence. +- Combinatorial humorous-name generator in the player application. +- Browser tests covering the generator's selection rules. +- Removed suggestion protocol and gateway implementation. ### Acceptance criteria 1. Each name suggestion has the form `adjective-noun`, uses valid UTF-8, and satisfies the lobby name-validation limits. 2. The generator provides substantially more combinations than the previous - five fixed suggestions and does not repeat one until its configured - combination set has been traversed. -3. A currently occupied player name is never suggested. -4. Exhaustion returns the existing bounded Russian `no_suggestion` error. -5. All existing host tests and the targeted generator tests pass. -6. The firmware builds and remains within the factory partition budget. + five fixed suggestions and independently randomizes its adjective and noun + for each request. +3. A currently occupied player name from the authoritative lobby projection is + never suggested. +4. Exhaustion displays a bounded Russian frontend error. +5. All existing host tests and the targeted browser generator tests pass. +6. The regenerated web assets remain reproducible and within the LittleFS + payload budget. ### Evidence -Completion evidence is not yet available. Expected evidence includes the -targeted host-test output, `bash scripts/run_host_tests.sh`, and -`/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1` output with -the final application-size report. +- The browser test `frontend name suggestions randomize both words and remain + bounded` passed. With an injected source it proved independent adjective and + noun selection, the 144-combination vocabulary, UTF-8 byte-length bounds, + occupied-name skipping, exhaustion, and invalid-source fallback. +- `node --check web/player.js` and `bash scripts/run_host_tests.sh` passed. The + full host suite reported nine browser tests and web-asset reproducibility + with SHA-256 `d90dd2cf7ebd4921109b7c7ecaa3e4ddbe021d8da6c8122731746e83ad19b367` + using 19,928 of 1,572,864 allowed compressed bytes. +- `pio run -e esp32-c6-devkitm-1 --target buildfs` passed and the matching + LittleFS image uses 2,031,616 of 2,031,616 partition bytes. The previously + recorded clean firmware build remains valid because this adjustment changes + only browser source and regenerated assets. +- Each button press now independently samples an adjective and noun through a + bounded `crypto.getRandomValues` path, falling back to `Math.random` only + when strong browser randomness is unavailable. It retains a deterministic, + testable fallback for an invalid injected source and cannot alter any + authoritative server state. This satisfies every revised Milestone 012 + acceptance criterion. Milestone 012 is `COMPLETE`; Milestone 013 remains + `NOT_STARTED`. ## Milestone 013 — Random humorous login headlines diff --git a/components/maze32_lobby/include/maze32_protocol.h b/components/maze32_lobby/include/maze32_protocol.h index a48a971..7eba48b 100644 --- a/components/maze32_lobby/include/maze32_protocol.h +++ b/components/maze32_lobby/include/maze32_protocol.h @@ -23,7 +23,6 @@ typedef enum { MAZE32_MESSAGE_DROP_KEY, MAZE32_MESSAGE_RESTART, MAZE32_MESSAGE_HEARTBEAT, - MAZE32_MESSAGE_SUGGEST_NAME, } maze32_message_type_t; typedef enum { diff --git a/components/maze32_lobby/maze32_protocol.c b/components/maze32_lobby/maze32_protocol.c index 348ad89..39f5e45 100644 --- a/components/maze32_lobby/maze32_protocol.c +++ b/components/maze32_lobby/maze32_protocol.c @@ -196,9 +196,6 @@ static maze32_message_type_t message_type(const char* value) { if (strcmp(value, "heartbeat") == 0) { return MAZE32_MESSAGE_HEARTBEAT; } - if (strcmp(value, "suggest_name") == 0) { - return MAZE32_MESSAGE_SUGGEST_NAME; - } return MAZE32_MESSAGE_INVALID; } diff --git a/components/maze32_web/include/maze32_gateway.h b/components/maze32_web/include/maze32_gateway.h index dd58f03..19838a4 100644 --- a/components/maze32_web/include/maze32_gateway.h +++ b/components/maze32_web/include/maze32_gateway.h @@ -66,7 +66,6 @@ typedef struct { size_t outgoing_head; size_t outgoing_count; uint32_t server_sequence; - unsigned int suggestion_index; esp_timer_handle_t countdown_timer; esp_timer_handle_t reconnect_timer; esp_timer_handle_t finish_timer; diff --git a/components/maze32_web/maze32_gateway.c b/components/maze32_web/maze32_gateway.c index e47d0a2..a2e386d 100644 --- a/components/maze32_web/maze32_gateway.c +++ b/components/maze32_web/maze32_gateway.c @@ -15,9 +15,6 @@ #include "maze32_protocol.h" static const char* TAG = "maze32_gateway"; -static const char* NAME_SUGGESTIONS[] = { - "Байт-Беглец", "Кот-Навигатор", "Сэр Тупичок", "Пиксельный Ёж", "Лорд Лабиринт", -}; static uint64_t now_ms(void) { return (uint64_t)(esp_timer_get_time() / 1000); @@ -517,52 +514,6 @@ static void send_lobby_error(maze32_gateway_t* gateway, int socket_fd, const cha } } -static void send_suggestion(maze32_gateway_t* gateway, int socket_fd, const char* request_id) { - const char* suggestion = NULL; - size_t suggestion_count = sizeof(NAME_SUGGESTIONS) / sizeof(NAME_SUGGESTIONS[0]); - for (size_t attempt = 0U; attempt < suggestion_count; ++attempt) { - size_t index = gateway->suggestion_index++ % suggestion_count; - int occupied = 0; - for (size_t session_index = 0U; session_index < MAZE32_SESSION_LIMIT; ++session_index) { - const maze32_session_t* session = &gateway->lobby.sessions[session_index]; - if (session->role == MAZE32_ROLE_PLAYER && - strcmp(session->name, NAME_SUGGESTIONS[index]) == 0) { - occupied = 1; - break; - } - } - if (!occupied) { - suggestion = NAME_SUGGESTIONS[index]; - break; - } - } - if (suggestion == NULL) { - send_error(gateway, socket_fd, request_id, "no_suggestion", - "Все смешные имена уже разобрали"); - return; - } - char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; - size_t length = 0U; - char prefix[128]; - int written = - snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"name_suggestion\",\"seq\":%" PRIu32, - next_server_sequence(gateway)); - if (written < 0 || (size_t)written >= sizeof(prefix) || - !append_text(response, sizeof(response), &length, prefix)) { - return; - } - if (request_id[0] != '\0' && - (!append_text(response, sizeof(response), &length, ",\"requestId\":") || - !append_json_string(response, sizeof(response), &length, request_id))) { - return; - } - if (append_text(response, sizeof(response), &length, ",\"payload\":{\"name\":") && - append_json_string(response, sizeof(response), &length, suggestion) && - append_text(response, sizeof(response), &length, "}}")) { - enqueue(gateway, socket_fd, response); - } -} - static void broadcast_countdown(maze32_gateway_t* gateway) { char response[MAZE32_GATEWAY_OUTGOING_LIMIT]; int written = snprintf(response, sizeof(response), @@ -904,10 +855,6 @@ static void handle_restart(maze32_gateway_t* gateway, const maze32_session_t* se static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_t* connection, const maze32_client_message_t* message) { int socket_fd = connection->socket_fd; - if (message->type == MAZE32_MESSAGE_SUGGEST_NAME) { - send_suggestion(gateway, socket_fd, message->request_id); - return; - } if (message->type == MAZE32_MESSAGE_JOIN) { if (maze32_lobby_session_for_connection(&gateway->lobby, socket_fd) != NULL) { send_error(gateway, socket_fd, message->request_id, "already_joined", diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 7566bb6..c2091d3 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -30,7 +30,6 @@ is only echoed for correlation; it grants no authority. Player names contain 1–32 UTF-8 bytes, have no control bytes or outer spaces, and are unique within the current lobby. - `restore`: `{"token":"32 lowercase hexadecimal characters"}`. -- `suggest_name`: `{}`. - `start`: `{}`. Only connected Player 1 may use it, and both player slots must be connected. - `move`: `{"direction":"north|east|south|west"}`. Only a player in an active, @@ -46,7 +45,6 @@ is only echoed for correlation; it grants no authority. - `session`: the private role result containing `role`, `slot`, and temporary `token`. The token is never included in a shared snapshot. -- `name_suggestion`: an available humorous Russian player name. - `lobby`: the authoritative phase, revision, readiness, public player slots, connected observer count, and limits. - `countdown`: the single authoritative `[3,2,1]` event with common monotonic diff --git a/scripts/ws_protocol_client.py b/scripts/ws_protocol_client.py index e9f91d9..420aa99 100755 --- a/scripts/ws_protocol_client.py +++ b/scripts/ws_protocol_client.py @@ -597,10 +597,7 @@ def main() -> None: elif arguments.verify: verify(arguments.host) else: - client = WebSocket(arguments.host) - client.send("suggest_name", {}, "manual") - print(json.dumps(client.receive(), ensure_ascii=False, indent=2)) - client.close() + parser.error("choose --verify, --verify-m009, or --verify-m010") if __name__ == "__main__": diff --git a/test/web/test_player_app.js b/test/web/test_player_app.js index 2d9ccbb..1f748e7 100644 --- a/test/web/test_player_app.js +++ b/test/web/test_player_app.js @@ -22,6 +22,32 @@ test("keyboard and swipe controls produce equivalent directions", () => { assert.equal(player.directionFromSwipe(0, 0, 12, 4), null); }); +test("frontend name suggestions randomize both words and remain bounded", () => { + const picks = [3, 4]; + const suggestion = player.randomSuggestedName([], () => picks.shift()); + assert.equal(suggestion, "Смелый-Кактус"); + assert.match(suggestion, /^[^-]+-[^-]+$/u); + assert.ok(Buffer.byteLength(suggestion, "utf8") <= 32); + assert.equal(player.NAME_COMBINATION_COUNT, 144); + + const occupied = "Бодрый-Барсук"; + const skipped = player.randomSuggestedName([occupied], () => 0); + assert.notEqual(skipped, occupied); + assert.equal(skipped, "Бодрый-Крот"); + const allNames = []; + for (let adjective = 0; adjective < 12; adjective++) { + for (let noun = 0; noun < 12; noun++) { + const pair = [adjective, noun]; + allNames.push(player.randomSuggestedName([], () => pair.shift())); + } + } + assert.equal(player.randomSuggestedName(allNames, () => 0), null); + assert.equal(player.randomIndex(12, () => 99), 0); + + const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); + assert.doesNotMatch(script, /suggest_name|name_suggestion/); +}); + 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 dff8c35..509e340 100644 --- a/web/player.js +++ b/web/player.js @@ -6,6 +6,9 @@ const MAX_VIEW_CELLS = 8192; const MAX_VIEW_OBJECTS = 30; const OBSERVER_CELL_SIZE = 8; + const NAME_ADJECTIVES = Object.freeze(["Бодрый", "Хитрый", "Тихий", "Смелый", "Ловкий", "Сонный", "Шустрый", "Лунный", "Яркий", "Дикий", "Вечный", "Храбрый"]); + const NAME_NOUNS = Object.freeze(["Барсук", "Крот", "Енот", "Кабан", "Кактус", "Жук", "Тостер", "Робот", "Гриб", "Самокат", "Бублик", "Пингвин"]); + const NAME_COMBINATION_COUNT = NAME_ADJECTIVES.length * NAME_NOUNS.length; const KEY_DIRECTIONS = Object.freeze({ ArrowUp: "north", KeyW: "north", ArrowRight: "east", KeyD: "east", @@ -15,7 +18,42 @@ const HINTS = Object.freeze({none: "Молчит", n: "Север", ne: "Северо-восток", e: "Восток", se: "Юго-восток", s: "Юг", sw: "Юго-запад", w: "Запад", nw: "Северо-запад"}); const HINT_ANGLES = Object.freeze({none: 0, n: 0, ne: 45, e: 90, se: 135, s: 180, sw: 225, w: 270, nw: 315}); const PHASES = Object.freeze({countdown: "Обратный отсчёт", waiting: "Ждём игроков", active: "Гонка идёт", finish_pending: "Фотофиниш", paused: "Пауза", finished: "Финиш"}); - const ERRORS = Object.freeze({invalid_name: "Позывной должен быть длиной от 1 до 32 символов без пробелов по краям.", duplicate_name: "Этот позывной уже занят. Клоны сегодня не участвуют.", role_full: "Свободных мест больше нет.", invalid_token: "Старая сессия не найдена. Выбираем новый позывной.", session_connected: "Эта сессия уже открыта в другом окне.", not_owner: "Запустить гонку может только Игрок 1.", not_ready: "Для старта нужны оба игрока онлайн.", invalid_phase: "Сейчас эту команду выполнять нельзя.", rate_limited: "Слишком быстро! Сервер просит немного меньше турбо.", blocked: "Там стена. Очень убедительная.", no_key: "Карман пуст — бросать нечего.", player_exited: "Герой уже снаружи. Возвращаться за тапочками поздно.", match_finished: "Гонка уже закончилась.", stale_sequence: "Команда устарела и отправилась в музей.", unauthorized: "Сначала выберите роль или восстановите сессию.", text_required: "Сервер принимает только текстовые команды.", protocol_error: "Пакет не прошёл проверку протокола.", request_rejected: "Сервер отклонил команду."}); + const ERRORS = Object.freeze({invalid_name: "Позывной должен быть длиной от 1 до 32 символов без пробелов по краям.", duplicate_name: "Этот позывной уже занят. Клоны сегодня не участвуют.", role_full: "Свободных мест больше нет.", invalid_token: "Старая сессия не найдена. Выбираем новый позывной.", session_connected: "Эта сессия уже открыта в другом окне.", not_owner: "Запустить гонку может только Игрок 1.", not_ready: "Для старта нужны оба игрока онлайн.", invalid_phase: "Сейчас эту команду выполнять нельзя.", rate_limited: "Слишком быстро! Сервер просит немного меньше турбо.", blocked: "Там стена. Очень убедительная.", no_key: "Карман пуст — бросать нечего.", player_exited: "Герой уже снаружи. Возвращаться за тапочками поздно.", match_finished: "Гонка уже закончилась.", stale_sequence: "Команда устарела и отправилась в музей.", unauthorized: "Сначала выберите роль или восстановите сессию.", text_required: "Сервер принимает только текстовые команды.", protocol_error: "Пакет не прошёл проверку протокола.", request_rejected: "Сервер отклонил команду.", no_suggestion: "Все смешные имена уже разобрали."}); + + function randomIndex(limit, randomSource) { + if (!Number.isInteger(limit) || limit < 1) return 0; + if (typeof randomSource === "function") { + const value = randomSource(limit); + return Number.isInteger(value) && value >= 0 && value < limit ? value : 0; + } + if (root.crypto && typeof root.crypto.getRandomValues === "function") { + const maximum = Math.floor(0x100000000 / limit) * limit; + const values = new Uint32Array(1); + for (let attempt = 0; attempt < 4; attempt++) { + root.crypto.getRandomValues(values); + if (values[0] < maximum) return values[0] % limit; + } + return values[0] % limit; + } + return Math.floor(Math.random() * limit); + } + + function randomSuggestedName(occupiedNames, randomSource) { + const occupied = new Set(Array.isArray(occupiedNames) ? occupiedNames : []); + for (let attempt = 0; attempt < NAME_COMBINATION_COUNT; attempt++) { + const adjective = NAME_ADJECTIVES[randomIndex(NAME_ADJECTIVES.length, randomSource)]; + const noun = NAME_NOUNS[randomIndex(NAME_NOUNS.length, randomSource)]; + const name = `${adjective}-${noun}`; + if (!occupied.has(name)) return name; + } + for (let combination = 0; combination < NAME_COMBINATION_COUNT; combination++) { + const adjective = NAME_ADJECTIVES[Math.floor(combination / NAME_NOUNS.length)]; + const noun = NAME_NOUNS[combination % NAME_NOUNS.length]; + const name = `${adjective}-${noun}`; + if (!occupied.has(name)) return name; + } + return null; + } function directionFromKey(code) { return KEY_DIRECTIONS[code] || null; @@ -388,7 +426,6 @@ showNotice(message.payload.role === "player" ? `Слот P${message.payload.slot} закреплён за этим браузером.` : "Вы в зрительном зале. Руками не трогаем!", "success"); if (state.lobby) renderLobby(); } else if (message.type === "lobby") { state.lobby = message.payload; renderLobby(); - } else if (message.type === "name_suggestion") { byId("player-name").value = message.payload.name; } else if (message.type === "countdown") { showCountdown(message.payload); } else if (message.type === "active") { setScreen(state.session && state.session.role === "observer" ? "observer" : "match"); byId("countdown").textContent = "ВПЕРЁД"; windowObject.setTimeout(hideModal, 750); } else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false); @@ -426,7 +463,12 @@ byId("map-shell").addEventListener("touchend", event => { if (!state.touchStart) return; const touch = event.changedTouches[0]; move(directionFromSwipe(state.touchStart.x, state.touchStart.y, touch.clientX, touch.clientY), null); state.touchStart = null; }, {passive: true}); documentObject.querySelectorAll("[data-direction]").forEach(button => button.addEventListener("click", () => move(button.dataset.direction, button))); byId("drop-key").addEventListener("click", () => send("drop_key", {}, byId("drop-key"))); - byId("suggest-name").addEventListener("click", () => send("suggest_name", {}, byId("suggest-name"))); + byId("suggest-name").addEventListener("click", () => { + const occupiedNames = state.lobby && Array.isArray(state.lobby.players) ? state.lobby.players.filter(player => player.occupied && typeof player.name === "string").map(player => player.name) : []; + const suggestion = randomSuggestedName(occupiedNames); + if (suggestion) byId("player-name").value = suggestion; + else showNotice(localError("no_suggestion"), "error"); + }); byId("join-player").addEventListener("click", () => send("join", {role: "player", name: byId("player-name").value}, byId("join-player"))); byId("join-observer").addEventListener("click", () => send("join", {role: "observer"}, byId("join-observer"))); byId("start-match").addEventListener("click", () => send("start", {}, byId("start-match"))); @@ -451,7 +493,7 @@ return {state, handleMessage, move}; } - const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, directionFromKey, directionFromSwipe, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot}); + const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, NAME_COMBINATION_COUNT, directionFromKey, directionFromSwipe, randomIndex, randomSuggestedName, 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); });