# Функциональная спецификация 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 Окно хода и 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.5). 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. Якоря секций отображены в sticky-TOC (на мобильном — `