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>
72 KiB
Функциональная спецификация 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-операции могут оказаться в одном разделе, если все они участвуют в одном бизнес-флоу.
Содержание
- Аутентификация и устройство-сессия
- Управление аккаунтом
- Жизненный цикл игры в лобби
- Участие в лобби
- Реестр названий рас
- Игровая сессия
- Канал push
- Уведомления и почта
- Гео-сигнал
- Администрирование
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 Перекрёстные ссылки
- Admin-аналоги (sanction, limit, entitlement, soft delete): Раздел 10.
- Контракт каскада "user blocked / user deleted": ARCHITECTURE.md §7.
- Виды уведомлений, эмитящихся каскадом:
backend/README.md§10.
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-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
описывает веер.
4.7 Перекрёстные ссылки
- Жизненный цикл игры: Раздел 3.
- Каталог уведомлений и веер: Раздел 8
и
backend/README.md§10.
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.ready—lobby.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; виды, ещё не мигрировавшие, используют fallbackpush.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 Перекрёстные ссылки
- Обоснование trust-boundary: ARCHITECTURE.md §10, §15, §16.
- Контракт one-shot-write при регистрации vs per-request-counter:
backend/README.md§11.
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 Перекрёстные ссылки
- Контракт каскада soft-delete: ARCHITECTURE.md §7.
- Жизненный цикл контейнера и арбитраж версий: ARCHITECTURE.md §13.
- Mail outbox и notification-диспатчер: ARCHITECTURE.md §11, §12 и Раздел 8.