feat(ads): server-driven ad-banner backend, wire & admin console (PR1)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s

Turn the gated-off mock banner into a real advertising subsystem (backend +
admin half; the UI rotation lands in PR2).

- internal/ads: campaigns (percent weight + validity window; a perpetual,
  undeletable default that fills the remainder up to 100%), 1..N bilingual
  messages (en+ru), global display timings; ActiveSet computes the
  window-filtered, default-remainder, GCD-reduced, language-resolved rotation
  feed. Smooth-weighted-round-robin math is unit-tested.
- migration 00006 (+ jetgen): ad_campaigns / ad_messages / ad_settings, seeded
  default campaign + house message + default timings.
- eligibility = !paid_account && hint_balance==0 && !no_banner role (new role;
  guests qualify). The resolved feed rides the profile.get response (no new RPC,
  works for guests, nothing distinct to filter); language by service_language.
- live update: a notify `banner` sub-kind (re-poll signal) published when an
  operator grants hints or grants/revokes no_banner, so the client shows/hides
  in place.
- admin console /_gm/banners (+ /_gm/banner-settings): campaign + message CRUD
  with reorder, default protection, clamped timings.
- wire: fbs BannerInfo/BannerCampaign on Profile; gateway transcode forwards it.
- docs: ARCHITECTURE §10, FUNCTIONAL (+ _ru), backend README, PRERELEASE tracker
  (incl. the deferred app.load aggregator note).
This commit is contained in:
Ilia Denisov
2026-06-15 23:00:19 +02:00
parent f59c8dcd43
commit 0946a3f66c
45 changed files with 2993 additions and 28 deletions
+23
View File
@@ -270,3 +270,26 @@ IP и вложением. Оператор может пометить сооб
пользователя. Открытие сообщения не помечает его прочитанным — это делают только явные действия; тело
сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не
рендерятся).
### Рекламный баннер
Однострочный баннер под навбаром показывает короткие рекламные или служебные сообщения **бесплатным
игрокам**: всем, кто **не** оплатил пожизненный аккаунт, **не** имеет купленных подсказок и **не**
несёт роль **`no_banner`** (она отключает баннер безусловно). Покупка платного аккаунта или подсказок —
либо выдача `no_banner` — убирает его; гости как самые «бесплатные» пользователи баннер видят. Когда
оператор меняет одно из этих свойств, баннер появляется или исчезает **на месте**, без перезагрузки.
Сообщения берутся из управляемых оператором **кампаний**, каждая из которых — заказ на размещение со
**своим весом показа** (процент) и необязательным **окном** действия (начало/конец). Одновременно
идущие кампании **конкурируют** за баннер пропорционально весам; постоянная **дефолтная (домашняя)**
кампания добивает остаток, не выкупленный платными, и полностью уходит из ротации, когда те выкупают
все 100%. Каждое сообщение написано на **двух языках** (английский + русский); игрок видит вариант для
**бота, через которого играет**, независимо от языка интерфейса. Клиент ротирует подходящие кампании
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
кампания с несколькими сообщениями показывает их по очереди.
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и
переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней
всегда остаётся хотя бы одно сообщение.