# Функциональная спецификация Galaxy Документ описывает, что делает платформа Galaxy в терминах пользовательских операций и логики каждого сервиса, которая их реализует. Каждый раздел проводит читателя по одному доменному сценарию: кто инициирует операцию, что `gateway` проверяет и форвардит, что `backend` валидирует и сохраняет, что возвращается клиенту, и какие побочные эффекты при этом запускаются (почта, push, операции с контейнерами). Это отправная точка для любого изменения, затрагивающего поведение системы. Точные форматы протоколов, словарь кодов ошибок, переменные окружения, значения по умолчанию, лимиты троттлинга, имена таблиц и колонок, field-level-валидация — всё это лежит в нижнеуровневых источниках: - [`ARCHITECTURE.md`](ARCHITECTURE.md) — глобальная архитектура, модель безопасности, транспортный контракт. - `galaxy//README.md` — структура сервиса, конфигурация, эксплуатация. - `galaxy//openapi.yaml`, `*.proto` — wire-контракты. - `galaxy//docs/flows.md` — sequence-диаграммы. Этот файл сознательно опускает такие детали. Если этот файл расходится с нижнеуровневым источником, см. правило синхронизации в проектном `CLAUDE.md`. > **Внимание.** Этот файл — перевод английского > [`FUNCTIONAL.md`](FUNCTIONAL.md) и **не является источником истины**. > Авторитетна английская версия; при расхождении выигрывает она. > Каждое точечное изменение в `FUNCTIONAL.md` должно быть зеркально > внесено сюда в том же патче (переводить только затронутые абзацы). > Полный перевод заново выполняется только по явному запросу владельца > проекта. Документ организован по доменным сценариям, не по группам HTTP-маршрутов. Публичные, user-аутентифицированные и admin-операции могут оказаться в одном разделе, если все они участвуют в одном бизнес-флоу. ## Содержание 1. [Аутентификация и устройство-сессия](#1-аутентификация-и-устройство-сессия) 2. [Управление аккаунтом](#2-управление-аккаунтом) 3. [Жизненный цикл игры в лобби](#3-жизненный-цикл-игры-в-лобби) 4. [Участие в лобби](#4-участие-в-лобби) 5. [Реестр названий рас](#5-реестр-названий-рас) 6. [Игровая сессия](#6-игровая-сессия) 7. [Канал push](#7-канал-push) 8. [Уведомления и почта](#8-уведомления-и-почта) 9. [Гео-сигнал](#9-гео-сигнал) 10. [Администрирование](#10-администрирование) 11. [Дипломатическая почта](#11-дипломатическая-почта) --- ## 1. Аутентификация и устройство-сессия Раздел описывает, как анонимный клиент становится аутентифицированным и остаётся таковым, пока серверное действие не отзовёт эти полномочия. ### 1.1 Состав В составе: выпуск e-mail-вызова на вход, его подтверждение (с созданием аккаунта при первом входе и регистрацией публичного ключа клиента), создание устройства-сессии, поиск сессии для каждого аутентифицированного запроса, отзыв сессии со стороны сервера. Вне состава: формат конверта и схема подписи, которые используются каждым аутентифицированным запросом — определены однажды в [ARCHITECTURE.md §15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary) и переиспользуются всеми последующими разделами; хранение ключа на стороне клиента; маршрутизация push-событий внутри gateway к конкретному стриму подписчика. ### 1.2 Выпуск вызова на вход Клиент отправляет e-mail на публичный auth-маршрут gateway. Маршрут не аутентифицирован — ещё нет устройства-сессии, к которому можно было бы привязаться. Gateway относится к этому маршруту как к более строгому классу "public auth": применяет per-IP и per-identity (per-email) anti-abuse, ограничение на размер тела, allow-list HTTP-методов, после чего форвардит запрос в backend. Сбои upstream-адаптера проецируются обратно клиенту с тем же статусом и envelope-ошибкой; транспортные сбои — обобщённым ответом "недоступно". Backend выпускает непрозрачный идентификатор вызова и отправляет письмо подтверждения через durable mail outbox. **Форма ответа идентична независимо от того, принадлежит ли e-mail существующему аккаунту, новому или попадает под троттлинг** — endpoint нельзя использовать для перечисления аккаунтов. Ветки внутри backend: - **Permanent block.** Если адрес заблокирован на уровне аккаунта, запрос отклоняется. Это единственная account-state-ветка, которая отдаёт отдельный код ошибки; все прочие ветки возвращают стандартную форму с challenge-id. - **Throttle.** Если для одного e-mail в окне троттлинга уже существует слишком много непогашенных и не истёкших вызовов, backend переиспользует последний имеющийся вызов вместо создания нового. Клиент получает ту же форму ответа и не знает о повторе. - **Иначе.** Backend создаёт новый вызов с разрешённым preferred_language (выводится из опционального поля `locale` в JSON-теле — оно имеет приоритет — либо, если оно отсутствует или пустое, из заголовка `Accept-Language`, форварднутого gateway, с откатом на дефолт) и в той же транзакции ставит auth-mail-строку прямо в outbox. SMTP-доставка асинхронна; auth-ответ возвращается, как только строки challenge и outbox durably закоммитены. Поле в теле — это канонический канал, потому что Safari молча сбрасывает выставляемые из JS заголовки `Accept-Language`; клиентам не на Safari достаточно одного заголовка. ### 1.3 Подтверждение вызова Клиент отправляет challenge id, код из письма, свежий публичный ключ Ed25519 и выбранную IANA-таймзону. Gateway применяет тот же public-auth anti-abuse-класс, но per-identity-бакет ключуется по challenge id, а не по e-mail. `Accept-Language` на этом endpoint не учитывается — preferred_language был зафиксирован на этапе send и проигрывается из строки challenge. Backend валидирует challenge под row lock: отклоняет неизвестные, истёкшие или уже погашенные id, инкрементирует счётчик попыток и сжигает challenge при достижении потолка. После того как код сошёлся, backend перепроверяет permanent-block-флаг — это ловит случай, когда админ применил блок между send и confirm — и отклоняет запрос, если флаг выставлен. На успешном пути backend гарантирует существование аккаунта (синтезирует неизменяемый display-handle только при первом входе и заполняет declared_country по source IP), после чего помечает challenge consumed и в той же транзакции создаёт устройство-сессию, привязанную к публичному ключу вызывающего. Ответ несёт идентификатор новой устройства-сессии. Challenge — single-use. Повторное подтверждение того же id возвращает ту же непрозрачную форму `invalid_request`, что и подтверждение неизвестного или истёкшего id; API сознательно не различает эти три случая, чтобы атакующий не мог майнить состояние challenge. Throttle-переиспользование на стороне send означает, что клиент, попавший под троттлинг, получит обратно последний существующий `challenge_id` вместо свежего, но каждый id всё равно гасится ровно один раз. ### 1.4 Поиск сессии для каждого запроса Когда у клиента есть идентификатор устройства-сессии и приватный ключ, каждый аутентифицированный вызов — это подписанный запрос к gateway по аутентифицированному edge-листенеру (Connect / gRPC / gRPC-Web на одном HTTP/h2c-порту). Gateway — единственный компонент, который видит подпись запроса; backend доверяет вердикту gateway. Gateway нужен публичный ключ сессии для проверки подписи, поэтому каждый аутентифицированный запрос разрешает устройство-сессию через in-memory LRU-кэш (с ограничением на число записей плюс TTL-страховка). При промахе кэш зовёт endpoint backend для поиска сессии и заполняет запись. Gateway отклоняет запрос, если кэш сообщает "сессия неизвестна" или "отозвана"; иначе он проверяет конверт согласно [ARCHITECTURE.md §15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary) и форвардит проверенный payload в backend по обычному REST, инжектируя в заголовке резолвлёный user_id. Backend никогда не выводит identity из тела запроса. Backend обновляет `last_seen_at` в строке сессии при каждом успешном поиске — это даёт админам видимость того, когда каждая закэшированная сессия в последний раз резолвилась на edge. Обновление — часть транзакции поиска; сбои логируются, но не пропагируются вызывающему. Кэш инвалидируется через push-канал, а не через периодический рефреш: событие `session_invalidation` переключает статус закэшированной записи на revoked, после чего последующие запросы, привязанные к этой сессии, отклоняются без повторного похода в backend. TTL — это страховка на случай потерянных событий (курсор устарел, gateway перезапустился) — в установившемся режиме push-события являются авторитетным источником инвалидации. ### 1.5 Отзыв Отзыв делает устройство-сессию неспособной аутентифицировать любой будущий запрос и принуждает закрыться все push-стримы, привязанные к ней. Триггеры разделяются на две группы. **Инициированный пользователем (logout).** User-surface предоставляет три операции: получить свои активные сессии, отозвать одну и отозвать все. Gateway форвардит их в backend как обычные аутентифицированные запросы. Backend проверяет, что целевая сессия принадлежит вызывающему (иначе возвращает ту же форму, что и отсутствующая сессия — чужие session id не могут быть зондированы), атомарно переключает `device_sessions.status` на `revoked` и вставляет строку в `session_revocations`, после чего публикует одно `session_invalidation`-событие на каждую отозванную сессию. **Инициированный админом и lifecycle.** Санкции, подразумевающие отзыв сессий (сейчас — `permanent_block`), admin-инициированный soft-delete и пользовательский self-soft-delete — все они приводят к in-process-вызову внутри backend. Действуют те же атомарные UPDATE + audit-insert + push-эмиссия; audit-строка несёт другой `actor_kind` (`admin_sanction` / `soft_delete_admin` / `soft_delete_user`). Когда backend опубликовал push-событие, gateway переключает закэшированную запись сессии на revoked и закрывает все активные push-стримы, привязанные к ней. Per-request internal-поиск против backend остаётся durable-страховкой: если push-событие потеряно, следующий поиск (после истечения TTL кэша) вернёт уже отозванную запись. `session_revocations` — это аудит-журнал. Каждая строка несёт `revocation_id`, `device_session_id`, `user_id`, `actor_kind`, пару полей актора (`actor_user_id` для user-driven kind'ов, `actor_username` для admin-driven kind'ов — ровно одно из двух заполнено в каждой строке), `reason` и `revoked_at`. Операторы могут запрашивать её, чтобы ответить "кто и почему отозвал эту сессию"; таблица append-only. Endpoint `/api/v1/internal/sessions/{id}` в backend — read-only: он несёт per-request session lookup, который gateway использует для проверки подписанных конвертов. Internal revoke-endpoints больше не существуют; revoke инициируется либо пользователем (через user-surface), либо админом (через in-process-вызов внутри backend). ### 1.6 Перекрёстные ссылки - Wire-конверт, подпись, окно свежести, anti-replay: [ARCHITECTURE.md §15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary). - Зоны ответственности backend-модулей `auth`, `user`, `geo`, `mail`, `push`: [ARCHITECTURE.md §4](ARCHITECTURE.md#4-backend-domain-modules) и `backend/README.md`. - Семантика mail outbox для шаблона auth login-code: [ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox). - Фрейминг push-канала и правила переподключения: [ARCHITECTURE.md §8](ARCHITECTURE.md#8-backend--gateway-communication). Пользовательская семантика push — в [Разделе 7](#7-канал-push) этого документа. --- ## 2. Управление аккаунтом Раздел описывает, что аутентифицированный пользователь может читать или менять в своём аккаунте и как удалить аккаунт. ### 2.1 Состав В составе: чтение агрегата аккаунта, обновление мутабельного слайса профиля, обновление настроек (preferred_language, time_zone, declared_country), пользовательский soft-delete. Вне состава: admin-side-мутации того же аккаунта (санкции, лимиты, изменения entitlement, admin-soft-delete) — описаны в [Разделе 10](#10-администрирование). Переключение permanent_block — только для админов. ### 2.2 Агрегат аккаунта Backend предоставляет один read-endpoint, который возвращает агрегат аккаунта вызывающего: durable-идентифицирующие поля (неизменяемый display-handle, e-mail), мутабельные слайсы profile и settings, текущий снимок entitlement, любые активные санкции и per-user-overrides лимитов. Агрегат — авторитетный клиентский взгляд "что платформа обо мне знает". Display-handle синтезируется при первом входе ([Раздел 1.3](#13-подтверждение-вызова)) и никогда не перезаписывается ни при последующих входах, ни при апдейтах профиля. Клиенты должны относиться к нему как к стабильному идентификатору, а не как к display-предпочтению. ### 2.3 Обновление профиля и настроек Два различных мутирующих endpoint'а разделяют user-управляемые поля по природе изменения. Оба следуют PATCH-семантике — отсутствующие поля не трогаются, присутствующие заменяют сохранённое значение — и оба возвращают обновлённый агрегат. Profile несёт одно display-ориентированное поле: `display_name`. Явно пустое значение очищает сохранённое имя; пропуск поля оставляет его нетронутым. Settings несёт locale- и timezone-предпочтения: `preferred_language` (BCP 47-тег) и `time_zone` (IANA-идентификатор). Оба должны быть непустыми после trim, если они присутствуют; timezone валидируется по IANA-базе перед коммитом. `declared_country` **не** входит ни в один из patch'ей. Backend пишет его один раз при регистрации из source IP ([Раздел 9](#9-гео-сигнал)) и считает неизменяемым после; нет user-видимого пути его изменить. ### 2.4 Удаление аккаунта пользователем Пользователь может попросить backend soft-delete'нуть свой аккаунт. Backend помечает строку аккаунта удалённой и запускает in-process-каскад, описанный в [ARCHITECTURE.md §7](ARCHITECTURE.md#7-in-process-async-patterns). Конкретно: - Каждая устройство-сессия пользователя отзывается ([Раздел 1.5](#15-отзыв)) — одна audit-строка на сессию и одно `session_invalidation`-push-событие на сессию. - Активные membership'ы переходят в `removed` (admin-инициированный блок переключает их в `blocked`); pending-заявки переходят в `rejected`; входящие приглашения — в `declined`; исходящие приглашения — в `revoked`. - Race-name-записи, принадлежащие пользователю — registered, reservation или pending_registration — удаляются одной cascade- записью. - Owned-игры в не-running-статусах (`draft`, `enrollment_open`, `ready_to_start`, `start_failed`, `paused`) отменяются. Owned-игры уже в `running` каскадом **не** отменяются — engine-контейнер продолжает выпускать ходы, пока не завершится естественно; только membership-cleanup отвязывает пользователя. - Один `lobby.membership.removed`-веер уведомлений уходит пользователю с `reason=removed` (или `reason=blocked` для admin-block-пути). Endpoint не возвращает тела. Каскад best-effort внутри одного процесса: если downstream-модуль падает, ошибка логируется, но аккаунт остаётся помеченным удалённым. ### 2.5 Перекрёстные ссылки - Admin-аналоги (sanction, limit, entitlement, soft delete): [Раздел 10](#10-администрирование). - Контракт каскада "user blocked / user deleted": [ARCHITECTURE.md §7](ARCHITECTURE.md#7-in-process-async-patterns). - Виды уведомлений, эмитящихся каскадом: [`backend/README.md` §10](../backend/README.md#10-notification-catalog). --- ## 3. Жизненный цикл игры в лобби Раздел описывает жизнь одной игры от создания до терминального состояния. [Раздел 4](#4-участие-в-лобби) описывает, как игроки присоединяются к существующей игре; этот раздел сосредоточен на самой игре. ### 3.1 Состав В составе: создание игры (private или public), обновление её мутабельной конфигурации, переходы по машине состояний лобби, отмена, повтор failed-старта, терминальные переходы (`finished`, `cancelled`). Вне состава: заявки, приглашения, membership'ы ([Раздел 4](#4-участие-в-лобби)), Race Name Directory-промоушен при завершении ([Раздел 5](#5-реестр-названий-рас)), engine-команды во время running-фазы ([Раздел 6](#6-игровая-сессия)). ### 3.2 Машина состояний Машина состояний лобби — закрытый граф, описанный в [ARCHITECTURE.md §7](ARCHITECTURE.md#7-in-process-async-patterns): ```text draft → enrollment_open → ready_to_start → starting → running ↔ paused → finished ↳ start_failed → ready_to_start (retry) cancelled достижим из любого pre-finished-состояния. ``` Два базовых правила: - **Тип владения определяет surface.** Privatе-игры несут `owner_user_id`; переходы инициирует владелец через user-surface. Публичные игры — collective ownership админов (`owner_user_id IS NULL`); их переходы и изменения конфигурации идут через admin-surface. - **Runtime-callback владеет одним переходом.** `starting → running` и `starting → start_failed` — единственные переходы, которые производит runtime-модуль, после того как engine-контейнер полностью поднялся или подтвердил сбой. Каждый прочий переход — user- или admin-действие. ### 3.3 Создание Пользователь создаёт private-игру через user-surface. Backend записывает новую игру с `owner_user_id`, равным вызывающему, visibility `private`, в состоянии `draft`, с конфигурацией из тела запроса в качестве начальных значений. User-surface гейтится платным тарифом вызывающего. Backend читает `EntitlementProvider.IsPaid(userID)` перед вызовом lobby-сервиса; free-tier-вызовы отклоняются с HTTP `403 forbidden` (канонический код ошибки `forbidden`), и `draft`-запись не создаётся. Соответствующие UI-аффордансы — подраздел `private games` в сайдбаре и кнопка `create new game` внутри него — скрыты в lobby-shell для free-tier-сессий; build-флаг `VITE_GALAXY_DEV_AFFORDANCES` переопределяет UI-гейт, чтобы owner мог в DEV-сборке проверять обе ветки с одного тестового аккаунта. Admin-создание public-игр ([Раздел 10](#10-администрирование)) обходит тир-гейт. Public-игры создаются исключительно через admin-surface ([Раздел 10](#10-администрирование)). User-surface никогда не производит public-игру; асимметрия enforced в backend, не на уровне маршрута. ### 3.4 Прямые переходы Владельцы инициируют прямые переходы через специальные endpoint'ы (`open-enrollment`, `ready-to-start`, `start`, `pause`, `resume`, `retry-start`). Каждый endpoint: - проверяет владение игрой (или admin-scope для public-игр); - проверяет, что исходное состояние совпадает с предусловием перехода, отклоняя с conflict иначе; - обновляет lobby-запись и публикует все user-видимые уведомления, привязанные к переходу. `start` ставит в очередь runtime-job (длинный pull / start / init контейнера) и сразу возвращает "queued". Финальное движение состояния (`starting → running` или `starting → start_failed`) приходит позже через runtime-callback. `retry-start` возвращает `start_failed`-игру в `ready_to_start` и позволяет владельцу снова дёрнуть `start`. `pause` и `resume` переключают между `running` и `paused`. Запущенный engine-контейнер не сносится при pause; меняются только lobby-расписание и флаги приёма команд. `ready-to-start` всегда — explicit-действие владельца (или админа), никогда не auto-fired. Переход проверяет, что число одобренных участников не меньше `min_players`, и иначе отклоняет с conflict. ### 3.5 Отмена и завершение `cancel` достижим из любого pre-finished-состояния. Владельцы могут отменять свои игры; админы — любые. Отмена примиряет оставшиеся заявки, приглашения и membership'ы; она не повышает race-name-резервации. `finished` производится внутри backend, после того как engine сообщает о завершении игры. Переход сносит engine-контейнер, замораживает lobby-запись и триггерит Race Name Directory-промоушен для capable-finishes ([Раздел 5](#5-реестр-названий-рас)). Оба терминальных состояния поглощающие. ### 3.6 Админские оверрайды Администраторы могут делать `force-start`, `force-stop` и `ban-member` на любой игре (public или private), независимо от состояния. `force-stop` переводит игру в stopped-состояние и сносит engine-контейнер; `ban-member` удаляет membership и запрещает пользователю снова присоединиться ([Раздел 4](#4-участие-в-лобби)). ### 3.7 Перекрёстные ссылки - Словарь машины состояний и правила переходов: [ARCHITECTURE.md §7](ARCHITECTURE.md#7-in-process-async-patterns). - Жизненный цикл runtime-job (асинхронная работа за `start`): [ARCHITECTURE.md §13](ARCHITECTURE.md#13-container-lifecycle-in-process) и `backend/docs/flows.md`. - Public-vs-private-инварианты и поддерживающий их частичный индекс: [ARCHITECTURE.md §4](ARCHITECTURE.md#4-backend-domain-modules). --- ## 4. Участие в лобби Раздел описывает всё, что связано с присоединением и выходом из существующей игры: заявки (для public), приглашения (для private) и membership'ы (после успешного присоединения). ### 4.1 Состав В составе: подача заявки в public-игру, одобрение / отклонение заявки владельцем или админом, выпуск и активация приглашений, отказ получателя и отзыв выпустившим, листинг membership'ов для игры, удаление или блокировка участника. Вне состава: сама машина состояний игры ([Раздел 3](#3-жизненный-цикл-игры-в-лобби)) и in-game-команды, когда участник уже играет ([Раздел 6](#6-игровая-сессия)). ### 4.2 Заявки (public-игры) Пользователь подаёт заявку в игру по id. Заявки **принимаются только в public-играх**; попытка против private-игры отклоняется с conflict. Игра должна дополнительно быть в `enrollment_open` (единственное enrolment-принимающее состояние для заявок). Backend также отклоняет запрос, если пользователь уже member или в block-листе игры (через `ban-member`). Иначе сохраняет заявку как `pending` и эмитит уведомление в admin-канал. Владелец — или администратор для public-игр — одобряет или отклоняет заявку через специальные endpoint'ы. Одобрение создаёт membership для подающего и эмитит соответствующее уведомление. Отклонение просто записывает терминальное состояние; membership не появляется. ### 4.3 Приглашения (private-игры) Приглашения **принимаются только в private-играх**; попытка выпустить для public-игры отклоняется с conflict. Владелец выпускает приглашение, пока игра в `draft`, `enrollment_open` или `ready_to_start`. Сосуществуют две разновидности: - **User-bound** — установлен `invited_user_id`; погасить может только этот пользователь. Эмитится уведомление `lobby.invite.received` получателю. - **Code-based** — `invited_user_id` пуст; backend минтит hex-код при выпуске, и любой вызывающий, знающий код, может погасить. При выпуске уведомление не эмитится (получатель ещё не привязан). Каждое приглашение несёт срок действия (по умолчанию из конфигурации, если тело пропускает `expires_at`). Получатель гасит (создаёт membership) или отклоняет; выпустивший может отозвать выданное приглашение в любое время до погашения. ### 4.4 Membership'ы Membership'ы перечисляют игроков, прикреплённых к игре. Владельцы могут удалить или заблокировать члена; член может также удалить сам себя. Удаление чисто прекращает участие; блок дополнительно запрещает тому же пользователю снова подаваться или гасить будущее приглашение для той же игры. Admin-surface предоставляет `ban-member` как cross-game-policy- аналог owner-блока. ### 4.5 Личные списки User-surface предоставляет три "my"-листинга (games, applications, invites). Они проецируют участие вызывающего по всем играм без необходимости заранее знать game-id'ы — это даёт возможность для dashboard- и inbox-вью. ### 4.6 Уведомления Каждое изменение состояния в этом разделе эмитит уведомление из каталога: `lobby.invite.received`, `lobby.invite.revoked`, `lobby.application.submitted`, `lobby.application.approved`, `lobby.application.rejected`, `lobby.membership.removed`, `lobby.membership.blocked`. [Раздел 8](#8-уведомления-и-почта) описывает веер. ### 4.7 Перекрёстные ссылки - Жизненный цикл игры: [Раздел 3](#3-жизненный-цикл-игры-в-лобби). - Каталог уведомлений и веер: [Раздел 8](#8-уведомления-и-почта) и [`backend/README.md` §10](../backend/README.md#10-notification-catalog). --- ## 5. Реестр названий рас Раздел описывает, как игрок выбирает имя своей in-game-расы и в итоге получает это имя зарегистрированным платформенно. ### 5.1 Состав В составе: трёхуровневый реестр (registered, reservation, pending_registration), промоушен через "capable finish", пользовательский промоушен pending_registration в registered, sweeper-релиз по истечению TTL, уникальность через canonical-key- модель. Вне состава: как движок реально потребляет выбранное имя — это живёт в [Разделе 6](#6-игровая-сессия). ### 5.2 Три уровня - **Registered** — platform-unique. У одного canonical-key — не более одного живого binding к одному пользователю. - **Reservation** — per-game. Один и тот же canonical-key может быть зарезервирован одним и тем же пользователем в нескольких активных играх одновременно, но два разных пользователя не могут зарезервировать один canonical-key в одной игре. - **Pending registration** — переходный уровень между reservation и registered. Выпускается автоматически после "capable finish" (игра завершилась с тем, что игрок вырастил начальные значения планет и популяции) и даёт пользователю окно времени, чтобы превратить reservation в постоянную registered-запись. ### 5.3 Канонизация Каждое имя (введённое пользователем или зарегистрированное платформой) сворачивается в canonical key. Канонизация confusable- aware (latin-cyrillic-look-alikes, цифро-буквенные подмены) и применяется единообразно по реестру; уникальность enforced по canonical-key, не по отображаемому имени. Cross-tier-конфликты по одному и тому же canonical-key блокируются на write через per-canonical advisory lock. ### 5.4 Путь продвижения Reservation появляется, когда игрок именует свою расу во время игры. Когда игра capable-завершается, backend автоматически конвертирует reservation в pending_registration с TTL. Пока pending-запись жива, пользователь может вызвать registration- endpoint, чтобы продвинуть запись в `registered`. Если TTL истёк раньше, периодический sweeper освобождает запись; canonical-key снова доступен. Pending registration может claim'нуть только пользователь, который её заработал; backend отклоняет попытку другого пользователя, даже если canonical-key совпадает. ### 5.5 Уведомления Реестр эмитит `lobby.race_name.registered`, `lobby.race_name.pending` и `lobby.race_name.expired` владеющему пользователю. [Раздел 8](#8-уведомления-и-почта) описывает веер. ### 5.6 Перекрёстные ссылки - Библиотека канонизации и записи глоссария ("canonical key", "capable finish"): [ARCHITECTURE.md §19](ARCHITECTURE.md#19-glossary). - Триггер промоушена внутри lobby-модуля: [ARCHITECTURE.md §7](ARCHITECTURE.md#7-in-process-async-patterns) (`lobby.OnGameFinished`) и `backend/docs/flows.md`. --- ## 6. Игровая сессия Раздел описывает, что делает активный игрок, пока идёт игра: посылает команды и приказы, читает отчёты по ходам. ### 6.1 Состав В составе: подача команд, подача приказов, чтение отчёта, turn-cutoff-поведение, которое закрывает окно команд во время генерации. Вне состава: как сам engine-контейнер запускается, планируется или останавливается — это runtime-вопросы, описанные в [Разделе 3](#3-жизненный-цикл-игры-в-лобби) (start / stop) и [Разделе 10](#10-администрирование) (admin-runtime-оверрайды). Wire-формат команд, приказов и отчётов — собственный контракт движка, здесь не дублируется. ### 6.2 Роль backend: pass-through с авторизацией Подписанный конвейер аутентифицированного edge для in-game-трафика использует три message types на аутентифицированной поверхности — `user.games.order`, `user.games.order.get`, `user.games.report` — у каждого типизированный FlatBuffers-payload. Gateway транскодирует FB-запрос в JSON-форму, которую ждёт backend, форвардит её REST'ом в соответствующий `/api/v1/user/games/{game_id}/*` endpoint, после чего транскодирует JSON-ответ обратно в FB перед подписью. `user.games.order.get` — read-back-компаньон для `user.games.order`: клиент использует его, чтобы восстановить локальный черновик приказа после потери кэша (свежая установка, очищенное хранилище, новое устройство). Для каждого in-game-endpoint user-surface работает как авторизующий pass-through к engine-контейнеру. Backend: - проверяет, что вызывающий — активный member целевой игры и что игра в состоянии, принимающем операцию; - ребиндит поле `actor` в теле на race-name вызывающего из runtime-player-mapping (клиент не несёт доверенный actor); - резолвит engine-endpoint (запущенный контейнер для `game_id`) и форвардит вызов; - возвращает payload-ответа движка клиенту без переинтерпретации. Backend не парсит содержимое payload команд или приказов сверх того, что требует авторизация. Движок — источник истины о валидности и порядке in-game-решений. Gateway знает типизированную FB-форму только чтобы транскодировать wire-формат; per-command- семантика живёт в движке. Специально для `user.games.order` движок валидирует каждую команду приказа на транзиентном слепке текущего состояния игры и записывает итог по каждой команде в её мету (`cmdApplied`, `cmdErrorCode`) в том же ответе `UserGamesOrder`. Приказ сохраняется с этими per-command-вердиктами даже если часть команд была отклонена — например, удаление команды «создать класс корабля X» из приказа, в котором остаётся «строить X», приводит к тому, что вторая команда возвращается с `cmdErrorCode` «сущность не существует», а остальные команды приказа остаются сохранёнными, и ответ остаётся `202 Accepted`. `400` возвращается только для структурных отказов на уровне приказа (`quit` не последняя команда, неизвестный command type, малформированный вход); `500` — только для реальных внутренних сбоев движка. ### 6.3 Окно хода и auto-pause Запущенная игра постоянно чередуется между окном приёма команд и фазой генерации, управляемой cron-выражением из `runtime_records.turn_schedule`. Backend-планировщик (`backend/internal/runtime/scheduler.go`) оборачивает каждый engine `/admin/turn` двумя `runtime_status`-флипами: - Перед engine-вызовом: `running → generation_in_progress`. User-games-handler'ы команд/приказов (`backend/internal/server/handlers_user_games.go`) на каждом запросе сверяются с per-game runtime-записью и отклоняют с HTTP 409 + `code = turn_already_closed`, пока runtime в `generation_in_progress`. Тело ошибки — стандартный `httperr`-конверт: `{"error": {"code": "turn_already_closed", "message": "..."}}`. - После успешного тика: `generation_in_progress → running`. Окно приказов открывается на новый ход, следующий тик идёт как обычно. - После провалившегося тика (`engine_unreachable` / `generation_failed`): `lobby.OnRuntimeSnapshot` переводит игру `running → paused` и публикует push-эвент `game.paused` (см. §6.6). Order-handler'ы отклоняют запросы с HTTP 409 + `code = game_paused`, пока админ не выполнит resume. `force-next-turn` (admin) планирует one-shot-доп-тик, который сдвигает следующий запланированный ход на один cron-шаг; те же правила status-flip и отклонения применимы. Клиенты различают два варианта отказа по `code`: `turn_already_closed` — «дождись следующего `game.turn.ready` и отправь ещё раз», `game_paused` — «дождись resume администратором». Web-клиент реализует оба сценария согласно `ui/docs/sync-protocol.md`. ### 6.4 Отчёты Per-turn-отчёты — read-only-вью, забираемые из движка по запросу. Backend авторизует вызывающего и форвардит запрос; в этом пути нет ни кэширования, ни денормализации. Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив (общие сведения, скоро покидающие игру расы, голоса, статус игроков, мои / чужие науки, мои / чужие классы кораблей, сражения, бомбардировки, приближающиеся группы, мои / чужие / необитаемые / неопознанные планеты, корабли в производстве, грузовые маршруты, мои флоты, мои / чужие / неопознанные группы кораблей). Пустые секции получают явную копию empty-state; исключение — секция «скоро покидающие игру расы»: она полностью скрывается, когда ни одна раса не близка к исключению. Если же близка к исключению за неактивность сама локальная раса (осталось не более пяти ходов), над списком секций показывается персональный баннер-предупреждение (стиль danger) с числом оставшихся ходов; публичная секция «скоро покидающие игру расы» перечисляет все прочие расы, до исключения которых осталось не более трёх ходов. Навигация по секциям — sticky icon-popup в правом верхнем углу колонки отчёта (анкорный popover на десктопе и фикс. bottom-sheet на мобильном); подпись на кнопке отслеживает раздел, который сейчас в зоне видимости, выбор пункта меню — скролл к нужной секции. При возврате в активный вью отчёт перемонтируется, позиция скролла сбрасывается к началу, а IntersectionObserver заново рассчитывает подсветку при прокрутке. Секция бомбардировок — это плоская read-only-таблица: одна строка на событие, колонки `attacker`, `attack_power`, признак `wiped` и ресурсный снимок после удара. Секция сражений — список ссылок в Battle Viewer (см. [§6.5](#65-battle-viewer)). ### 6.5 Battle viewer Battle Viewer — отдельное представление, заменяющее карту и показывающее одну битву. Входы: - Строка в секции «сражения» в Reports (ссылка с пиннингом текущего хода через `?turn=`). - Battle-marker на карте (жёлтый крест через противоположные углы квадрата, описанного вокруг круга планеты; толщина линий растёт с длиной протокола). Сам Viewer — логически изолированный компонент, потребляющий `BattleReport` в форме `pkg/model/report/battle.go`. Страница-обёртка (`ui/frontend/src/lib/active-view/battle.svelte`) забирает отчёт подписанной ConnectRPC-командой `user.games.battle` на аутентифицированном edge: gateway переводит верифицированный envelope в `GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}` к backend-у, тот в свою очередь проксирует engine-эндпоинт `GET /api/v1/battle/:turn/:uuid`. Для synthetic-игр загрузчик короткозамыкает запрос на in-memory карту фикстур, наполненную из synthetic-report envelope (см. ниже), и не обращается к gateway. Визуальная модель — радиальная: планета в центре, расы по внешней окружности на равных угловых интервалах, внутри расы — облако кружков по классам кораблей, выложенное Vogel-спиралью с биасом к планете (самая многочисленная группа по NumberLeft — ближе к планете, остальные раскручиваются спиралью позади). Tech-варианты одного `(race, className)` схлопываются в один визуальный нод `:`; детали по тех-уровням остаются в Reports. Радиус кружка масштабируется по FullMass корабля (диапазон `[6, 24] px`, нормировка на самую тяжёлую группу в битве), так что тяжёлые корабли визуально доминируют. Наблюдатели (`inBattle: false`) не рисуются. Выбывшие расы убираются из сцены, оставшиеся перераспределяются на следующем кадре. Viewer закреплён по высоте viewport-а: сцена растягивается, лог скроллит внутри — никаких скроллов на уровне страницы. Каждый кадр — одна запись протокола; выстрел рисуется тонкой линией от атакующего к защитнику, красной при `destroyed`, зелёной иначе. Непрерывное воспроизведение: 1x / 2x / 4x (400 / 200 / 100 мс на кадр), плюс play/pause, шаг вперёд/назад, rewind. Текстовый протокол доступности под сценой дублирует те же события построчно. Бомбардировки и сражения умышленно не смешиваются: бомбардировки остаются статической таблицей в Reports; bombing-marker на карте — тонкая окружность вокруг планеты (жёлтая при damaged, красная при wiped), клик скроллит соответствующую строку в Reports. Текущая wire-форма отчёта несёт `battle: [{ id, planet, shots }]` на каждую битву, чтобы map-маркеры могли расположиться без дополнительного запроса полного `BattleReport`. Для DEV / e2e легаси-CLI (`tools/local-dev/legacy-report/cmd/legacy-report-to-json`) выдаёт envelope `{version: 1, report, battles}`, где `battles` несёт полные `BattleReport`-ы, распарсенные из `Battle at (#N)`-блоков. Synthetic- загрузчик в лобби разбирает envelope и регистрирует каждую битву через `registerSyntheticBattle`, так что Battle Viewer открывает любой UUID без сетевого запроса. ### 6.6 Побочные эффекты Успешная генерация хода публикует runtime-snapshot в lobby-модуль, который обновляет денормализованное вью (текущий ход, runtime- status, per-player-stats). Engine-отчёт "game finished" гонит переход `running → finished` ([Раздел 3.5](#35-отмена-и-завершение)) и триггерит Race Name Directory-промоушен ([Раздел 5](#5-реестр-названий-рас)). Из `game.*`-видов уведомлений подключены `game.turn.ready` и `game.paused`: - `game.turn.ready` — `lobby.Service.OnRuntimeSnapshot` (`backend/internal/lobby/runtime_hooks.go`) выпускает один intent на каждое увеличение `current_turn`, адресуя его всем активным membership-ам игры, с idempotency-ключом `turn-ready::` и JSON-payload-ом `{game_id, turn}`. - `game.paused` — тот же хук публикует один intent на каждое выставление статуса `paused` по runtime-снапшоту (`engine_unreachable` / `generation_failed`), адресуя его всем активным membership-ам игры, с idempotency-ключом `paused::` и JSON-payload-ом `{game_id, turn, reason}`. `reason` несёт runtime-статус, спровоцировавший переход, чтобы UI смог в будущем дифференцировать копию. Оба вида направляются только в push-канал; email-фан-аут сознательно опущен, чтобы избежать спама на каждом ходе/паузе. Остальные `game.*`-виды (`game.started`, `game.generation.failed`, `game.finished`) и `mail.dead_lettered` зарезервированы без поставщика; добавление поставщика чисто аддитивное (зарегистрировать вид в каталоге, расширить `CHECK`-констрейнт миграции и вызвать `notification.Submit` из подходящего доменного модуля). ### 6.7 Управление видимостью карты Карта (`ui/frontend/src/lib/active-view/map.svelte`) несёт попап-«шестерёнку» (`map-toggles.svelte`) в правом верхнем углу канваса — посекторный интерфейс видимости для конкретной игры. Попап содержит три группы элементов; любое изменение применяется в пределах одного кадра (без перемонтирования Pixi): - **Объекты** — шесть независимых чекбоксов: группы в гиперпространстве, входящие группы, неопознанные группы, грузовые маршруты, метки сражений, метки бомбардировок. - **Планеты** — четыре строки: чужие / необитаемые / неопознанные виды планет плюс выключатель «показывать недостижимые планеты», который при выключении прячет каждую не-LOCAL планету, отстоящую дальше `FlightDistance(localPlayerDrive)` от любой LOCAL-планеты (метрика учитывает торическую развёртку). - **Вид** — переключатель «видимое гиперпространство» (лёгкая заливка, подобранная под фон карты активной темы, вне объединения окружностей `VisibilityDistance(localPlayerDrive)` вокруг LOCAL-планет; LOCAL-планеты всегда вне фильтра — тоггл назван по видимой области карты, а не по затемнённой). Рендерер всегда работает в торическом режиме; прежняя радиогруппа «торус / без переноса» была удалена в полишинге F8 (issue #48 п.8), поскольку топология карты — серверная сущность, а не per-session UI-настройка. Код-путь без переноса в рендерере оставлен на день, когда движок выставит режим bounded plane. LOCAL-планеты отрисовываются всегда — для них тоггла нет. Остальные тогглы по умолчанию включены. Скрытие планеты каскадно прячет все привязанные к ней примитивы: метки сражений и бомбардировок на этой планете, точки in-space и incoming ship-групп вместе с траекториями, летящих *к* этой планете, и грузовые стрелки, чей источник или назначение — эта планета. Каскад не оставляет на карте «осиротевших» меток, указывающих в пустоту. Состояние видимости сохраняется по игре в namespace кеша `game-map-toggles/{gameId}` (см. [`ui/docs/storage.md`](../ui/docs/storage.md)). При каждом новом серверном ходе, ставшем текущим — либо через `setGame`, обнаруживший `currentTurn` сервера выше последнего сохранённого `lastResetTurn`, либо при явном клике пользователя по аффордансу «новый ход» — все тогглы принудительно сбрасываются в дефолт, чтобы новое содержимое хода не оказалось скрытым устаревшими настройками. Навигация по истории (`viewTurn`) общее состояние тогглов не сбрасывает. ### 6.8 Перекрёстные ссылки - Backend ↔ engine wire-контракт (`pkg/model/{order,report,rest}`): [ARCHITECTURE.md §9](ARCHITECTURE.md#9-backend--game-engine-communication). - Жизненный цикл контейнера, дисциплина меток, согласование: [ARCHITECTURE.md §13](ARCHITECTURE.md#13-container-lifecycle-in-process) и `backend/docs/flows.md`. --- ## 7. Канал push Раздел описывает, как платформа пушит real-time-события аутентифицированным клиентам (turn-ready-сигналы, изменения состояния лобби, инвалидации сессий). ### 7.1 Состав В составе: server-streaming-подписка, которую клиент открывает к gateway (Connect / gRPC / gRPC-Web фреймы все маршрутизируются на одну точку), bootstrap-событие, фрейминг форварднутых событий, control-канал backend → gateway, который производит эти события. Вне состава: каталог видов событий — см. [Раздел 8](#8-уведомления-и-почта) для notification-стороны и [`backend/README.md` §10](../backend/README.md#10-notification-catalog) для закрытого списка. ### 7.2 Подписка клиента Аутентифицированный клиент открывает server-streaming-вызов `SubscribeEvents` на gateway. Gateway проводит ту же envelope- проверку, что и для unary-запросов ([Раздел 1.4](#14-поиск-сессии-для-каждого-запроса)), затем регистрирует стрим в своём внутреннем хабе. Первый фрейм, получаемый клиентом — это gateway-подписанное bootstrap-событие с текущим серверным временем, чтобы клиент мог калибровать свои локальные часы без отдельного запроса. Пока стрим открыт, gateway отслеживает таймер тишины; если за `GATEWAY_PUSH_HEARTBEAT_INTERVAL` (по умолчанию `15s`, `0s` отключает) не пришло ни одного реального события, gateway отправляет неподписанное `gateway.heartbeat`-событие, чтобы браузерные fetch-streaming слои не закрыли response body как idle. Реальные события сбрасывают таймер, поэтому на нагруженных стримах heartbeat срабатывает редко. UI-клиент короткозамыкает heartbeat-тип до верификации подписи и никогда не дотягивает его до handlers — см. [`docs/ARCHITECTURE.md` § 15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary) для расчёта траффика и обоснования отсутствия подписи у heartbeat. ### 7.3 Управление backend → gateway Backend хостит единственный gRPC-сервис `Push.SubscribePush`, потребляемый gateway. На каждую client-id gateway одновременно существует ровно одна логическая подписка; переподключение с тем же id заменяет старую подписку. Каждый фрейм в стриме несёт монотонный курсор и одну из двух форм payload: - **Client event.** Типизированный payload, адресованный одному пользователю (и опционально одной устройства-сессии). Поставщики передают в `push.Service` объект `push.Event` (Kind + Marshal), сервис сам вызывает Marshal и кладёт байты в `pushv1.ClientEvent.Payload`. Gateway форвардит байты внутри подписанного клиентского конверта без переинтерпретации. Поставщики прикрепляют correlation-id'ы, которые gateway пробрасывает as-is. Новые виды событий поставляются с FlatBuffers-реализацией Event; виды, ещё не мигрировавшие, используют fallback `push.JSONEvent`, чтобы pipeline продолжал отправлять их без задержек на миграцию. - **Session invalidation.** Говорит gateway сбросить активные стримы и отклонить in-flight-запросы для затронутых сессий — путь распространения отзыва, описанный в [Разделе 1.5](#15-отзыв). ### 7.4 Надёжность и переподключение Backend держит in-memory ring-буфер недавних событий. При переподключении gateway шлёт последний потреблённый курсор; backend возобновляет с следующего события, если курсор всё ещё внутри freshness-window-TTL, или начинает с головы, если курсор устарел. Per-connection-обратное давление — drop-oldest: медленное gateway-соединение теряет старые события первыми, при каждом дропе пишется log-строка, чтобы обе стороны могли скоррелировать дыру. Push-канал — best-effort. Durable-запись "мы попытались сообщить этому пользователю об этом событии" живёт в `notifications` / `notification_routes` ([Раздел 8](#8-уведомления-и-почта)); потерянное push-событие не значит, что платформа забыла событие. ### 7.5 Поставщики Backend-поставщики, эмитящие в push-канал, — это: notification- диспатчер (push-маршруты из каталога) и session-модуль (события отзыва). Никакой другой доменный модуль не эмитит client-события, кроме notification-диспатчера. ### 7.6 Перекрёстные ссылки - Wire-конверт, используемый для push-фреймов: [ARCHITECTURE.md §15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary). - Семантика переподключения и ring-буфера: [ARCHITECTURE.md §8](ARCHITECTURE.md#8-backend--gateway-communication) и `backend/docs/flows.md` "Push gRPC". - Notification-диспатчер: [Раздел 8](#8-уведомления-и-почта). --- ## 8. Уведомления и почта Раздел описывает, как платформа сообщает пользователю о событии через push или e-mail (или оба). ### 8.1 Состав В составе: подача notification-намерения, веер по push- и email- каналам, durable mail outbox, dead-letter-обработка, оператор- инициированный resend. Вне состава: per-event-семантика — когда срабатывает каждый вид, описано в соответствующем feature-разделе ([Раздел 4](#4-участие-в-лобби) для lobby-видов, [Раздел 5](#5-реестр-названий-рас) для race-name-видов, [Раздел 6](#6-игровая-сессия) для game-видов). ### 8.2 Notification-намерение и веер Доменные поставщики (lobby, runtime, geo) подают типизированное намерение в notification-модуль вместо передачи сообщения в конкретный канал. Модуль затем: - enforced'ит идемпотентность по виду намерения плюс идемпотентному ключу от поставщика; - резолвит получателей; - материализует один маршрут на получателя на канал, по политике каталога, специфичной для типа (только push, только email, оба, или admin email); - эмитит push-маршруты в gRPC push-стрим, потребляемый gateway; - вставляет email-маршруты прямо в mail outbox. Малформ-намерения карантинизируются в выделенную таблицу и никогда не блокируют поставщика. ### 8.3 Каталог Каталог — это закрытый набор видов. Каждый вид специфицирует свои каналы и payload-поля, потребляемые шаблонами и клиентами. Три категории записей заслуживают отдельного упоминания: - **`auth.login_code`.** Это единственный вид, обходящий notification-pipeline целиком. Auth пишет email-строку прямо в outbox, чтобы commit challenge был атомарен с mail-enqueue. - **`runtime.*`-виды.** Они доставляются на сконфигурированный admin email. Если admin email не сконфигурирован, маршруты ложатся со статусом `skipped` и оператор-логом — запрос никогда не падает из-за отсутствия operator-конфига. - **Зарезервированные виды без поставщика.** `game.*` и `mail.dead_lettered` перечислены в каталоге, но текущий код никаких из них не эмитит. Добавление поставщика чисто аддитивно. ### 8.4 Mail outbox Email — это Postgres-backed durable outbox. Поставщики (notification-маршруты и auth login-code-путь) пишут delivery- строку плюс рендеренные payload-байты в одной транзакции. Worker-горутина дренит outbox: подбирает строки под row-lock, пытается SMTP-доставку, записывает попытку и либо помечает строку sent, либо планирует следующую попытку с exponential backoff и jitter. Доставка, превысившая бюджет попыток, переходит в dead-letter- таблицу; сам dead-lettering эмитит admin-notification-намерение. На старте worker дренит всё, что в pending или retrying — нет отдельного recovery-флоу. Операторы могут переслать non-`sent` доставку с admin-surface ([Раздел 10](#10-администрирование)). Resend по `sent`-доставке отклоняется, чтобы оператор случайно не пере-отправил почту, которая уже ушла из relay. ### 8.5 Видимость для оператора Admin-surface перечисляет deliveries, attempts на delivery, dead-letters, notifications, notification-dead-letters и malformed notification-намерения. Ничего из этих листингов недоступно с user-surface. ### 8.6 Перекрёстные ссылки - Таблица notification-каталога (виды, каналы, payload): [`backend/README.md` §10](../backend/README.md#10-notification-catalog). - Внутренности mail outbox (таблицы, лог попыток, worker pickup): [ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox) и `backend/docs/flows.md` "Mail outbox". - Push-транспорт для client_event-маршрутов: [Раздел 7](#7-канал-push). --- ## 9. Гео-сигнал Раздел описывает, что backend записывает о source IP аутентифицированного запроса и что он сознательно с этим не делает. ### 9.1 Состав В составе: one-shot declared_country при регистрации, fire-and-forget per-request country-counter, оператор-only read-endpoint. Вне состава: любой автоматический flagging, обнаружение account- takeover, geo-fencing, enforcement санкций, история версий. Гео-сигнал — это пассивная запись, не enforcement-механизм. ### 9.2 Что backend записывает При регистрации ([Раздел 1.3](#13-подтверждение-вызова)) backend ищет source IP в GeoLite2-country-базе и сохраняет полученный ISO-код страны на аккаунте. Это значение пишется ровно один раз на аккаунт; последующие входы из другой страны не перезаписывают его. При каждом аутентифицированном запросе через user-surface fire-and-forget-горутина выполняет тот же поиск против request IP и инкрементирует per-(user, country)-счётчик. Сам запрос никогда не блокируется этой работой; горутина запускается после возврата handler'а. Оба пути fail-open: ошибка geoip-поиска логируется, но никогда не блокирует пользователя. ### 9.3 Что backend НЕ делает - Никакой агрегации по пользователям. - Никакого автоматического flagging при смене страны. - Никаких уведомлений из geo-сигнала. - Никакой истории версий `declared_country`. - Никакой корреляции с санкциями, лимитами или entitlement. ### 9.4 Доступ оператора Admin-surface предоставляет один read-endpoint, перечисляющий per-user-country-счётчики. Данные предназначены для ручного inspect'а во время оператор-triage; UI-флоу поверх этого нет. ### 9.5 Дисциплина source IP Backend читает source IP из самого левого `X-Forwarded-For`- entry, откатываясь на connection peer, если заголовок отсутствует. Backend доверяет значению, потому что сетевой сегмент между gateway и backend — это платформенный trust boundary, edge уже саниТизировал его. Это сделано намеренно и переутверждено в [ARCHITECTURE.md §10](ARCHITECTURE.md#10-geo-profile-reduced) и [§16](ARCHITECTURE.md#16-security-boundaries-summary). E-mail-адреса никогда не пишутся в логи как есть. Backend пишет process-scoped HMAC-truncated hash, чтобы операторы могли скоррелировать log-строки внутри одного process lifetime без сохранения PII. ### 9.6 Перекрёстные ссылки - Обоснование trust-boundary: [ARCHITECTURE.md §10](ARCHITECTURE.md#10-geo-profile-reduced), [§15](ARCHITECTURE.md#15-transport-security-model-gateway-boundary), [§16](ARCHITECTURE.md#16-security-boundaries-summary). - Контракт one-shot-write при регистрации vs per-request-counter: [`backend/README.md` §11](../backend/README.md#11-geo-profile). --- ## 10. Администрирование Раздел описывает каждую admin-only-операцию. Многие из них упоминались в предыдущих разделах (admin-overrides для лобби, admin-soft-delete и санкции, mail- и notification-inspection); этот раздел — консолидированное вью. ### 10.1 Состав В составе: admin-аутентификация, cross-domain-admin-операции, их побочные эффекты на остальную часть платформы. Вне состава: end-user-флоу, разделяющие домен с admin-операцией — они в своём собственном разделе. ### 10.2 Аутентификация и bootstrap Admin-surface использует HTTP Basic Auth против backend-owned admin-account-таблицы; пароли хешированы bcrypt'ом. На старте, если bootstrap-admin-username и пароль сконфигурированы и таблица ещё не содержит строки с этим username, backend вставляет её. Insert идемпотентен: последующие рестарты ничего не делают. Failed Basic Auth-ответ запрашивает оператор-tooling за credentials стандартным способом; realm-строка фиксирована, чтобы password manager оператора мог матчить её через deployments. После первого деплоя bootstrap-пароль должен быть ротирован через admin-surface. ### 10.2.1 Операторская консоль (`/_gm`) Администраторы выполняют эти операции либо программно через JSON admin-API, либо через серверно-рендеримую веб-консоль на `/_gm`. Консоль аутентифицируется теми же Basic Auth-учётными данными: открытие любой страницы `/_gm` вызывает нативный диалог браузера для ввода учётных данных, и оператор остаётся залогинен на время сессии. Навигация — обычными ссылками и query-параметрами; каждое изменение отправляется формой и завершается редиректом обратно на затронутую страницу. Консоль — единственная admin-поверхность, достижимая извне доверенной сети. Она проксируется через gateway, поэтому наследует те же edge-rate-limiting и лимиты запросов, что и публичный API, и несёт анти-CSRF-токен на каждом изменении. JSON admin-API остаётся внутренним для деплоя. ### 10.3 Управление admin-аккаунтами Существующие админы могут перечислять других админов, создавать новых, искать конкретного, отключать или включать обратно админа, и сбрасывать пароль. Отключённая admin-строка не может аутентифицироваться; строка сохраняется, чтобы сохранить audit-references, а не удаляется. Reset-password принимает новый пароль в теле запроса. Backend bcrypt-хеширует его, заменяет `admin_accounts.password_hash` и возвращает обновлённую `AdminAccount`-форму — сам новый пароль никогда не возвращается. "Delivered out-of-band" поэтому означает: админ, инициирующий reset — тот, кто должен сообщить новое значение target-админу через какой-то канал вне платформы (защищённый мессенджер, голос и т.п.); платформа не email'ит и не auto-доставляет. ### 10.4 Администрирование пользователей Для любого user-аккаунта админ может: - перечислять и инспектировать аккаунты; - применить санкцию; - применить per-user-limit-override, корректирующий конкретную квоту; - обновить entitlement (план, paid-флаг, source, validity); - soft-delete'нуть аккаунт (тот же in-process-каскад, что и [Раздел 2.4](#24-удаление-аккаунта-пользователем)). Каталог санкций сознательно минимален в MVP: единственный поддерживаемый `sanction_code` — это `permanent_block`. Применение переключает `accounts.permanent_block`, отзывает все активные сессии ([Раздел 1.5](#15-отзыв)) и запускает тот же lobby-каскад, что и soft-delete, со membership-статусом `blocked` ([Раздел 2.4](#24-удаление-аккаунта-пользователем)). OpenAPI-схема кодирует это как закрытый enum, чтобы будущие добавления были явным breaking-изменением. Soft-delete всегда отзывает сессии; санкции отзывают только когда вид документирует этот побочный эффект (сегодня — только `permanent_block`). ### 10.5 Администрирование игр Админы создают public-игры, перечисляют и инспектируют любую игру, делают force-start или force-stop игре и баннят member'а. Force-stop сносит запущенный engine-контейнер для игры; ban-member добавляет пользователя в block-лист игры и удаляет любой активный membership ([Раздел 4.4](#44-членства)). Public-game-владение коллективное: строка несёт `owner_user_id IS NULL` и любой админ может действовать с ней. User-surface никогда не производит и не транзишнит public-игру. ### 10.6 Администрирование runtime Админы инспектируют runtime-запись для игры, рестартят engine- контейнер, патчат его image на более новый semver-патч в той же major / minor-линии и форсят one-shot-доп-тик хода. Patch сознательно ограничен patch-компонентом. Major- или minor- смена версии требует явного stop / start игры, не in-place-апгрейда. Регистрация версий движка и disable — рядом. ### 10.7 Реестр версий движка Реестр версий движка — источник разрешённых engine-image'ов. Поставщики (start, restart, patch) никогда не выбирают image- references сами; они читают из реестра. Disable версии — forward- looking-решение: существующие запущенные контейнеры держат свой текущий image до stop / start, но disabled-версия больше не eligible для новых стартов или патчей. ### 10.8 Администрирование почты и уведомлений Операторы могут перечислять и инспектировать mail-deliveries, attempts на delivery, dead-letters, notifications, notification- dead-letters и malformed notification-намерения. Они также могут переслать non-sent mail-delivery ([Раздел 8.4](#84-mail-outbox)). Эти вью — единственный путь к видимости почты и уведомлений вне телеметрии. ### 10.9 Администрирование geo Единственный geo admin-endpoint перечисляет per-user-country- счётчики ([Раздел 9.4](#94-доступ-оператора)). Admin-write- доступа к geo-данным нет; declared_country устанавливается раз при регистрации и не меняется, счётчики заполняются runtime'ом, а операторы могут только читать. ### 10.10 Перекрёстные ссылки - Контракт каскада soft-delete: [ARCHITECTURE.md §7](ARCHITECTURE.md#7-in-process-async-patterns). - Жизненный цикл контейнера и арбитраж версий: [ARCHITECTURE.md §13](ARCHITECTURE.md#13-container-lifecycle-in-process). - Mail outbox и notification-диспатчер: [ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox), [§12](ARCHITECTURE.md#12-notification-pipeline) и [Раздел 8](#8-уведомления-и-почта). --- ## 11. Дипломатическая почта Сценарий описывает обмен сообщениями между игроками одной партии и адресные / широковещательные уведомления от администрации и владельца партии. Подсистема концептуально часть лобби (сообщения переживают рестарты движка), но видна только внутри игрового UI; в лобби виден лишь счётчик непрочитанного. ### 11.1 Состав В составе: отправка персональной почты между активными участниками одной партии; ответы на персональную почту; чтение, отметка «прочитано» и soft-удаление своей входящей почты; адресные и широковещательные уведомления от админов и владельцев; платный broadcast от игроков; мультигеймовая admin-рассылка; ручная массовая чистка почты завершённых партий; авто-перевод тела сообщения на `preferred_language` получателя с кэшированием. Вне состава: чат вне партии, групповые чаты с участниками разных партий, вложения, редактирование / отзыв сообщения, end-to-end-шифрование. ### 11.2 Модель сообщения Каждая отправка порождает ровно одну строку в `diplomail_messages` плюс по одной строке на получателя в `diplomail_recipients`. Broadcast на N получателей — одно сообщение и N recipient-строк; строка перевода, если материализована, общая для всех получателей с одинаковым целевым языком. `diplomail_messages.kind` — закрытое множество `{personal, admin}`. Персональные сообщения допускают ответ (получатель отправляет новое персональное сообщение); admin-сообщения не предполагают ответа — это уведомления о смене состояния или операторском действии. `sender_kind` — это `{player, admin, system}` и определяет роль отправителя: игрок- владелец партии (admin-уведомление от owner), site-администратор (admin-уведомление от оператора) или собственно автомат лобби (`game.paused`, `game.cancelled`, `membership.removed`, `membership.blocked`). `broadcast_scope` фиксирует тип отправки: одному получателю (`single`), рассылка по одной партии (`game_broadcast`) или admin-рассылка по нескольким партиям (`multi_game_broadcast`). Получатели multi_game-рассылки видят отдельную, независимо удаляемую запись inbox в каждой адресованной партии. Снимки сохраняются прямо в строках сообщения и получателя: `game_name`, `sender_username`, `sender_ip` и на стороне получателя — `recipient_user_name`, `recipient_race_name` и `recipient_preferred_language`. Они переживают переименование партии, отзыв членства, soft-delete аккаунта и итоговый bulk-purge — admin observability отрисовывается корректно даже после исчезновения «живых» строк. Тела и subject — plain UTF-8 текст. Сервер не парсит, не санитайзит и не экранирует HTML; клиент рендерит тело через `textContent`. Максимум размера тела — `BACKEND_DIPLOMAIL_MAX_BODY_BYTES` (по умолчанию `4096`); максимум для subject — `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (по умолчанию `256`). ### 11.3 Отправка почты Персональная отправка требует активного членства в партии и от отправителя, и от получателя. Игроки free-tier отправляют одно персональное сообщение за запрос. Игрокам платных тиров доступен и игровой broadcast — одна отправка на всех остальных активных участников партии; ответы возвращаются автору broadcast. Владельцы (приватных партий) и site-администраторы отправляют admin-уведомления. Endpoint владельца находится на user-поверхности (аутентификация по `X-User-ID`, проверка владельца в обработчике); endpoint администратора — на admin-поверхности (HTTP Basic). Оба принимают `target=user` (один получатель) или `target=all` (broadcast в одной партии). Site-администратору доступен дополнительный multi-game endpoint, принимающий `scope=selected` со списком game_id или `scope=all_running` — перебор всех партий в нетерминальных состояниях. Состав получателей broadcast параметризуется полем `recipients`: `active` (по умолчанию), `active_and_removed` или `all_members` (включает блокированных, для аудит-уведомлений). Собственная recipient-строка автора broadcast не создаётся. Player-broadcast от free-tier пользователя отклоняется кодом `403 forbidden`. ### 11.4 Получение почты Получатель видит сообщение в своём inbox только после того, как асинхронный worker перевода обработал его (см. [§11.6](#116-перевод)). До этого строка невидима: не выводится в inbox-листинге, не учитывается в badge непрочитанного, push-событие не доставляется. Это исключает ситуацию «строка появилась, перевод не подъехал, badge мигает». Badge непрочитанного в лобби агрегируется по партиям. Endpoint `/api/v1/user/lobby/mail/unread-counts` возвращает по одной записи на каждую партию с ненулевым unread плюс общий total; UI лобби отображает общий badge и плитки по партиям, не раскрывая самих сообщений. Mark-read идемпотентен. Soft-удаление требует, чтобы сообщение уже было помечено прочитанным — клиент не может стереть неоткрытое сообщение. Soft-удаление действует только для одного получателя: строка самого сообщения переживает удаление вплоть до admin bulk-purge всей почты соответствующей партии. Ответ message-detail содержит и оригинальное тело, и (если есть кэш) перевод; UI по умолчанию показывает перевод и предлагает переключение «показать оригинал». Внутриигровой UI группирует личную почту по веткам по расам — каждая личная переписка между локальным игроком и другой расой оказывается в одной ветке, ключевая по расе собеседника. Системные сообщения, административные уведомления и собственные рассылки игрока (платный тариф) показываются отдельными автономными записями в том же списке и никогда не группируются. `read_at` и `deleted_at` поддерживают локальный счётчик непрочитанного и кнопку удаления, но не показываются игроку — дипломатическая почта не обещает уведомления о прочтении. Форма compose выбирает получателя по имени расы (сервер резолвит через `Memberships.ListMembers(game_id, "active")`); клиент не тянет отдельный список членов. Подробнее — в [`ui/docs/diplomail-ui.md`](../ui/docs/diplomail-ui.md). ### 11.5 Хуки жизненного цикла Три транзитных перехода в лобби порождают system mail в inbox затронутых игроков: - **Пауза / отмена игры.** Когда автомат партии проходит через `paused` или `cancelled`, лобби эмитит system-сообщение всем активным членам. Текст рендерится сервером по шаблону, чтобы игрок, открывший inbox позже, нашёл объяснение даже без одновременной push-сессии. - **Удаление / блокировка членства.** Сам-выход, удаление владельцем и admin-бан порождают system-сообщение только для затронутого игрока. Это письмо переживает переход членства в `removed` / `blocked` — игрок сохраняет к нему read-доступ навсегда (правило soft-доступа). Будущее удаление по неактивности должно вызывать тот же publisher, чтобы объяснение дошло до затронутого игрока; README пакета прибивает этот контракт для следующего реализатора. ### 11.6 Перевод `diplomail_messages.body_lang` заполняется на стороне сервера в момент отправки внутрипроцессным детектором языка, работающим только по телу. Subject наследует язык тела для ключа кэша перевода. Когда детектор не может уверенно классифицировать тело (слишком короткое, пустое, смешанные скрипты), значение — плейсхолдер BCP 47 `und` ("неопределённый"), и pipeline перевода обходится стороной — получатели видят оригинал. Перевод выполняется асинхронно. Каждая recipient-строка содержит снимок `preferred_language` получателя плюс метку `available_at`. Получатель, чей язык совпадает с детектированным `body_lang` (или чей preferred_language пуст / язык тела — `und`), получает `available_at = now()` сразу при вставке, и push-событие отправляется в момент `POST`. Получатель с отличающимся языком вставляется с `available_at IS NULL` и ждёт worker. Worker (`internal/diplomail.Worker`) тикает каждые `BACKEND_DIPLOMAIL_WORKER_INTERVAL` (по умолчанию `2s`) и обрабатывает по одной паре `(message_id, target_lang)` за тик. Он сначала смотрит в кэш переводов; на miss дёргает настроенный `Translator`. Дефолт production-сборки — LibreTranslate HTTP клиент; пустой `BACKEND_DIPLOMAIL_TRANSLATOR_URL` оставляет noop-translator, который доставляет сообщение в оригинале. Исходы перевода: - **Успех.** Строка в `diplomail_translations` создаётся (или переиспользуется, если параллельная попытка успела раньше), все pending-получатели пары переключаются на `available_at = now()`, и по каждому отправляется push. - **Неподдерживаемая пара языков** (HTTP 400 от LibreTranslate). Строка перевода не сохраняется; получатели доставляются с оригинальным телом. Последующие чтения возвращают оригинал. - **Транзитный сбой** (timeout, 5xx, network error). Счётчик попыток увеличивается, следующая попытка планируется по экспоненциальному backoff `1s → 2s → 4s → 8s → 16s` (с потолком 60s). После `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (по умолчанию `5`) worker fallback'ит на оригинальное тело. Длительный отказ переводчика тормозит доставку максимум на ~30 секунд на пару до того, как получатель увидит оригинал. Кэш переводов общий: broadcast на N получателей с одинаковым preferred_language порождает одну строку кэша и один вызов переводчика, не N. ### 11.7 Хранение и purge Сообщения живут в `diplomail_messages`; per-recipient state — в `diplomail_recipients` с FK-каскадом на сообщение; переводы — в `diplomail_translations` тоже с каскадом. IP-адрес отправителя снимается из `X-Forwarded-For` (форвардит gateway) и хранится в сообщении для сохранения доказательств. Автоматического retention нет. Admin bulk-purge endpoint удаляет все сообщения, чья партия завершилась более `older_than_years` лет назад (минимум `1`); каскад удаляет recipient- и translation-строки той же транзакцией. ### 11.8 Видимость для оператора Admin-поверхность экспонирует постраничный листинг всех сообщений (`/api/v1/admin/mail/messages`) с фильтрами по `game_id`, `kind` и `sender_kind`. Bulk-purge endpoint (`/api/v1/admin/mail/cleanup`) принимает порог `older_than_years`. Per-game admin-отправки и multi-game broadcast'ы доступны через `/api/v1/admin/games/{game_id}/mail` и `/api/v1/admin/mail/broadcast`. ### 11.9 Перекрёстные ссылки - Обзор пакета и карта стадий: [`backend/internal/diplomail/README.md`](../backend/internal/diplomail/README.md). - Рецепт развёртывания LibreTranslate для локальной разработки: [`backend/docs/diplomail-translator-setup.md`](../backend/docs/diplomail-translator-setup.md). - Детали хранения: [ARCHITECTURE.md §12.1](ARCHITECTURE.md#121-diplomatic-mail-subsystem). - Push-транспорт для событий доставки: [Раздел 7](#7-канал-push). - Notification-каталог: kind `diplomail.message.received`: [`backend/README.md` §10](../backend/README.md#10-notification-catalog).