Обзор API
MARV предоставляет V2 как основную клиентскую API; V1 поддерживается для обратной совместимости. ACP - административный профиль; EXT - внешние вебхуки мерчантов.
Авторизация (V2)
V2 использует session-based авторизацию через JWT.
1. Login — клиент отправляет POST /v2/login с платформенными данными в JSON body:
{
"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 вместе с профилем:
{
"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:
{
"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_name | product_sku | purchase_token | receipt | transaction_id | user_id |
|---|---|---|---|---|---|---|
| Google Play | ✅ | ✅ | ✅ | — | — | — |
| App Store (iOS) | — | опц. | — | — | ✅ | — |
| RuStore | ✅ | ✅ | ✅ | — | — | — |
| Amazon | — | — | — | ✅ | — | ✅ |
| Galaxy Store | — | — | ✅ | — | — | — |
| Bazaar | ✅ | ✅ | ✅ | — | — | — |
| Myket | ✅ | ✅ | ✅ | — | — | — |
| Discord | — | — | — | — | ✅ | опц. |
| — | — | — | ✅ | — | — | |
| Jest | — | — | ✅ | — | опц. | — |
| Yandex | — | — | — | — | — | — |
✅ — обязательное; опц. — опциональное; — — не используется.
Yandex
IAP-верификация на платформе Yandex не поддерживается. Запрос вернёт ошибку.
Примеры store_payload по платформам
Google Play / RuStore / Bazaar / Myket:
{
"package_name": "com.example.game",
"product_sku": "gem_pack_100",
"purchase_token": "opaque-purchase-token"
}App Store (iOS):
{
"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:
{
"receipt": "receipt-id-from-amazon",
"user_id": "amazon-user-id"
}Galaxy Store:
{
"purchase_token": "payment-id-token"
}Discord:
{
"transaction_id": "entitlement-id",
"user_id": "discord-user-id"
}Facebook:
{
"receipt": "signature.base64payload"
}Jest:
{
"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
| Поле | Тип | Описание |
|---|---|---|
id | serial PK | — |
api_type | integer | Тип платформы |
api_uid | text | UID пользователя на платформе |
sku | text | Идентификатор продукта |
transaction_id | text, nullable | ID транзакции (заполняется при resolve) |
status | text | pending / completed |
created_at | timestamp | Время создания |
updated_at | timestamp | Время последнего обновления |
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 дней). Настройка:
infrastructure:
scheduler:
tasks:
- name: Order Cleanup
tag: order_cleanup
type: order_cleanup
expression: "0 3 * * *" # каждый день в 3:00
enabled: trueКонфигурация
| Ключ | По умолчанию | Описание |
|---|---|---|
domain.orders.ttl_days | 7 | Сколько дней хранить 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на весьdurationresults-фазы (чтобы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-timeclan_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-фазный подход для оптимизации:
- TABLESAMPLE SYSTEM_ROWS — блочная выборка O(1), самый быстрый путь
- TABLESAMPLE BERNOULLI(1%) — fallback при разреженных данных
- 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():
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.
