Skip to content

Обзор API ​

MARV предоставляет V2 как основную клиентскую API; V1 поддерживается для обратной совместимости. ACP - административный профиль; EXT - внешние вебхуки мерчантов.

Авторизация (V2) ​

V2 использует session-based авторизацию через JWT.

1. Login — клиент отправляет POST /v2/login с платформенными данными в JSON body:

json
{
  "api_type": 30,
  "api_uid": "12345",
  "api_token": "platform-signed-token",
  "platform_data": "vk_user_id=12345&sign=abc...",
  "client_version": "1.5.0",
  "language": "ru"
}

Поле platform_data — raw JSON с платформ-специфичными данными для верификации. Содержимое зависит от api_type:

Платформаplatform_dataОписание
VK"vk_user_id=123&sign=abc..."URL-encoded launch params (HMAC-SHA256)
OK"session_key"Ключ сессии для MD5-верификации
DR"session_key"Ключ сессии для MD5-верификации
MS Start"base64-payload"Base64-encoded JSON с RSA подписью; идентификатор игрока внутри подписанного playerInformation должен совпасть с заявленным api_uid
GamePush"base64-query"Base64-encoded query params
Другие—Не требуется (можно не передавать)

Сервер проверяет платформенный токен, создаёт/получает пользователя и возвращает JWT вместе с профилем:

json
{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "expires_at": 1709136000,
  "user": { "id": 1, "api_uid": "12345", "data": { ... } },
  "needs_portable_anchor": true
}

Объект user содержит полный профиль (с data), что избавляет от отдельного запроса к /v2/users/me при старте сессии. Поле needs_portable_anchor подсказывает клиенту, что у аккаунта ещё нет кросс-девайсного «якоря» (FB/Google/Apple), и его стоит предложить привязать.

2. Запросы — все остальные V2 эндпоинты требуют JWT в заголовке:

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

Платформенные заголовки (X-Auth-Token, X-Auth-Type, X-Auth-Uid) для V2 не нужны. Без валидного Bearer-токена запрос будет отклонён с 401.

3. Single session — каждый вызов /v2/login выдаёт новый токен с уникальным jti (JWT ID) и сохраняет его на сервере. Предыдущий токен того же пользователя автоматически инвалидируется: запросы со старым jti получат 401. Один пользователь = одна активная сессия.

4. Истечение — токен действует 24ч (настраивается через app.security.jwt.ttl). При истечении сервер вернёт 401; клиент должен повторить login.

JWT подписан HMAC-SHA256 ключом app.security.jwt.secret. Этот ключ можно использовать в других сервисах для верификации токена. Payload содержит: uid (user_id), pid (public_id; добавляется только если задан), atp (api_type), aui (api_uid), jti (session id), exp, iat.

Авторизация (V1) ​

V1 использует per-request платформенную авторизацию через заголовки:

ЗаголовокОбязателенОписание
X-Auth-TokenдаТокен авторизации: по умолчанию MD5(secret_apiType_apiUID)
X-Auth-TypeдаТип платформы (целое), выбирает конфигурацию платформы
X-Auth-UidдаUID пользователя на платформе
X-Session-TokenнетСессионный токен (только при single_mode.enabled)
X-Client-VersionнетВерсия клиента; валидируется согласно конфигу
X-Client-Language-CodeнетКод языка клиента; сохраняется в профиле пользователя
X-SessionнетСессия платформы (используется некоторыми платформами для верификации)
X-Access-TokenнетAccess-токен платформы
X-Inviter-UidнетUID пригласившего пользователя (для реферальной системы)

Single session mode (deprecated): при app.session.single_mode.enabled: true и немобильной платформе, при первом вызове /v1/users/fetch без X-Session-Token сервер генерирует токен, сохраняет его в БД и возвращает в заголовке X-Set-Session-Token. Клиент должен передавать этот токен в последующих запросах через X-Session-Token. При blocking: true запросы с устаревшим или отсутствующим токеном отклоняются (код 8 или 11).

Request ID и страна клиента ​

  • Клиент может (но не обязан) передавать X-Request-Id. Если заголовок не задан, MARV сгенерирует UUID самостоятельно.
  • Каждый ответ содержит X-Request-Id — его можно использовать для сопоставления с логами (в zerolog контекст пишется поле request_id; trace_id — это его алиас в gin-контексте).
  • При работе через Cloudflare заголовок CF-IPCountry проксируется в ответ как X-Client-Country-Code, поэтому страна клиента доступна даже без отдельного API.
  • В ответ также добавляется X-Server-Id: это идентификатор узла (app.server.id из конфига либо автогенерированное значение), что упрощает отладку и мониторинг лидерства.

