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 ## Milestone 012 — Combinatorial humorous name suggestions
**Status:** `NOT_STARTED` **Status:** `COMPLETE`
### Objective ### Objective
Replace the small fixed set of humorous player-name suggestions with a Replace the small fixed set of humorous player-name suggestions with a
deterministic, varied generator that combines a Russian adjective and noun in random browser-side generator that combines a Russian adjective and noun in the
the manner of Docker's random container names. manner of Docker's random container names.
### Scope ### Scope
@@ -1061,37 +1061,56 @@ the manner of Docker's random container names.
player-facing lobby. player-facing lobby.
- Generate valid UTF-8 name suggestions by combining one adjective and one - Generate valid UTF-8 name suggestions by combining one adjective and one
noun, with a hyphen between them. noun, with a hyphen between them.
- Cycle through the available combinations deterministically before repeating. - Select an adjective and noun independently with browser-provided randomness
- Skip combinations already assigned to a player session. each time the player requests a suggestion.
- Retain the existing bounded response size, protocol shape, and Russian - Skip combinations already shown as assigned in the authoritative public lobby
player-facing error behavior. projection.
- Add deterministic host-side coverage for combination count, cycling, - Remove the obsolete suggestion WebSocket request and its gateway code.
uniqueness, occupied-name skipping, and valid output bounds. - 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 ### Deliverables
- Combinatorial humorous-name generator in the authoritative web gateway. - Combinatorial humorous-name generator in the player application.
- Portable host tests covering the generator's selection rules. - Browser tests covering the generator's selection rules.
- Updated protocol/lobby test evidence. - Removed suggestion protocol and gateway implementation.
### Acceptance criteria ### Acceptance criteria
1. Each name suggestion has the form `adjective-noun`, uses valid UTF-8, and 1. Each name suggestion has the form `adjective-noun`, uses valid UTF-8, and
satisfies the lobby name-validation limits. satisfies the lobby name-validation limits.
2. The generator provides substantially more combinations than the previous 2. The generator provides substantially more combinations than the previous
five fixed suggestions and does not repeat one until its configured five fixed suggestions and independently randomizes its adjective and noun
combination set has been traversed. for each request.
3. A currently occupied player name is never suggested. 3. A currently occupied player name from the authoritative lobby projection is
4. Exhaustion returns the existing bounded Russian `no_suggestion` error. never suggested.
5. All existing host tests and the targeted generator tests pass. 4. Exhaustion displays a bounded Russian frontend error.
6. The firmware builds and remains within the factory partition budget. 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 ### Evidence
Completion evidence is not yet available. Expected evidence includes the - The browser test `frontend name suggestions randomize both words and remain
targeted host-test output, `bash scripts/run_host_tests.sh`, and bounded` passed. With an injected source it proved independent adjective and
`/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1` output with noun selection, the 144-combination vocabulary, UTF-8 byte-length bounds,
the final application-size report. 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 ## Milestone 013 — Random humorous login headlines
@@ -23,7 +23,6 @@ typedef enum {
MAZE32_MESSAGE_DROP_KEY, MAZE32_MESSAGE_DROP_KEY,
MAZE32_MESSAGE_RESTART, MAZE32_MESSAGE_RESTART,
MAZE32_MESSAGE_HEARTBEAT, MAZE32_MESSAGE_HEARTBEAT,
MAZE32_MESSAGE_SUGGEST_NAME,
} maze32_message_type_t; } maze32_message_type_t;
typedef enum { typedef enum {
@@ -196,9 +196,6 @@ static maze32_message_type_t message_type(const char* value) {
if (strcmp(value, "heartbeat") == 0) { if (strcmp(value, "heartbeat") == 0) {
return MAZE32_MESSAGE_HEARTBEAT; return MAZE32_MESSAGE_HEARTBEAT;
} }
if (strcmp(value, "suggest_name") == 0) {
return MAZE32_MESSAGE_SUGGEST_NAME;
}
return MAZE32_MESSAGE_INVALID; return MAZE32_MESSAGE_INVALID;
} }
@@ -66,7 +66,6 @@ typedef struct {
size_t outgoing_head; size_t outgoing_head;
size_t outgoing_count; size_t outgoing_count;
uint32_t server_sequence; uint32_t server_sequence;
unsigned int suggestion_index;
esp_timer_handle_t countdown_timer; esp_timer_handle_t countdown_timer;
esp_timer_handle_t reconnect_timer; esp_timer_handle_t reconnect_timer;
esp_timer_handle_t finish_timer; esp_timer_handle_t finish_timer;
-53
View File
@@ -15,9 +15,6 @@
#include "maze32_protocol.h" #include "maze32_protocol.h"
static const char* TAG = "maze32_gateway"; static const char* TAG = "maze32_gateway";
static const char* NAME_SUGGESTIONS[] = {
"Байт-Беглец", "Кот-Навигатор", "Сэр Тупичок", "Пиксельный Ёж", "Лорд Лабиринт",
};
static uint64_t now_ms(void) { static uint64_t now_ms(void) {
return (uint64_t)(esp_timer_get_time() / 1000); 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) { static void broadcast_countdown(maze32_gateway_t* gateway) {
char response[MAZE32_GATEWAY_OUTGOING_LIMIT]; char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
int written = snprintf(response, sizeof(response), 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, static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_t* connection,
const maze32_client_message_t* message) { const maze32_client_message_t* message) {
int socket_fd = connection->socket_fd; 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 (message->type == MAZE32_MESSAGE_JOIN) {
if (maze32_lobby_session_for_connection(&gateway->lobby, socket_fd) != NULL) { if (maze32_lobby_session_for_connection(&gateway->lobby, socket_fd) != NULL) {
send_error(gateway, socket_fd, message->request_id, "already_joined", 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, Player names contain 1–32 UTF-8 bytes, have no control bytes or outer spaces,
and are unique within the current lobby. and are unique within the current lobby.
- `restore`: `{"token":"32 lowercase hexadecimal characters"}`. - `restore`: `{"token":"32 lowercase hexadecimal characters"}`.
- `suggest_name`: `{}`.
- `start`: `{}`. Only connected Player 1 may use it, and both player slots must - `start`: `{}`. Only connected Player 1 may use it, and both player slots must
be connected. be connected.
- `move`: `{"direction":"north|east|south|west"}`. Only a player in an active, - `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 - `session`: the private role result containing `role`, `slot`, and temporary
`token`. The token is never included in a shared snapshot. `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, - `lobby`: the authoritative phase, revision, readiness, public player slots,
connected observer count, and limits. connected observer count, and limits.
- `countdown`: the single authoritative `[3,2,1]` event with common monotonic - `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: elif arguments.verify:
verify(arguments.host) verify(arguments.host)
else: else:
client = WebSocket(arguments.host) parser.error("choose --verify, --verify-m009, or --verify-m010")
client.send("suggest_name", {}, "manual")
print(json.dumps(client.receive(), ensure_ascii=False, indent=2))
client.close()
if __name__ == "__main__": 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); 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", () => { 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"}});
+46 -4
View File
@@ -6,6 +6,9 @@
const MAX_VIEW_CELLS = 8192; const MAX_VIEW_CELLS = 8192;
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_NOUNS = Object.freeze(["Барсук", "Крот", "Енот", "Кабан", "Кактус", "Жук", "Тостер", "Робот", "Гриб", "Самокат", "Бублик", "Пингвин"]);
const NAME_COMBINATION_COUNT = NAME_ADJECTIVES.length * NAME_NOUNS.length;
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",
@@ -15,7 +18,42 @@
const HINTS = Object.freeze({none: "Молчит", n: "Север", ne: "Северо-восток", e: "Восток", se: "Юго-восток", s: "Юг", sw: "Юго-запад", w: "Запад", nw: "Северо-запад"}); 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 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 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) { function directionFromKey(code) {
return KEY_DIRECTIONS[code] || null; return KEY_DIRECTIONS[code] || null;
@@ -388,7 +426,6 @@
showNotice(message.payload.role === "player" ? `Слот P${message.payload.slot} закреплён за этим браузером.` : "Вы в зрительном зале. Руками не трогаем!", "success"); showNotice(message.payload.role === "player" ? `Слот P${message.payload.slot} закреплён за этим браузером.` : "Вы в зрительном зале. Руками не трогаем!", "success");
if (state.lobby) renderLobby(); if (state.lobby) renderLobby();
} else if (message.type === "lobby") { state.lobby = message.payload; 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 === "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 === "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); } 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}); 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))); 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("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-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("join-observer").addEventListener("click", () => send("join", {role: "observer"}, byId("join-observer")));
byId("start-match").addEventListener("click", () => send("start", {}, byId("start-match"))); byId("start-match").addEventListener("click", () => send("start", {}, byId("start-match")));
@@ -451,7 +493,7 @@
return {state, handleMessage, move}; 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; 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); });