Files
galaxy-game/docs/FUNCTIONAL_ru.md
T
Ilia Denisov 2ca47eb4df ui/phase-25: backend turn-cutoff guard + auto-pause + UI sync protocol
Backend now owns the turn-cutoff and pause guards the order tab
relies on: the scheduler flips runtime_status between
generation_in_progress and running around every engine tick, a
failed tick auto-pauses the game through OnRuntimeSnapshot, and a
new game.paused notification kind fans out alongside
game.turn.ready. The user-games handlers reject submits with
HTTP 409 turn_already_closed or game_paused depending on the
runtime state.

UI delegates auto-sync to a new OrderQueue: offline detection,
single retry on reconnect, conflict / paused classification.
OrderDraftStore surfaces conflictBanner / pausedBanner runes,
clears them on local mutation or on a game.turn.ready push via
resetForNewTurn. The order tab renders the matching banners and
the new conflict per-row badge; i18n bundles cover en + ru.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 22:00:16 +02:00

72 KiB
Raw Blame History

Функциональная спецификация Galaxy

Документ описывает, что делает платформа Galaxy в терминах пользовательских операций и логики каждого сервиса, которая их реализует. Каждый раздел проводит читателя по одному доменному сценарию: кто инициирует операцию, что gateway проверяет и форвардит, что backend валидирует и сохраняет, что возвращается клиенту, и какие побочные эффекты при этом запускаются (почта, push, операции с контейнерами).

Это отправная точка для любого изменения, затрагивающего поведение системы. Точные форматы протоколов, словарь кодов ошибок, переменные окружения, значения по умолчанию, лимиты троттлинга, имена таблиц и колонок, field-level-валидация — всё это лежит в нижнеуровневых источниках:

  • ARCHITECTURE.md — глобальная архитектура, модель безопасности, транспортный контракт.
  • galaxy/<service>/README.md — структура сервиса, конфигурация, эксплуатация.
  • galaxy/<service>/openapi.yaml, *.proto — wire-контракты.
  • galaxy/<service>/docs/flows.md — sequence-диаграммы.

Этот файл сознательно опускает такие детали. Если этот файл расходится с нижнеуровневым источником, см. правило синхронизации в проектном CLAUDE.md.

Внимание. Этот файл — перевод английского FUNCTIONAL.md и не является источником истины. Авторитетна английская версия; при расхождении выигрывает она. Каждое точечное изменение в FUNCTIONAL.md должно быть зеркально внесено сюда в том же патче (переводить только затронутые абзацы). Полный перевод заново выполняется только по явному запросу владельца проекта.

Документ организован по доменным сценариям, не по группам HTTP-маршрутов. Публичные, user-аутентифицированные и admin-операции могут оказаться в одном разделе, если все они участвуют в одном бизнес-флоу.

Содержание

  1. Аутентификация и устройство-сессия
  2. Управление аккаунтом
  3. Жизненный цикл игры в лобби
  4. Участие в лобби
  5. Реестр названий рас
  6. Игровая сессия
  7. Канал push
  8. Уведомления и почта
  9. Гео-сигнал
  10. Администрирование

1. Аутентификация и устройство-сессия

Раздел описывает, как анонимный клиент становится аутентифицированным и остаётся таковым, пока серверное действие не отзовёт эти полномочия.

1.1 Состав

В составе: выпуск e-mail-вызова на вход, его подтверждение (с созданием аккаунта при первом входе и регистрацией публичного ключа клиента), создание устройства-сессии, поиск сессии для каждого аутентифицированного запроса, отзыв сессии со стороны сервера.

Вне состава: формат конверта и схема подписи, которые используются каждым аутентифицированным запросом — определены однажды в ARCHITECTURE.md §15 и переиспользуются всеми последующими разделами; хранение ключа на стороне клиента; маршрутизация 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 и форвардит проверенный 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.
  • Зоны ответственности backend-модулей auth, user, geo, mail, push: ARCHITECTURE.md §4 и backend/README.md.
  • Семантика mail outbox для шаблона auth login-code: ARCHITECTURE.md §11.
  • Фрейминг push-канала и правила переподключения: ARCHITECTURE.md §8. Пользовательская семантика push — в Разделе 7 этого документа.

2. Управление аккаунтом

Раздел описывает, что аутентифицированный пользователь может читать или менять в своём аккаунте и как удалить аккаунт.

2.1 Состав

В составе: чтение агрегата аккаунта, обновление мутабельного слайса профиля, обновление настроек (preferred_language, time_zone, declared_country), пользовательский soft-delete.

Вне состава: admin-side-мутации того же аккаунта (санкции, лимиты, изменения entitlement, admin-soft-delete) — описаны в Разделе 10. Переключение permanent_block — только для админов.

2.2 Агрегат аккаунта

Backend предоставляет один read-endpoint, который возвращает агрегат аккаунта вызывающего: durable-идентифицирующие поля (неизменяемый display-handle, e-mail), мутабельные слайсы profile и settings, текущий снимок entitlement, любые активные санкции и per-user-overrides лимитов. Агрегат — авторитетный клиентский взгляд "что платформа обо мне знает".

