Files
galaxy-game/docs/FUNCTIONAL_ru.md
T
Ilia Denisov c48bc83890
Tests · UI / test (pull_request) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m46s
Tests · Go / test (pull_request) Successful in 2m4s
Phase 28 (Step 10): docs — diplomail UI topic + FUNCTIONAL mirror
- `ui/docs/diplomail-ui.md`: new topic doc covering the wire
  surface, recipient-by-race-name decision, threading model,
  translation toggle, push events, badge, layout, and
  accessibility.
- `docs/FUNCTIONAL.md` §11.4 grows a paragraph that records the
  UI's per-race threading rule, the absent read-receipt UX, and
  the recipient-by-race-name compose path. Mirrored verbatim into
  `docs/FUNCTIONAL_ru.md`.
- `ui/PLAN.md` Phase 28 marked done with a "Decisions during
  stage" block matching the implementation plan, and the artifact
  list updated to the actual file set.

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

93 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. Администрирование
  11. Дипломатическая почта

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

Секция бомбардировок — это плоская read-only-таблица: одна строка на событие, колонки attacker, attack_power, признак wiped и ресурсный снимок после удара. Секция сражений — список ссылок в Battle Viewer (см. §6.5).

6.5 Battle viewer

Battle Viewer — отдельное представление, заменяющее карту и показывающее одну битву. Входы:

  • Строка в секции «сражения» в Reports (ссылка с пиннингом текущего хода через ?turn=).
  • Battle-marker на карте (жёлтый крест через противоположные углы квадрата, описанного вокруг круга планеты; толщина линий растёт с длиной протокола).

Сам Viewer — логически изолированный компонент, потребляющий BattleReport в форме pkg/model/report/battle.go. Страница-обёртка (ui/frontend/src/lib/active-view/battle.svelte) забирает отчёт через backend-маршрут GET /api/v1/user/games/{game_id}/battles/{turn}/{battle_id}, который проксирует ответ engine-эндпоинта GET /api/v1/battle/:turn/:uuid.

Визуальная модель — радиальная: планета в центре, расы по внешней окружности на равных угловых интервалах, внутри расы — облако кружков по классам кораблей, выложенное Vogel-спиралью с биасом к планете (самая многочисленная группа по NumberLeft — ближе к планете, остальные раскручиваются спиралью позади). Tech-варианты одного (race, className) схлопываются в один визуальный нод <className>:<numLeft>; детали по тех-уровням остаются в Reports. Радиус кружка масштабируется по FullMass корабля (диапазон [6, 24] px, нормировка на самую тяжёлую группу в битве), так что тяжёлые корабли визуально доминируют. Наблюдатели (inBattle: false) не рисуются. Выбывшие расы убираются из сцены, оставшиеся перераспределяются на следующем кадре. Viewer закреплён по высоте viewport-а: сцена растягивается, лог скроллит внутри — никаких скроллов на уровне страницы.

Каждый кадр — одна запись протокола; выстрел рисуется тонкой линией от атакующего к защитнику, красной при destroyed, зелёной иначе. Непрерывное воспроизведение: 1x / 2x / 4x (400 / 200 / 100 мс на кадр), плюс play/pause, шаг вперёд/назад, rewind. Текстовый протокол доступности под сценой дублирует те же события построчно.

Бомбардировки и сражения умышленно не смешиваются: бомбардировки остаются статической таблицей в Reports; bombing-marker на карте — тонкая окружность вокруг планеты (жёлтая при damaged, красная при wiped), клик скроллит соответствующую строку в Reports.

Текущая wire-форма отчёта несёт battle: [{ id, planet, shots }] на каждую битву, чтобы map-маркеры могли расположиться без дополнительного запроса полного BattleReport.

