Files
galaxy-game/docs/FUNCTIONAL_ru.md
T
2026-05-07 00:58:53 +03:00

1072 lines
67 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Функциональная спецификация Galaxy
Документ описывает, что делает платформа Galaxy в терминах пользовательских
операций и логики каждого сервиса, которая их реализует. Каждый раздел
проводит читателя по одному доменному сценарию: кто инициирует операцию,
что `gateway` проверяет и форвардит, что `backend` валидирует и сохраняет,
что возвращается клиенту, и какие побочные эффекты при этом запускаются
(почта, push, операции с контейнерами).
Это отправная точка для любого изменения, затрагивающего поведение системы.
Точные форматы протоколов, словарь кодов ошибок, переменные окружения,
значения по умолчанию, лимиты троттлинга, имена таблиц и колонок,
field-level-валидация — всё это лежит в нижнеуровневых источниках:
- [`ARCHITECTURE.md`](ARCHITECTURE.md) — глобальная архитектура,
модель безопасности, транспортный контракт.
- `galaxy/<service>/README.md` — структура сервиса, конфигурация,
эксплуатация.
- `galaxy/<service>/openapi.yaml`, `*.proto` — wire-контракты.
- `galaxy/<service>/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
(выводится из опционального заголовка `Accept-Language`,
форварднутого gateway, с откатом на дефолт) и в той же транзакции
ставит auth-mail-строку прямо в outbox. SMTP-доставка асинхронна;
auth-ответ возвращается, как только строки challenge и outbox
durably закоммитены.
### 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 Поиск сессии для каждого запроса
Когда у клиента есть идентификатор устройства-сессии и приватный ключ,
каждый аутентифицированный вызов — это подписанный gRPC-запрос к
gateway. 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 с авторизацией
Signed-gRPC-конвейер для in-game-трафика использует три message
types на аутентифицированной поверхности — `user.games.command`,
`user.games.order`, `user.games.report` — у каждого типизированный
FlatBuffers-payload. Gateway транскодирует FB-запрос в JSON-форму,
которую ждёт backend, форвардит её REST'ом в соответствующий
`/api/v1/user/games/{game_id}/*` endpoint, после чего транскодирует
JSON-ответ обратно в FB перед подписью.
Для каждого 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 авторизует вызывающего и форвардит запрос; в этом пути
нет ни кэширования, ни денормализации.
### 6.5 Побочные эффекты
Успешная генерация хода публикует runtime-snapshot в lobby-модуль,
который обновляет денормализованное вью (текущий ход, runtime-
status, per-player-stats). Engine-отчёт "game finished" гонит
переход `running → finished`
([Раздел 3.5](#35-отмена-и-завершение)) и триггерит Race Name
Directory-промоушен ([Раздел 5](#5-реестр-названий-рас)).
`game.*`-виды уведомлений (`game.started`, `game.turn.ready`,
`game.generation.failed`, `game.finished`) зарезервированы в
документации, но **не имеют поставщика** в кодовой базе сегодня;
notification-каталог явно их опускает
(`backend/internal/notification/catalog.go`). Добавление поставщика
аддитивно: зарегистрировать вид в каталоге, заполнить
`MailTemplateID`, если нужен email-веер, и заставить нужный
доменный модуль вызвать `notification.Submit`.
### 6.6 Перекрёстные ссылки
- 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 Состав
В составе: gRPC-стрим, который клиент открывает к gateway,
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-событие
с текущим серверным временем, чтобы клиент мог калибровать свои
локальные часы без отдельного запроса.
### 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.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-уведомления-и-почта).