Верификация IAP ​

  • ApiType/ApiUid всегда берутся из аутентифицированного пользователя; значения в теле запроса игнорируются.
  • Покупку проверяет магазин, через который она прошла, — по умолчанию платформа логина сессии. Если игрок вошёл через Google, Apple, Facebook, Game Center или Play Games, логин сессии — не магазин, и клиент передаёт store_api_type: магазин своей сборки (11 — Google Play, 10 — App Store и т.д.). Транзакция пишется под логином сессии и за аккаунтом сессии; магазин сохраняется в store_api_type, public id аккаунта — в public_id.
  • Транзакции «пользователя» (/v2/transactions, /v2/transactions/complete, ACP) — это транзакции его аккаунта под любым его логином плюс транзакции текущего логина, записанные до привязки к аккаунту. Каждая транзакция при создании закрепляется за аккаунтом, которому принадлежит её логин, — в том числе платежи мерчантов и Telegram-бота, которые знают только логин, — поэтому незавершённый платёж подтверждается из сессии любого логина аккаунта.
  • Поле user_id опционально (передаётся внутри store_payload). Если его нет, MARV подставляет ApiUid текущего пользователя. Указывайте user_id явно только на платформах, где IAP использует другой идентификатор (например, Discord).
  • V2 передаёт store-specific данные через store_payload (JSON object). V1 использует flat-поля для обратной совместимости.
  • Необязательное поле merchant_profile позволяет выбрать профиль мерчанта (по умолчанию default).

Пример V2 запроса POST /v2/transactions/iap/verify:

json
{
  "product_id": 10,
  "price": 9.99,
  "currency": "USD",
  "store_payload": {
    "transaction_id": "receipt-123",
    "purchase_token": "purchase-token-abc",
    "package_name": "com.example.app"
  }
}

Ответ включает success, order_id, product_sku, transaction_id (внутренний ID), duplicate (true если транзакция уже существовала) и error (строка причины при неуспешной верификации; отсутствует при успехе).

success: true означает, что покупка не только подтверждена стором, но и записана в реестр транзакций. Если стор подтвердил покупку, а запись не удалась, ответ приходит с success: false и причиной в error — начислять покупку по такому ответу нельзя, её нет в реестре. Повторный запрос безопасен: как только строка появится, он вернётся через ветку duplicate: true.

Поля store_payload по платформам ​

Все поля ниже передаются внутри store_payload. Основные поля запроса (product_id, price, currency) обязательны для всех платформ.

Платформаpackage_nameproduct_skupurchase_tokenreceipttransaction_iduser_id
Google Play✅✅✅———
App Store (iOS)—опц.——✅—
RuStore✅✅✅———
Amazon———✅—✅
Galaxy Store——✅———
Bazaar✅✅✅———
Myket✅✅✅———
Discord————✅опц.
Facebook———✅——
Jest——✅—опц.—
Yandex——————

✅ — обязательное; опц. — опциональное; — — не используется.

Yandex

IAP-верификация на платформе Yandex не поддерживается. Запрос вернёт ошибку.

Примеры store_payload по платформам ​

Google Play / RuStore / Bazaar / Myket:

json
{
  "package_name": "com.example.game",
  "product_sku": "gem_pack_100",
  "purchase_token": "opaque-purchase-token"
}

App Store (iOS):

json
{
  "product_sku": "com.example.game.gem_pack_100",
  "transaction_id": "1000000123456789"
}

Сервер находит транзакцию у Apple по transaction_id (App Store Server API) и сверяет подписанный ответ: покупка сделана в этом приложении, это тот же SKU, что в product_sku (если он передан), она не возвращена. Чек приложения (receipt) не нужен, клиент может его не присылать. Покупку из Sandbox (App Review, TestFlight) сервер принимает, но помечает. Если ту же транзакцию уже засчитали другому аккаунту, ответ — ошибка iap.order_taken.

Amazon:

json
{
  "receipt": "receipt-id-from-amazon",
  "user_id": "amazon-user-id"
}

Galaxy Store:

json
{
  "purchase_token": "payment-id-token"
}

Discord:

json
{
  "transaction_id": "entitlement-id",
  "user_id": "discord-user-id"
}

Facebook:

json
{
  "receipt": "signature.base64payload"
}

Jest:

json
{
  "purchase_token": "jwt-signed-token",
  "transaction_id": "optional-purchase-id"
}

Форматы ответов ​

  • Успех (V2): { "time": <unix>, "data": <payload> }
  • Ошибка (V2/ACP): { "time": <unix>, "error": { "code": "...", "message": "...", "details": { ... }}} — HTTP-статус только в статус-строке ответа, в теле его нет

Подробно про ошибки и соответствия доменных ошибок см. в V2 Errors.

Конфигурация и таймауты ​

  • HTTP‑таймауты и бакеты метрик настраиваются в config/main.md, секциях app.http.timeouts.* и observability.metrics.http_buckets_ms.

EXT (мерчанты) ​

  • Общие принципы и требования описаны в EXT Guide. Для каждого мерчанта используется собственная авторизация/подпись.

