# Функциональная спецификация 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-администрирование) --- ## 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`, с конфигурацией из тела запроса в качестве начальных значений. 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.command`, `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- семантика живёт в движке. ### 6.3 Окно хода Запущенная игра постоянно чередуется между окном приёма команд и фазой генерации. Переход `running → generation_in_progress` — cutoff: любая команда или приказ, пришедшие после cutoff, отклоняются backend до форварда, потому что движок больше не принимает запись для закрывающегося хода. После окончания генерации backend заново открывает окно для следующего хода. `force-next-turn` (admin) планирует one-shot-доп-тик, который сдвигает следующий запланированный ход на один cron-шаг. ### 6.4 Отчёты Per-turn-отчёты — read-only-вью, забираемые из движка по запросу. Backend авторизует вызывающего и форвардит запрос; в этом пути нет ни кэширования, ни денормализации. Web-клиент рендерит отчёт как одну секцию на каждый FBS-массив (общие сведения, голоса, статус игроков, мои / чужие науки, мои / чужие классы кораблей, сражения, бомбардировки, приближающиеся группы, мои / чужие / необитаемые / неопознанные планеты, корабли в производстве, грузовые маршруты, мои флоты, мои / чужие / неопознанные группы кораблей). Пустые секции получают явную копию empty-state. Якоря секций отображены в sticky-TOC (на мобильном — `