Для DEV / e2e легаси-CLI (tools/local-dev/legacy-report/cmd/legacy-report-to-json) выдаёт envelope {version: 1, report, battles}, где battles несёт полные BattleReport-ы, распарсенные из Battle at (#N)-блоков. Synthetic- загрузчик в лобби разбирает envelope и регистрирует каждую битву через registerSyntheticBattle, так что Battle Viewer открывает любой UUID без сетевого запроса.

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

Успешная генерация хода публикует 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.7 Перекрёстные ссылки

  • 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 Перекрёстные ссылки


11. Дипломатическая почта

Сценарий описывает обмен сообщениями между игроками одной партии и адресные / широковещательные уведомления от администрации и владельца партии. Подсистема концептуально часть лобби (сообщения переживают рестарты движка), но видна только внутри игрового UI; в лобби виден лишь счётчик непрочитанного.

11.1 Состав

В составе: отправка персональной почты между активными участниками одной партии; ответы на персональную почту; чтение, отметка «прочитано» и soft-удаление своей входящей почты; адресные и широковещательные уведомления от админов и владельцев; платный broadcast от игроков; мультигеймовая admin-рассылка; ручная массовая чистка почты завершённых партий; авто-перевод тела сообщения на preferred_language получателя с кэшированием.

Вне состава: чат вне партии, групповые чаты с участниками разных партий, вложения, редактирование / отзыв сообщения, end-to-end-шифрование.

11.2 Модель сообщения

Каждая отправка порождает ровно одну строку в diplomail_messages плюс по одной строке на получателя в diplomail_recipients. Broadcast на N получателей — одно сообщение и N recipient-строк; строка перевода, если материализована, общая для всех получателей с одинаковым целевым языком.

diplomail_messages.kind — закрытое множество {personal, admin}. Персональные сообщения допускают ответ (получатель отправляет новое персональное сообщение); admin-сообщения не предполагают ответа — это уведомления о смене состояния или операторском действии. sender_kind — это {player, admin, system} и определяет роль отправителя: игрок- владелец партии (admin-уведомление от owner), site-администратор (admin-уведомление от оператора) или собственно автомат лобби (game.paused, game.cancelled, membership.removed, membership.blocked).

broadcast_scope фиксирует тип отправки: одному получателю (single), рассылка по одной партии (game_broadcast) или admin-рассылка по нескольким партиям (multi_game_broadcast). Получатели multi_game-рассылки видят отдельную, независимо удаляемую запись inbox в каждой адресованной партии.

Снимки сохраняются прямо в строках сообщения и получателя: game_name, sender_username, sender_ip и на стороне получателя — recipient_user_name, recipient_race_name и recipient_preferred_language. Они переживают переименование партии, отзыв членства, soft-delete аккаунта и итоговый bulk-purge — admin observability отрисовывается корректно даже после исчезновения «живых» строк.

Тела и subject — plain UTF-8 текст. Сервер не парсит, не санитайзит и не экранирует HTML; клиент рендерит тело через textContent. Максимум размера тела — BACKEND_DIPLOMAIL_MAX_BODY_BYTES (по умолчанию 4096); максимум для subject — BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES (по умолчанию 256).

11.3 Отправка почты

Персональная отправка требует активного членства в партии и от отправителя, и от получателя. Игроки free-tier отправляют одно персональное сообщение за запрос. Игрокам платных тиров доступен и игровой broadcast — одна отправка на всех остальных активных участников партии; ответы возвращаются автору broadcast.

Владельцы (приватных партий) и site-администраторы отправляют admin-уведомления. Endpoint владельца находится на user-поверхности (аутентификация по X-User-ID, проверка владельца в обработчике); endpoint администратора — на admin-поверхности (HTTP Basic). Оба принимают target=user (один получатель) или target=all (broadcast в одной партии). Site-администратору доступен дополнительный multi-game endpoint, принимающий scope=selected со списком game_id или scope=all_running — перебор всех партий в нетерминальных состояниях.

Состав получателей broadcast параметризуется полем recipients: active (по умолчанию), active_and_removed или all_members (включает блокированных, для аудит-уведомлений). Собственная recipient-строка автора broadcast не создаётся.

Player-broadcast от free-tier пользователя отклоняется кодом 403 forbidden.

11.4 Получение почты

Получатель видит сообщение в своём inbox только после того, как асинхронный worker перевода обработал его (см. §11.6). До этого строка невидима: не выводится в inbox-листинге, не учитывается в badge непрочитанного, push-событие не доставляется. Это исключает ситуацию «строка появилась, перевод не подъехал, badge мигает».

Badge непрочитанного в лобби агрегируется по партиям. Endpoint /api/v1/user/lobby/mail/unread-counts возвращает по одной записи на каждую партию с ненулевым unread плюс общий total; UI лобби отображает общий badge и плитки по партиям, не раскрывая самих сообщений.

Mark-read идемпотентен. Soft-удаление требует, чтобы сообщение уже было помечено прочитанным — клиент не может стереть неоткрытое сообщение. Soft-удаление действует только для одного получателя: строка самого сообщения переживает удаление вплоть до admin bulk-purge всей почты соответствующей партии.

Ответ message-detail содержит и оригинальное тело, и (если есть кэш) перевод; UI по умолчанию показывает перевод и предлагает переключение «показать оригинал».

Внутриигровой UI группирует личную почту по веткам по расам — каждая личная переписка между локальным игроком и другой расой оказывается в одной ветке, ключевая по расе собеседника. Системные сообщения, административные уведомления и собственные рассылки игрока (платный тариф) показываются отдельными автономными записями в том же списке и никогда не группируются. read_at и deleted_at поддерживают локальный счётчик непрочитанного и кнопку удаления, но не показываются игроку — дипломатическая почта не обещает уведомления о прочтении. Форма compose выбирает получателя по имени расы (сервер резолвит через Memberships.ListMembers(game_id, "active")); клиент не тянет отдельный список членов. Подробнее — в ui/docs/diplomail-ui.md.

11.5 Хуки жизненного цикла

Три транзитных перехода в лобби порождают system mail в inbox затронутых игроков:

  • Пауза / отмена игры. Когда автомат партии проходит через paused или cancelled, лобби эмитит system-сообщение всем активным членам. Текст рендерится сервером по шаблону, чтобы игрок, открывший inbox позже, нашёл объяснение даже без одновременной push-сессии.
  • Удаление / блокировка членства. Сам-выход, удаление владельцем и admin-бан порождают system-сообщение только для затронутого игрока. Это письмо переживает переход членства в removed / blocked — игрок сохраняет к нему read-доступ навсегда (правило soft-доступа).

Будущее удаление по неактивности должно вызывать тот же publisher, чтобы объяснение дошло до затронутого игрока; README пакета прибивает этот контракт для следующего реализатора.

11.6 Перевод

diplomail_messages.body_lang заполняется на стороне сервера в момент отправки внутрипроцессным детектором языка, работающим только по телу. Subject наследует язык тела для ключа кэша перевода. Когда детектор не может уверенно классифицировать тело (слишком короткое, пустое, смешанные скрипты), значение — плейсхолдер BCP 47 und ("неопределённый"), и pipeline перевода обходится стороной — получатели видят оригинал.

Перевод выполняется асинхронно. Каждая recipient-строка содержит снимок preferred_language получателя плюс метку available_at. Получатель, чей язык совпадает с детектированным body_lang (или чей preferred_language пуст / язык тела — und), получает available_at = now() сразу при вставке, и push-событие отправляется в момент POST. Получатель с отличающимся языком вставляется с available_at IS NULL и ждёт worker.

Worker (internal/diplomail.Worker) тикает каждые BACKEND_DIPLOMAIL_WORKER_INTERVAL (по умолчанию 2s) и обрабатывает по одной паре (message_id, target_lang) за тик. Он сначала смотрит в кэш переводов; на miss дёргает настроенный Translator. Дефолт production-сборки — LibreTranslate HTTP клиент; пустой BACKEND_DIPLOMAIL_TRANSLATOR_URL оставляет noop-translator, который доставляет сообщение в оригинале.

Исходы перевода:

  • Успех. Строка в diplomail_translations создаётся (или переиспользуется, если параллельная попытка успела раньше), все pending-получатели пары переключаются на available_at = now(), и по каждому отправляется push.
  • Неподдерживаемая пара языков (HTTP 400 от LibreTranslate). Строка перевода не сохраняется; получатели доставляются с оригинальным телом. Последующие чтения возвращают оригинал.
  • Транзитный сбой (timeout, 5xx, network error). Счётчик попыток увеличивается, следующая попытка планируется по экспоненциальному backoff 1s → 2s → 4s → 8s → 16s (с потолком 60s). После BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS (по умолчанию 5) worker fallback'ит на оригинальное тело. Длительный отказ переводчика тормозит доставку максимум на ~30 секунд на пару до того, как получатель увидит оригинал.

Кэш переводов общий: broadcast на N получателей с одинаковым preferred_language порождает одну строку кэша и один вызов переводчика, не N.

11.7 Хранение и purge

Сообщения живут в diplomail_messages; per-recipient state — в diplomail_recipients с FK-каскадом на сообщение; переводы — в diplomail_translations тоже с каскадом. IP-адрес отправителя снимается из X-Forwarded-For (форвардит gateway) и хранится в сообщении для сохранения доказательств.

Автоматического retention нет. Admin bulk-purge endpoint удаляет все сообщения, чья партия завершилась более older_than_years лет назад (минимум 1); каскад удаляет recipient- и translation-строки той же транзакцией.

11.8 Видимость для оператора

Admin-поверхность экспонирует постраничный листинг всех сообщений (/api/v1/admin/mail/messages) с фильтрами по game_id, kind и sender_kind. Bulk-purge endpoint (/api/v1/admin/mail/cleanup) принимает порог older_than_years. Per-game admin-отправки и multi-game broadcast'ы доступны через /api/v1/admin/games/{game_id}/mail и /api/v1/admin/mail/broadcast.

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