docs(plans): add combinatorial name suggestions milestone
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
@@ -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); });
|
||||
|
||||
Reference in New Issue
Block a user