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
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:
+30
-5
@@ -40,8 +40,8 @@ Three executables plus per-platform side-services:
|
||||
in-memory mock transport (`pnpm start`) runs the whole slice with no backend.
|
||||
Embeddable in platform webviews; packageable to native (iOS/Android) via Capacitor.
|
||||
The client uses a mobile-app shell (a growing nav bar; content pinned to the bottom),
|
||||
a one-line **announcement banner** under the nav (a client-side mock rotation, **gated off
|
||||
in the build until polished after release** — a server-driven channel later, §10),
|
||||
a one-line **advertising banner** under the nav (server-driven campaigns shown to eligible free
|
||||
users, a weighted fair rotation — §10),
|
||||
and a client **board-style** setting (bonus-label
|
||||
mode). The visual/interaction design system is documented in
|
||||
[`UI_DESIGN.md`](UI_DESIGN.md).
|
||||
@@ -658,9 +658,34 @@ response/withdrawal lobby sync) never becomes a platform push. Operator broadcas
|
||||
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and
|
||||
cursor-based stream resume stay deferred (single-instance MVP).
|
||||
|
||||
A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md).
|
||||
It is a client-side **mock** rotation today; a server-driven source (operational notices,
|
||||
promotions) is future work and would deliver short markdown messages (text + links).
|
||||
A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md),
|
||||
server-driven by `internal/ads`. An operator manages **campaigns** (each one placement order) in
|
||||
the admin console (`/_gm/banners`): a campaign has a show **weight** (integer percent 1..100), an
|
||||
optional validity **window**, an `enabled` flag and one or more **bilingual messages** (en + ru,
|
||||
both mandatory, minimal markdown). A single perpetual **default** campaign fills the unsold
|
||||
remainder up to 100% and is undeletable. Eligibility — who sees a banner at all — is
|
||||
`!paid_account && hint_balance == 0 && !has(no_banner)` (the `no_banner` account role suppresses
|
||||
it unconditionally); guests qualify. The eligible viewer's banner block rides the **`profile.get`**
|
||||
response (the one bootstrap every client fetches on open, authed or guest — no separate request,
|
||||
nothing distinct for an advanced user to filter): the backend resolves each message to the viewer's
|
||||
**service language** (the bot they signed in through, falling back to the interface language) and
|
||||
computes the active set — window-filtered campaigns, the default's effective weight
|
||||
(`max(0, 100 − Σ active timed weights)`, dropped at 0), GCD-reduced. The **client** rotates that set
|
||||
with a smooth weighted round-robin (deterministic, fair: each campaign gets its weight share per
|
||||
cycle), round-robining a campaign's messages within its slots; the global display **timings** (hold,
|
||||
edge-pause, scroll speed, and the fade-out → gap → fade-in transition) are operator-set
|
||||
(`/_gm/banner-settings`, clamped) and ride the same block. When an operator changes a viewer's
|
||||
eligibility inputs (grants hints, grants/revokes `no_banner`; a future payment flow sets
|
||||
`paid_account`), the backend emits a `notify` **`banner`** sub-kind (a payload-free re-poll signal),
|
||||
and the open client re-fetches `profile.get` to show or hide the banner in place. Operator *content*
|
||||
edits take effect on the next `profile.get` (open/reconnect/foreground), not mid-session.
|
||||
|
||||
> A single `app.load` bootstrap aggregator (collapsing `profile.get` + lobby + badge fetches into
|
||||
> one round-trip) was **considered and deferred**: client↔gateway is HTTP/2 (h2c), so the bootstrap
|
||||
> RPCs already multiplex over one reused connection — the saving would be per-request fixed overhead,
|
||||
> not connections, and it is a high-blast-radius cross-cutting refactor. Revisit only with evidence
|
||||
> (`edge_request_duration`); if ever built, as a gateway-side fan-out/merge that keeps the per-domain
|
||||
> backend handlers intact.
|
||||
|
||||
## 11. Observability
|
||||
|
||||
|
||||
@@ -264,3 +264,27 @@ in-app), archive it, delete it, or delete every message from that player — and
|
||||
stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
|
||||
message does not mark it read — only the explicit actions do; message bodies and attachments are
|
||||
shown defensively (text escaped, attachments downloaded rather than rendered).
|
||||
|
||||
### Advertising banner
|
||||
|
||||
A one-line banner under the nav shows short promotional or operational messages to **free
|
||||
players**: anyone who has **not** paid for a lifetime account, holds **no purchased hints**, and
|
||||
does not carry the **`no_banner`** role (which suppresses it unconditionally). Buying a paid account
|
||||
or any hints — or being granted `no_banner` — removes it; guests, the freest users, see it. When an
|
||||
operator changes one of those properties, the banner appears or disappears **in place**, without a
|
||||
reload.
|
||||
|
||||
Messages come from operator-run **campaigns**, each a placement order with a **show weight** (a
|
||||
percent) and an optional start/end **window**. Campaigns running at the same time **compete** for the
|
||||
banner in proportion to their weights; a permanent **house (default)** campaign fills whatever share
|
||||
paid campaigns leave unsold and steps aside entirely when they sell the full 100%. Each message is
|
||||
written in **both languages** (English + Russian); a player sees the variant for the **bot they play
|
||||
through**, regardless of their interface language. The client rotates the eligible campaigns
|
||||
**fairly** — every campaign gets its weighted share each cycle, evenly spread rather than at random —
|
||||
and a campaign with several messages shows them in turn.
|
||||
|
||||
Operators manage all of this in the admin console at **`/_gm/banners`**: create, edit, enable/disable
|
||||
and schedule campaigns, write each campaign's bilingual messages (reorder or remove them), and set
|
||||
the global display **timings** (how long a message holds, the scroll of an over-long message, and the
|
||||
fade-out → gap → fade-in transition between messages). The default campaign cannot be deleted and
|
||||
keeps at least one message.
|
||||
|
||||
@@ -270,3 +270,26 @@ IP и вложением. Оператор может пометить сооб
|
||||
пользователя. Открытие сообщения не помечает его прочитанным — это делают только явные действия; тело
|
||||
сообщения и вложения показываются защищённо (текст экранируется, вложения отдаются на скачивание, а не
|
||||
рендерятся).
|
||||
|
||||
### Рекламный баннер
|
||||
|
||||
Однострочный баннер под навбаром показывает короткие рекламные или служебные сообщения **бесплатным
|
||||
игрокам**: всем, кто **не** оплатил пожизненный аккаунт, **не** имеет купленных подсказок и **не**
|
||||
несёт роль **`no_banner`** (она отключает баннер безусловно). Покупка платного аккаунта или подсказок —
|
||||
либо выдача `no_banner` — убирает его; гости как самые «бесплатные» пользователи баннер видят. Когда
|
||||
оператор меняет одно из этих свойств, баннер появляется или исчезает **на месте**, без перезагрузки.
|
||||
|
||||
Сообщения берутся из управляемых оператором **кампаний**, каждая из которых — заказ на размещение со
|
||||
**своим весом показа** (процент) и необязательным **окном** действия (начало/конец). Одновременно
|
||||
идущие кампании **конкурируют** за баннер пропорционально весам; постоянная **дефолтная (домашняя)**
|
||||
кампания добивает остаток, не выкупленный платными, и полностью уходит из ротации, когда те выкупают
|
||||
все 100%. Каждое сообщение написано на **двух языках** (английский + русский); игрок видит вариант для
|
||||
**бота, через которого играет**, независимо от языка интерфейса. Клиент ротирует подходящие кампании
|
||||
**честно** — каждая получает свою долю по весу за цикл, равномерно размазанную, а не случайно — а
|
||||
кампания с несколькими сообщениями показывает их по очереди.
|
||||
|
||||
Всем этим оператор управляет в админ-консоли по адресу **`/_gm/banners`**: создаёт, редактирует,
|
||||
включает/выключает и планирует кампании, пишет двуязычные сообщения кампании (переставляет и удаляет
|
||||
их) и задаёт общие **тайминги** показа (сколько держится сообщение, прокрутка слишком длинного, и
|
||||
переход fade-out → пауза → fade-in между сообщениями). Дефолтную кампанию нельзя удалить, и в ней
|
||||
всегда остаётся хотя бы одно сообщение.
|
||||
|
||||
Reference in New Issue
Block a user