Display-handle синтезируется при первом входе (Раздел 1.3) и никогда не перезаписывается ни при последующих входах, ни при апдейтах профиля. Клиенты должны относиться к нему как к стабильному идентификатору, а не как к 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) и считает неизменяемым после; нет user-видимого пути его изменить.

2.4 Удаление аккаунта пользователем

Пользователь может попросить backend soft-delete'нуть свой аккаунт. Backend помечает строку аккаунта удалённой и запускает in-process-каскад, описанный в ARCHITECTURE.md §7. Конкретно:

  • Каждая устройство-сессия пользователя отзывается (Раздел 1.5) — одна 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 Перекрёстные ссылки


3. Жизненный цикл игры в лобби

Раздел описывает жизнь одной игры от создания до терминального состояния. Раздел 4 описывает, как игроки присоединяются к существующей игре; этот раздел сосредоточен на самой игре.

3.1 Состав

В составе: создание игры (private или public), обновление её мутабельной конфигурации, переходы по машине состояний лобби, отмена, повтор failed-старта, терминальные переходы (finished, cancelled).

Вне состава: заявки, приглашения, membership'ы (Раздел 4), Race Name Directory-промоушен при завершении (Раздел 5), engine-команды во время running-фазы (Раздел 6).

3.2 Машина состояний

Машина состояний лобби — закрытый граф, описанный в ARCHITECTURE.md §7:

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). 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). Оба терминальных состояния поглощающие.

3.6 Админские оверрайды

Администраторы могут делать force-start, force-stop и ban-member на любой игре (public или private), независимо от состояния. force-stop переводит игру в stopped-состояние и сносит engine-контейнер; ban-member удаляет membership и запрещает пользователю снова присоединиться (Раздел 4).

3.7 Перекрёстные ссылки

  • Словарь машины состояний и правила переходов: ARCHITECTURE.md §7.
  • Жизненный цикл runtime-job (асинхронная работа за start): ARCHITECTURE.md §13 и backend/docs/flows.md.
  • Public-vs-private-инварианты и поддерживающий их частичный индекс: ARCHITECTURE.md §4.

4. Участие в лобби

Раздел описывает всё, что связано с присоединением и выходом из существующей игры: заявки (для public), приглашения (для private) и membership'ы (после успешного присоединения).

4.1 Состав

В составе: подача заявки в public-игру, одобрение / отклонение заявки владельцем или админом, выпуск и активация приглашений, отказ получателя и отзыв выпустившим, листинг membership'ов для игры, удаление или блокировка участника.

Вне состава: сама машина состояний игры (Раздел 3) и in-game-команды, когда участник уже играет (Раздел 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-basedinvited_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 описывает веер.

4.7 Перекрёстные ссылки


5. Реестр названий рас

Раздел описывает, как игрок выбирает имя своей in-game-расы и в итоге получает это имя зарегистрированным платформенно.

5.1 Состав

В составе: трёхуровневый реестр (registered, reservation, pending_registration), промоушен через "capable finish", пользовательский промоушен pending_registration в registered, sweeper-релиз по истечению TTL, уникальность через canonical-key- модель.

Вне состава: как движок реально потребляет выбранное имя — это живёт в Разделе 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 описывает веер.

5.6 Перекрёстные ссылки

  • Библиотека канонизации и записи глоссария ("canonical key", "capable finish"): ARCHITECTURE.md §19.
  • Триггер промоушена внутри lobby-модуля: ARCHITECTURE.md §7 (lobby.OnGameFinished) и backend/docs/flows.md.

6. Игровая сессия

Раздел описывает, что делает активный игрок, пока идёт игра: посылает команды и приказы, читает отчёты по ходам.

6.1 Состав

В составе: подача команд, подача приказов, чтение отчёта, turn-cutoff-поведение, которое закрывает окно команд во время генерации.

Вне состава: как сам engine-контейнер запускается, планируется или останавливается — это runtime-вопросы, описанные в Разделе 3 (start / stop) и Разделе 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 (на мобильном — <select>); позиция скролла сохраняется при переключении активного представления через SvelteKit Snapshot API.

6.5 Побочные эффекты

Успешная генерация хода публикует runtime-snapshot в lobby-модуль, который обновляет денормализованное вью (текущий ход, runtime- status, per-player-stats). Engine-отчёт "game finished" гонит переход running → finished (Раздел 3.5) и триггерит Race Name Directory-промоушен (Раздел 5).

Из game.*-видов уведомлений подключены game.turn.ready и game.paused:

  • game.turn.readylobby.Service.OnRuntimeSnapshot (backend/internal/lobby/runtime_hooks.go) выпускает один intent на каждое увеличение current_turn, адресуя его всем активным membership-ам игры, с idempotency-ключом turn-ready:<game_id>:<turn> и JSON-payload-ом {game_id, turn}.
  • game.paused — тот же хук публикует один intent на каждое выставление статуса paused по runtime-снапшоту (engine_unreachable / generation_failed), адресуя его всем активным membership-ам игры, с idempotency-ключом paused:<game_id>:<turn> и JSON-payload-ом {game_id, turn, reason}. reason несёт runtime-статус, спровоцировавший переход, чтобы UI смог в будущем дифференцировать копию.

Оба вида направляются только в push-канал; email-фан-аут сознательно опущен, чтобы избежать спама на каждом ходе/паузе.

Остальные game.*-виды (game.started, game.generation.failed, game.finished) и mail.dead_lettered зарезервированы без поставщика; добавление поставщика чисто аддитивное (зарегистрировать вид в каталоге, расширить CHECK-констрейнт миграции и вызвать notification.Submit из подходящего доменного модуля).

6.6 Перекрёстные ссылки

  • Backend ↔ engine wire-контракт (pkg/model/{order,report,rest}): ARCHITECTURE.md §9.
  • Жизненный цикл контейнера, дисциплина меток, согласование: ARCHITECTURE.md §13 и backend/docs/flows.md.

7. Канал push

Раздел описывает, как платформа пушит real-time-события аутентифицированным клиентам (turn-ready-сигналы, изменения состояния лобби, инвалидации сессий).

7.1 Состав

В составе: server-streaming-подписка, которую клиент открывает к gateway (Connect / gRPC / gRPC-Web фреймы все маршрутизируются на одну точку), bootstrap-событие, фрейминг форварднутых событий, control-канал backend → gateway, который производит эти события.

Вне состава: каталог видов событий — см. Раздел 8 для notification-стороны и backend/README.md §10 для закрытого списка.

7.2 Подписка клиента

Аутентифицированный клиент открывает server-streaming-вызов SubscribeEvents на gateway. Gateway проводит ту же envelope- проверку, что и для unary-запросов (Раздел 1.4), затем регистрирует стрим в своём внутреннем хабе. Первый фрейм, получаемый клиентом — это 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.

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); потерянное push-событие не значит, что платформа забыла событие.