ACP: инициализация и мониторинг ​

  • POST /acp/me — основная точка входа ACP. Возвращает текущего оператора (email из HELM-токена), привязанные аккаунты, информацию о сервере (версия, аптайм, ОС), список включённых модулей, сконфигурированных платформ и активных маршрутов.
  • POST /acp/system/overview — сводная аналитика для дашборда: статистика пользователей (total, today, week, month, DAU, MAU), количество онлайн, сессий за сегодня, состояние кластера (leader), память, Redis и статусы задач.
  • POST /acp/system/metrics — Prometheus-совместимые метрики (HTTP, DB, cache, controllers, tasks). Описание полей и настройки — в Observability.

Реклама (ACP → Ads) ​

  • /acp/ads/list возвращает все объявления и runtime‑снимок контроллерных метрик (количество вызовов, ошибки, p95) по префиксу /acp/ads/*. Эндпоинт read-only, отдельная роль не требуется.
  • /acp/ads/create|update|delete|toggle требуют роль AccessAds (256). После модификаций ответы всегда содержат commit: ["ads"] и актуальный список ads, чтобы UI мог обновить состояние без повторного запроса.
  • DTO и поля объявлений описаны в openapi.yaml (AcpAd, AcpCreateAdDTO, ...). Поля title/description/data — произвольные JSON‑структуры, weight задаёт приоритет показа.

Alert Center (ACP) ​

  • /acp/alerts/list — фильтрует критические уведомления (type, severity, status, tags, диапазон дат). Требует роль AccessAlerts (512).
  • /acp/alerts/detail — возвращает карточку уведомления и историю доставок (Slack/Telegram webhooks).
  • /acp/alerts/ack — подтверждает обработку, сохраняет комментарий (виден всем операторам).
  • Конфигурация каналов и шаблонов описана в config/main.md; подробный контракт — в openapi.yaml.

Реклама (V1 → Ads) ​

  • POST /v1/ads/fetch возвращает доступные объявления (только включённые и отфильтрованные по весу). Дополнительно формируется video_url по domain.stream.subdomain из конфигурации.
  • POST /v1/ads/impression и POST /v1/ads/click инкрементируют счётчики просмотров/кликов по sid. Эти вызовы не возвращают payload.
  • Все V1 Ads маршруты используют стандартную платформенную авторизацию (X-Auth-*). См. структуры V1Ad, V1AdImpressionDTO, V1AdClickDTO в openapi.yaml.

Реклама (V2 → Ads) ​

  • POST /v2/ads возвращает массив объявлений (V2Ad) с теми же полями, что и V1 (включая вычисленный video_url).
  • POST /v2/ads/impression и POST /v2/ads/click принимают DTO (V2TrackImpression, V2TrackClick) и увеличивают счётчики по sid.
  • Все вызовы требуют V2 авторизацию (Authorization: Bearer JWT). Ответы следуют формату { "time": ..., "data": ... }. Схемы перечислены в openapi.yaml.

Заказы (Orders) ​

Модуль order реализует маппинг «SKU клиента → транзакция сервера» для IAP-покупок. Клиент создаёт заказ до инициации платежа на платформе, затем резолвит его при получении подтверждения.

Таблица orders ​

ПолеТипОписание
idserial PK—
api_typeintegerТип платформы
api_uidtextUID пользователя на платформе
skutextИдентификатор продукта
transaction_idtext, nullableID транзакции (заполняется при resolve)
statustextpending / completed
created_attimestampВремя создания
updated_attimestampВремя последнего обновления

V1 API ​

  • POST /v1/orders/create — создаёт pending-заказ. Body: { "sku": "gem_pack_100" }. Возвращает { "order_id": 42, "sku": "gem_pack_100", "status": "pending" }.
  • POST /v1/orders/resolve — привязывает transaction_id к существующему заказу. Body: { "sku": "gem_pack_100", "transaction_id": "txn_abc123" }. Идемпотентен: если заказ с таким sku+transaction_id уже есть — вернёт его.

V2 API ​

  • POST /v2/orders/create — аналог V1, V2 формат ответа ({ "time": ..., "data": ... }).
  • POST /v2/orders/resolve — аналог V1, V2 формат ответа.

Cron-задача order_cleanup ​

Удаляет неразрешённые (pending) заказы старше domain.orders.ttl_days (по умолчанию 7 дней). Настройка:

yaml
infrastructure:
  scheduler:
    tasks:
      - name: Order Cleanup
        tag: order_cleanup
        type: order_cleanup
        expression: "0 3 * * *"   # каждый день в 3:00
        enabled: true

Конфигурация ​

КлючПо умолчаниюОписание
domain.orders.ttl_days7Сколько дней хранить pending-заказы

Ограничения аккаунта ​

Ограничение — строка user_restrictions на аккаунт (users.id): какую часть игры закрывает (scope), как действует (mode), кто выдал (source), до какого времени (until, null — бессрочно), причина (reason), служебные подробности (meta), автор и время выдачи. Строки не переписываются: досрочное снятие проставляет lifted_at/lifted_by/lift_reason, поэтому таблица — сама себе история. Ограничение действует, пока не снято и не истекло. Ограничения принадлежат аккаунту, а не логину: при слиянии аккаунтов они переезжают к выжившему, при удалении аккаунта удаляются вместе с ним. Модули спрашивают набор действующих ограничений аккаунта у RestrictionService и в таблицу сами не ходят.

  • Области (scope) вложены через точку: ограничение на clans покрывает и clans.chat, и clans.war; global покрывает всё. events зарезервирована: её ничто не проверяет, и выдать её нельзя.
  • Режимы (mode): block — открытый отказ с кодом ошибки; shadow — тихо: игрок видит успех, а эффекта его действий не видит никто другой. Блок в той же области сильнее тени: заблокированный аккаунт тенью не считается.
  • Источники (source): operator — оператор ACP; left_clan — игрок ушёл из клана во время войны (см. «Войны кланов»); legacy — миграция перенесла users.banned в global/block, а clan_members.shadow_ban — в clans.chat/shadow.

Выдать можно только пары, которые что-то проверяет:

scope / modeЧто происходит
global / blockЛюбой запрос V1/V2 и /v2/login отклоняются так же, как при users.banned: V2 — auth.banned (403), V1 — статус UserBanned (6). Бессрочный глобальный блок дублируется в users.banned; колонка сбрасывается, когда у аккаунта не остаётся бессрочных глобальных блоков (у колонки нет срока, и срочный блок она пережила бы). Если ограничения прочитать не удалось, запрос пропускается: сбой проверки не должен закрыть игру всем.
clans / blockЧтения показывают игрока без клана: clans/my, clans/check, чтения войны и чат отвечают как игроку без клана (отправка в чат — clan.not_in_clan), clans/search и clans/leaderboard пусты, clans/get и clans/leader/snapshot — 404. Любое его действие с кланом — create, join, join-request, update, leave, member/role, member/kick, transfer-lead, snapshot/update, request/list, request/resolve — отклоняется открыто: clans.restricted (403). points/report, attack/start и attack/result — clan_war.participation_locked (403). Блок не снимает членство: для остальных игрок остаётся в составе, но лидерство ему не передаётся ни вручную (clan.target_cannot_lead), ни наследованием при простое лидера.
clans / shadowТеневой мир кланов (ниже).
clans.chat / blockВсе клановые чаты закрыты: отправка отвечает clan_chat.muted.
clans.chat / shadowСообщения игрока видит только он сам (echo без сохранения) — в любом клане.
clans.war / blockОтстранение от клановых войн (см. «Войны кланов»). Блок clans и global отстраняет так же.

Теневой мир кланов. Мир клана задаёт его флаг clans.shadow_ban; мир игрока теневой, пока на аккаунте действует clans/shadow. Игрок видит кланы одного мира: пока он в клане — мира своего клана (свой клан и его соседи по лидерборду всегда на виду, а вступить в другой клан он всё равно не может), без клана — своего мира, единственного, в кланы которого он может вступить. Кланов другого мира для игрока нет: их нет в поиске и лидерборде (там и total считается по этому миру), а clans/get и clans/leader/snapshot отвечают 404, как на несуществующий id. Исключение — соперник по текущей войне его клана: он виден, в каком бы мире ни был. join и join-request отвечают так же: открыто clans.restricted, если кланы закрыты; clan.already_in_clan, если игрок уже в клане; 404 на клан другого мира. Клан, основанный игроком теневого мира, сам становится теневым. Заявки игрока в кланы, которых для него нет, не показываются в request/mine и не занимают лимит заявок. Лидер не видит заявок игроков из другого мира и игроков, которым закрыты кланы, и они не входят в requests.count в clans/check у тех, кто может открыть заявки (старейшина и выше); принятие заявки, автор которой с момента подачи сменил мир или лишился доступа к кланам, отменяет её и отвечает так же, как на заявку, отозванную самим автором (422, «request already resolved»). Теневые кланы подбираются на войну только между собой; одинокий теневой клан может получить зеркало обычного клана, и на обычный клан такое зеркало не влияет, а обычному клану зеркало теневого не достаётся никогда. Игрок теневого мира, сидящий в обычном клане, пишет в чат как с clans.chat/shadow, а в войне участвует «тихо» (см. «Войны кланов»); в теневом клане он переписывается и воюет как обычно.

ACP. POST /acp/restrictions/list — все ограничения игрока, новые первыми (любой оператор). POST /acp/restrictions/issue выдаёт ограничение (until в будущем или без срока, reason обязателен), POST /acp/restrictions/lift снимает действующее досрочно (тоже с причиной). issue и lift требуют бит модуля, которому принадлежит область: global — AccessUser (8), clans, clans.chat, clans.war — AccessClan (2048). Ограничения выдают и другие операторские действия: переключатель banned в /acp/users/update выдаёт бессрочный global/block (если бессрочного ещё нет, даже при действующем срочном) или снимает все глобальные блоки (а /acp/users/search показывает banned: true и при срочном глобальном блоке). Коды отказов — в Кодах ошибок.

Кланы (Clans) ​

Кросс-платформенные кланы (идентичность игрока = api_type + api_uid; clan_tag глобально уникален). Бой клиент-детерминированный; сервер владеет подсчётом, наградами, состоянием и подбором.

V2 (игрок) ​

  • POST /v2/clans/{create,update,check,get,search,my,leave,join,join-request,request/list,request/mine,request/resolve,member/role,member/kick,transfer-lead,snapshot/update,leaderboard,leader/snapshot} — управление кланом и ростером. get/search/leaderboard/leader/snapshot публичны в пределах мира игрока; остальное требует авторизацию игрока. Как на них действуют ограничения аккаунта и теневой мир кланов — в Ограничениях аккаунта.
  • Запрета на вступление после выхода нет: вступить в клан или подать заявку можно сразу. Уход из клана во время войны отстраняет от клановых войн до конца цикла (см. ниже).
  • update — правка настроек клана лидером (announcement, description, join_policy, min_epoch_gate); частичное обновление (меняются лишь переданные не-null поля), только лидер.
  • check — лёгкий поллинг изменений: по секциям members/requests/war отдаёт метку changed_at (unix, для сравнения с last-seen) + count. Метки бампаются только на структурных изменениях (join/leave/kick/роль/лидерство; создание/резолв заявки), НЕ на снапшотах — клиент грузит тяжёлые данные лишь по изменившейся секции. Секция war дополнительно несёт phase_index, a_points/b_points текущей фазы (live-прогресс без обращения к /phases) и a_winning_points/b_winning_points (агрегат за выигранные фазы).
  • request/mine — собственные заявки игрока (куда подал + статус). leader/snapshot — снапшот лидера клана для превью потенциальным вступающим, не загружая весь ростер.
  • Снапшот лоадаута — first-class поле: create/join/join-request принимают {power, schema_version, hash, snapshot} (= snapshot/update одним запросом), плюс отдельный snapshot/update (гейтинг: хеш + rate-limit). Лидер видит снапшот кандидата в request/list; на accept он переносится в членство. Envelope: power/schema_version — колонки, snapshot — JSON-блоб клиента.
  • Отображаемая идентичность (nickname/avatar) обновляется через snapshot/update независимо от гейтинга снапшота: пишется только при реальном изменении, поэтому смена ника/аватара не «съедается» неизменившимся или rate-limit'нутым снапшотом.
  • total_power клана поддерживается сервером: пересчитывается как SUM(power участников) в той же транзакции при изменении состава/силы (create/join/leave/kick + апдейт power из снапшота); показывается в поиске и my. Война же берёт a_power/b_power отдельным свежим SUM на матчмейкинге — только по участникам, которые будут воевать (см. «Кто воюет»).
  • Ошибки унифицированы: стабильные code (clan.tag_taken, clan.already_in_clan, clan.full, clan.request_limit, …) с корректными статусами (409/422); unique_violation больше НЕ 500. Полный список — в Кодах ошибок.

Войны кланов (V2) ​

  • Эндпоинты: POST /v2/clans/war/{current,config,contributions,phases,points/report,rewards,rewards/claim,battle/roster,history,attack/start,attack/result,final,final/result}.
  • Война между двумя кланами — конфигурируемая упорядоченная последовательность фаз (domain.clan_war.phases), каждая длится свою duration. Планировщик закрывает фазу по истечении её окна (сравнивает a_points/b_points → winner → начисляет winning_points → фиксирует MVP) и активирует следующую, либо финиширует войну. Победитель войны — по сумме winning_points за выигранные фазы; ничья → без победителя. Типы фаз:
    • points — игроки репортят очки (points/report, валиден только на активной points-фазе); очки питают тали фазы, MVP клана и суммарный вклад игрока.
    • battle — 1×1 хендшейк: attack/start (валиден только в активной battle-фазе; проверяет, что атакующий не отстранён от клановых войн, что цель воюет за клан-противника сейчас и ещё не побеждена, и остаток атак — и замораживает снапшоты + сид) → attack/result (исход win/loss без звёзд; победа банкует points_per_win в тали клана). Каждого врага бьют один раз за фазу. Строка участника войны (clan_war_members, бюджет атак attacks_per_player) создаётся при первой атаке игрока. Бюджет атак, кап очков и «побеждён один раз» принадлежат аккаунту, а не логину: при смене логина посреди войны (привязка магазинного аккаунта меняет основной логин) строка участника и строка вклада фазы переезжают на новый логин вместе с потраченным, а побеждённый защитник остаётся побеждённым. attack/result засчитывается, только пока атакующий в клане этой войны и не отстранён; иначе атака закрывается без очков. battle/roster отдаёт бойцов обоих кланов ({attacks_cap, clan_a, clan_b}, боец = {api_type, api_uid, status, attacks_used, by?}): каждого, кто воюет за клан сейчас, сильнейшие первыми, без обрезки до равных отрядов. status ∈ free|active|beaten выводится из clan_war_pvp (pending → active, победа → beaten); active — эксклюзивный лок: attack/start по не-free цели отклоняется (clan_war.battle_in_progress) без траты атаки. Ник/power клиент берёт из clans/get.
    • results — неконкурентная пауза (без победителя).
    • squad5x5 — бой 5×5: final отдаёт составы + seed активной фазы (финал лениво создаётся при первом обращении; составы — сильнейшие по power бойцы каждого клана на этот момент, squad_size фазы), final/result фиксирует победителя и пишет исход в тали фазы, чтобы общий финализатор присудил winning_points. Сабмитит текущий участник клана войны (у зеркальной войны — только стороны A).
  • Кап очков на игрока. Очки считает клиент, поэтому фаза ограничивает, сколько один игрок может зачесть: points_cap за окно points_cap_window в конфиге фазы, а без окна — за всю фазу. По умолчанию на points-фазах не больше 250 000 за 5 минут, на боевой фазе не больше 100 за весь день. Окно отсчитывается от первого начисления в нём и хранится в строке вклада игрока. Очки сверх капа отбрасываются, остаток отчёта засчитывается: points/report отвечает {accepted}, attack/result — {points}, это то, что реально зачтено. Победа в атаке засчитывается и при выбранном капе: враг побит, но очков не приносит.
  • Кто воюет. Состав на подборе не фиксируется: за клан воюет любой его текущий участник, чьё положение в войне активно, — в том числе вступивший посреди войны, в любой фазе. Положение читается из ограничений аккаунта: отстранён — действует блок clans.war (или объемлющий clans/global), никакого участия; тихий — игрок теневого мира (clans/shadow) в обычном клане: действует и видит свой прогресс, но ничего из этого не засчитывается; иначе активен. Подбор учитывает только тех, кто будет воевать: отстранённые и тихие не входят ни в силу клана, по которой подбирается пара, ни в число участников, которое сверяется с min_members.
  • Уход из клана во время войны. Любой уход — leave, исключение лидером или офицером, операторский кик, авто-кик неактивных, роспуск клана (каждый участник) — пока идёт цикл войн, выдаёт аккаунту clans.war/block с источником left_clan до открытия следующего цикла войн: матчмейкер снимает такие блоки в начале подбора, до подсчёта силы кланов и min_members, поэтому от расписания крона они не зависят. Крайний срок блока (until) — самый поздний cooldown_ends_at среди войн, шедших в момент ухода и стирает его собственный прогресс — вклад по фазам и строку участника — в войнах клана, исход которых ещё не решён; его атаки в процессе закрываются, чтобы не держать защитников. Ограничение выдаётся и тогда, когда на аккаунте уже действует операторский блок: оператор может снять свой блок раньше конца цикла. Тали фаз, MVP и исходы клана остаются. Повторный уход в том же цикле второго ограничения не добавляет. Вступить в любой клан можно сразу, но воевать до конца цикла нельзя: так переход между кланами посреди войны ничего не даёт.
  • Отстранённый не видит войну: current отвечает {war: null, my_contribution: null, my_participation: {status: "locked", reason, until}} (reason: left_clan — ушёл из клана, banned — ограничил оператор; until — RFC3339 или null), остальные чтения войны (phases, contributions, battle/roster, history, final) отвечают как при отсутствии войны, а превью боя attack/get из клан-чата отдаётся и ему, а в clans/check у него нет секции war. points/report, attack/start, attack/result и final/result отвечают ему clan_war.participation_locked (403) — это серверная страховка, клиенту достаточно перезапросить current. У остальных ключа my_participation нет.
  • Тихий участник. Его очки копятся в shadow_points вклада по фазам (кап фазы считает их вместе с засчитанными) и никуда не идут: ни в тали фаз, ни в MVP, ни в награды, ни в зеркала, ни в чужую матрицу вклада. Его атаки помечены тенью (clan_war_pvp.shadow): защитника не бронируют и не забирают, события в чат не шлют. Свои тихие очки он видит в my_contribution (пока он тихий: вернувшись в обычный мир, он видит только засчитанное) и в своей строке contributions; его сабмит финала 5×5 принимается и ничего не меняет. Его тихую атаку по id (attack/get) находит только он сам. В battle/roster его видит только он сам — в списке своего клана, вместе со своими тихими атаками (побеждённая им тихо цель для него beaten); для остальных его и его атак нет. В составах финала его нет.
  • current отдаёт текущую войну + личный вклад; phases — разбивку по фазам; history — прошлые войны клана.
  • current дополнительно несёт транзиентные поля: current_a_points/current_b_points (тали активной фазы — клиент дёргает phases только когда сдвинулся current_phase_index) и a_members/b_members (маркеры {changed_at, count} ростеров обоих кланов — переподтягивать вражеский ростер лишь при изменении; ростер нужен для отображения MVP фаз). my_contribution (ClanWarMember) есть у любого участника клана, пока идёт война: points — вклад его аккаунта за клан в этой войне (0, пока ничего не набрано), ник и аватар — из членства, attacks_used/attacks_cap — из строки участника войны его аккаунта (до первой атаки attacks_used = 0). Если вклад сейчас не прочитать (сбой базы), current отвечает system.try_again_later (503) — повторить на следующем опросе.
  • config — разовый запрос при «война доступна»: весь статический конфиг наград (common пер-тир win/lose + индивидуальная лестница + min_points_for_reward + min_points_for_win + delivery) и tier_thresholds (E:0 … S+++:50). Из конфига, без БД — показывает награды для любого тира даже при доставке через почту, где rewards несёт только собственные начисления аккаунта.
  • contributions — матрица вклада член×фаза по обоим кланам текущей войны (для окна статистики по дням): {phase_count, clan_a, clan_b}, где у каждой строки nickname, avatar и массив phases (очки на каждую фазу, 0 где нет вклада). Строки клана — его текущие участники, за ними те, кто уже не в клане, но чьи засчитанные очки в этой войне по-прежнему идут клану (ник и аватар у них пустые). Очки сведены по аккаунту: вклад с разных логинов одного аккаунта складывается. В матрице только засчитанные очки, кроме строки самого вызывающего — в ней и его тихие очки. Источник — таблица clan_war_phase_contributions, которую ведёт движок фаз.
  • Тир: у клана есть счётчик tier_points (растёт за победы в войне), маппящийся на тир по порогам (domain.clan_war.tier). Тир замораживается на войне (a_tier/b_tier) и влияет на размер наград.
  • Определение исхода и results-фаза: исход войны (winner_clan_id + тир-очки) фиксируется в момент, когда никакая фаза уже не может изменить победителя — т.е. закрылась последняя соревновательная фаза и активировалась терминальная results-фаза (флаг clan_wars.outcome_finalized). Война при этом остаётся active на весь duration results-фазы (чтобы war/current продолжал её отдавать), с уже заполненным winner_clan_id и current_phase_type: "results". Когда окно results-фазы истекает — война просто → status=finished (повторно исход/тир/награды НЕ начисляются). Если войны без results-фазы — исход фиксируется на закрытии последней фазы (= завершение войны), как раньше.
  • Награды начисляются, как только исход определён (на старте results-фазы, а не в её конце). Получатели — текущие участники клана с активным положением в войне на этот момент (отстранённые и тихие ничего не получают); очки каждого — засчитанный вклад его аккаунта за клан в этой войне. Общая пер-тир награда — только набравшим не меньше min_points_for_reward (по умолчанию 1); из них win получают участники победившего клана, набравшие больше min_points_for_win, остальные — lose (ничья → lose обоим). Плюс индивидуальная лестница из стадий по суммарным очкам игрока. ReconcileWarRewards берёт войны по outcome_finalized AND NOT rewards_granted. Начисление идемпотентно и переживает сбои (reconcile в планировщике). Награды приходят двумя письмами — за итог войны и за личный вклад, — чтобы клиент мог назвать их по-разному: одно письмо не давало понять, какие предметы за что. Это две ledger-записи с разным kind (уникальность (war_id, api_type, api_uid, kind) даёт exactly-once каждой):
    • kind="war" → Description = { code: "clan_war_result", params: { outcome, opponent_clan_name } }, Award — общая пер-тир награда (win/lose);
    • kind="war_contribution" → Description = { code: "clan_war_contribution", params: { outcome, opponent_clan_name } }, Award — стадии индивидуальной лестницы. Если игрок не взял ни одной стадии, второе письмо не создаётся. Текст писем рендерит клиент, не сервер. Два режима доставки (rewards.delivery): mail — сервер кладёт письмо в почту; claim — сервер записывает право, клиент забирает через rewards (список любого статуса) + rewards/claim (атомарно pending → claimed, возвращает забранные, повторный вызов пуст).
  • Очки за всё время: вместе с reconcile наград (т.е. как только исход определён, на старте results-фазы) засчитанный вклад войны (clan_war_phase_contributions.points, сведённый по аккаунту) роллапится в all-time clan_members.points_total (кредитуется членству аккаунта в клане, за который набраны очки; вышедший из клана не кредитуется) и в клановый clans.points_total (сумма по клану). Последний ранжирует клановый лидерборд (clans/leaderboard → сортировка по points_total DESC). Сторона B зеркальной войны в роллап не входит: эти очки засчитывает собственная война B. Роллап ровно один раз — атомарно с захватом флага rewards_granted в одной транзакции, поэтому ретрай reconcile / конкурентный прогон ничего не задваивают. Так clans/my → members.points_total = вклад аккаунта за всё время (за войну — war/current → my_contribution.points, за фазу — war/contributions).
  • Роспуск клана в войне → форфейт: если воюющий клан распускается (последний участник вышел, или ACP DisbandClan), война не осиротевает — она сразу завершается форфейтом: выживший клан авто-побеждает (winner_clan_id = он, outcome_finalized, status=finished, +win tier points), reconcile выдаёт ему заработанные награды (win-тир + индив. лестница), а распущенному — ничего (его клан удалён, grantClanRewards пропускает несуществующий клан). Идемпотентно (гейт outcome_finalized).
  • Cron: clan_war_matchmaker открывает войны на старте цикла (материализует фазы из расписания); clan_war_scheduler продвигает фазы, делает reconcile наград и просрочивает зависшие атаки.
  • Зеркальная война: если на подборе клану не хватило пары (кланов в группе нечётное число), он получает войну против клана, который уже воюет: ближайшего по силе из своей группы, а если в его группе ни одной войны не открылось — из любой (так одинокий мобильный клан или единственный теневой клан тоже воюет; зеркало обычного клана на сам обычный клан не влияет). Такая война помечена mirror: true, клан без пары в ней всегда сторона A. Сторона B об этой войне не знает: current, история, чат, награды и очки тира у B только от своей войны. На сторону B в той же транзакции копируются очки, которые B набирает в своей войне, и в points-фазах, и за победы в боевой день; кап при этом уже применён в его войне. В боевой день A атакует тех, кто воюет за B сейчас, по обычным правилам, а побиты ли игроки B в зеркале, не зависит от их войны. Для A это обычная война с обычными наградами и очками тира. Если распускается B, зеркальные войны против него достаются A; если распускается A, победа достаётся B без очков тира. Без зеркала остаётся, только если на подборе не открылось ни одной войны.
  • Кто доступен для подбора: клан, у которого нет войны с ещё не решённым исходом. Исход решается, когда закрывается последняя соревновательная фаза, поэтому клан в results-фазе уже свободен, как и клан, чья война закрыта досрочно (next-phase на последней фазе, роспуск соперника). Плановый конец войны (cooldown_ends_at) на подбор не влияет, от него считается только очистка истории.
  • Config (domain.clan_war, авторитетный пример — config/config.example.yml): participation_mode, min_members, power_band_pct, attacks_per_player, retention/retention_wars, attack_reserve_timeout/attack_reserve_on_expire, phases[] ({type, duration, winning_points, points_per_win?, squad_size?}), tier ({points_per_win, points_per_loss, thresholds}), rewards ({delivery, min_points_for_reward, min_points_for_win, common, individual}).

ACP (оператор) ​

  • Просмотр открыт всем операторам: POST /acp/clans/{list,get}.
  • Мутации требуют роль AccessClan (2048) и пишут аудит: POST /acp/clans/{rename,disband,member/kick,shadow-ban,audit/list}.
  • Ручные триггеры войны (дублируют кроны clan_war_*): POST /acp/clans/war/{run-matchmaking,advance,next-phase}. advance выполняет один проход крона clan_war_scheduler (закрыть фазы с истёкшим окном, выдать награды, снять зависшие атаки) и отвечает тремя счётчиками {advanced, rewarded, expired}; next-phase — принудительно закрывает текущую фазу СЕЙЧАС по текущему счёту, активирует следующую и перезапускает таймлайн оставшихся фаз от now (каждая на свой duration), либо завершает войну на последней фазе (ops/тест).
  • Модерация чата (AccessClan): POST /acp/clans/chat/{messages,delete,reports,reports/resolve} — лента сообщений клана, удаление сообщения, очередь жалоб и её разбор.
  • shadow-ban переводит клан в теневой мир и обратно. Теневой мир игрока — ограничение clans/shadow, его выдают и снимают через /acp/restrictions/*.
  • Ограничения аккаунта: POST /acp/restrictions/{list,issue,lift} — история открыта всем операторам, выдача и снятие требуют бит модуля области (см. Ограничения аккаунта).
  • Полные DTO и схемы — в openapi.yaml (теги v2.clans, v2.clan_wars, acp.clans, acp.clan_wars, acp.restrictions).

A/B тесты: Platform-Aware Distribution ​

При включённой опции platform_aware (per-test boolean в таблице experiments), распределение пользователей по экспериментальных группам учитывает api_type. Это гарантирует, что пропорции групп соблюдаются внутри каждой платформы, а не по всей таблице.

В MARV2 (legacy) используется конфиг-флаг experiments.platform_aware_distribution, в MARV (DDD) — колонка platform_aware на каждом тесте.

Rival Query: TABLESAMPLE ​

Запрос соперников (/v1/rivals/fetch) использует 3-фазный подход для оптимизации:

  1. TABLESAMPLE SYSTEM_ROWS — блочная выборка O(1), самый быстрый путь
  2. TABLESAMPLE BERNOULLI(1%) — fallback при разреженных данных
  3. ORDER BY RANDOM() — last resort для маленьких таблиц

Это заменяет прежний ORDER BY RANDOM(), который на таблицах с миллионами записей вызывал полный SeqScan + сортировку.

Требование: расширение tsm_system_rows должно быть установлено: CREATE EXTENSION IF NOT EXISTS tsm_system_rows; (миграция включена).

QueryBuilder: Raw SQL ​

QueryBuilder поддерживает raw SQL через методы Raw() и Scan():

go
recs := []orm.RivalRecord{}
err := db.Query(ctx).
    Raw("SELECT * FROM rivals TABLESAMPLE SYSTEM_ROWS(?) WHERE api_type IN (?) LIMIT ?", 200, apiTypes, limit).
    Scan(&recs).Error

Эти методы доступны через все обёртки: GORM-реализация, metrics-декоратор, transaction.