docs(plans): add combinatorial name suggestions milestone

This commit is contained in:
2026-09-08 21:32:22 +03:00
parent a2a8f4a59d
commit 4b1ac6ca5a
9 changed files with 114 additions and 90 deletions
+41 -22
View File
@@ -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
@@ -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 {
@@ -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;
}
@@ -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;
-53
View File
@@ -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",
-2
View File
@@ -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
+1 -4
View File
@@ -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__":
+26
View File
@@ -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"}});
+46 -4
View File
@@ -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); });