7.5 Поставщики

Backend-поставщики, эмитящие в push-канал, — это: notification- диспатчер (push-маршруты из каталога) и session-модуль (события отзыва). Никакой другой доменный модуль не эмитит client-события, кроме notification-диспатчера.

7.6 Перекрёстные ссылки

  • Wire-конверт, используемый для push-фреймов: ARCHITECTURE.md §15.
  • Семантика переподключения и ring-буфера: ARCHITECTURE.md §8 и backend/docs/flows.md "Push gRPC".
  • Notification-диспатчер: Раздел 8.

8. Уведомления и почта

Раздел описывает, как платформа сообщает пользователю о событии через push или e-mail (или оба).

8.1 Состав

В составе: подача notification-намерения, веер по push- и email- каналам, durable mail outbox, dead-letter-обработка, оператор- инициированный resend.

Вне состава: per-event-семантика — когда срабатывает каждый вид, описано в соответствующем feature-разделе (Раздел 4 для lobby-видов, Раздел 5 для race-name-видов, Раздел 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). 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.
  • Внутренности mail outbox (таблицы, лог попыток, worker pickup): ARCHITECTURE.md §11 и backend/docs/flows.md "Mail outbox".
  • Push-транспорт для client_event-маршрутов: Раздел 7.

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) 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 и §16.

E-mail-адреса никогда не пишутся в логи как есть. Backend пишет process-scoped HMAC-truncated hash, чтобы операторы могли скоррелировать log-строки внутри одного process lifetime без сохранения PII.

9.6 Перекрёстные ссылки


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).

Каталог санкций сознательно минимален в MVP: единственный поддерживаемый sanction_code — это permanent_block. Применение переключает accounts.permanent_block, отзывает все активные сессии (Раздел 1.5) и запускает тот же lobby-каскад, что и soft-delete, со membership-статусом blocked (Раздел 2.4). OpenAPI-схема кодирует это как закрытый enum, чтобы будущие добавления были явным breaking-изменением. Soft-delete всегда отзывает сессии; санкции отзывают только когда вид документирует этот побочный эффект (сегодня — только permanent_block).

10.5 Администрирование игр

Админы создают public-игры, перечисляют и инспектируют любую игру, делают force-start или force-stop игре и баннят member'а. Force-stop сносит запущенный engine-контейнер для игры; ban-member добавляет пользователя в block-лист игры и удаляет любой активный membership (Раздел 4.4).

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).

Эти вью — единственный путь к видимости почты и уведомлений вне телеметрии.

10.9 Администрирование geo

Единственный geo admin-endpoint перечисляет per-user-country- счётчики (Раздел 9.4). Admin-write- доступа к geo-данным нет; declared_country устанавливается раз при регистрации и не меняется, счётчики заполняются runtime'ом, а операторы могут только читать.

10.10 Перекрёстные ссылки