From b5c8a04f0b9425e462588c9945860cd2a2462444 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 10 Jul 2026 03:36:18 +0200 Subject: [PATCH] fix(ads): share one interstitial cooldown timer across kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cooldowns were tracked per kind ({move,hint}), so a hint ad and a move ad did not see each other: after a hinted move's ad the move timer was still zero, and the next plain move fired an ad immediately — two ads within a minute, under the cooldown. Track a single shared last-shown time; the kind only selects the required gap (hint 1m, move 5m, vs_ai 30m) measured from the last interstitial of any kind. A hint can still fire on its shorter gap (D30's "independent" hint cooldown), but never stacks a second ad within a cooldown. Tests: a hint uses the shorter shared gap; a move does not stack onto a just-shown hint ad. --- docs/PAYMENTS.md | 7 ++++--- docs/PAYMENTS_DECISIONS_ru.md | 12 ++++++++---- docs/PAYMENTS_ru.md | 6 ++++-- ui/src/lib/ads.test.ts | 22 +++++++++++++++------ ui/src/lib/ads.ts | 37 ++++++++++++++++++++--------------- 5 files changed, 53 insertions(+), 31 deletions(-) diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index d43837b..15e6744 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -283,9 +283,10 @@ always shows real ads (a failed real ad must not credit). **Interstitial** (fullscreen after a confirmed play), **VK-only**, configurable server-side values. The gate is **client-mirrored**: the profile carries the cooldowns and a `suppressed` flag (the same no-ads / `no_banner` gate as the banner, resolved server-side in `adsFor`), and -the client self-gates on the last-shown time **per kind** in `localStorage` — no per-move -server round-trip. The contour `VITE_ADS_STUB` swaps the same "ad fired" toast for the real ad. -Values: +the client self-gates on a **single shared** last-shown time in `localStorage` — the kind only +picks the required gap, so a hint ad and a move ad never fire within a cooldown of each other +(one shared timer, not one per kind) — no per-move server round-trip. The contour +`VITE_ADS_STUB` swaps the same "ad fired" toast for the real ad. Values: - Global cooldown **per user, across all games**, default **5 min**. - **`vs_ai` — 30 min** (aligned with the hint cooldown, so it does not scare casual players). diff --git a/docs/PAYMENTS_DECISIONS_ru.md b/docs/PAYMENTS_DECISIONS_ru.md index ca32610..5bbab57 100644 --- a/docs/PAYMENTS_DECISIONS_ru.md +++ b/docs/PAYMENTS_DECISIONS_ru.md @@ -176,10 +176,14 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз **независимо** от основного кулдауна, со своим кулдауном **1 мин**. Оффлайн — только баннер. Уважать собственные лимиты частоты VK. **АМЕНД (E6, по факту реализации):** гейт **зеркальный** — сервер отдаёт кулдауны и `suppressed` - в профиле (`adsFor`), клиент сам гейтит по времени последнего показа на каждый вид в `localStorage` - (без раунд-трипа на ход). Ролик показывается **только после подтверждённого хода или подсказки** — - **не** после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»). - Interstitial — **только VK** (как и rewarded). + в профиле (`adsFor`), клиент сам гейтит по **единому** времени последнего показа в `localStorage` + (без раунд-трипа на ход). Таймер **общий на все виды** — вид (`hint`/`move`/`vs_ai`) лишь выбирает + нужный интервал, поэтому «независимость» подсказочного кулдауна значит лишь **более короткий + интервал от последнего показа**, а не отдельный таймер: hint-ролик и move-ролик не встают подряд + (баг раздельных таймеров: после hint-ролика move-таймер оставался нулевым → следующий ход сразу + крутил рекламу). Ролик показывается **только после подтверждённого хода или подсказки** — **не** + после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»). Interstitial — + **только VK** (как и rewarded). Частота — глобальная на все игры (localStorage на устройство). - **D31. `paid_account` тоже deprecated** → удаление из схемы (как `hint_balance`), в пользу per-origin бенефитов «без рекламы». Существующий `ads.Eligible` (`backend/internal/ads/ads.go:107`) расширяется: баннер гасится по origin-бенефиту, diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index 32be293..10ae6b0 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -282,8 +282,10 @@ build-флаг (`VITE_ADS_STUB`) подменяет ролик тостом; п **Полноэкранный ролик** (после подтверждённого хода), **только VK**, конфигурируемые серверные значения. Гейт **зеркалится на клиенте**: профиль несёт кулдауны и флаг `suppressed` (тот же гейт «без рекламы» / `no_banner`, что и у баннера, считается на сервере в `adsFor`), а клиент сам -гейтит по времени последнего показа **на каждый вид** в `localStorage` — без серверного раунд-трипа -на каждый ход. Контурный `VITE_ADS_STUB` подменяет ролик тем же тостом «ad fired». Значения: +гейтит по **единому** времени последнего показа в `localStorage` — вид лишь выбирает нужный +интервал, поэтому hint-ролик и move-ролик не встают подряд в пределах кулдауна (один общий таймер, +не по одному на вид) — без серверного раунд-трипа на каждый ход. Контурный `VITE_ADS_STUB` подменяет +ролик тем же тостом «ad fired». Значения: - Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**. - **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов). diff --git a/ui/src/lib/ads.test.ts b/ui/src/lib/ads.test.ts index 74fc04a..79f9ece 100644 --- a/ui/src/lib/ads.test.ts +++ b/ui/src/lib/ads.test.ts @@ -79,7 +79,7 @@ describe('maybeShowInterstitial (client-mirrored gate)', () => { expect(mocks.showToast).not.toHaveBeenCalled(); }); - it('respects the per-kind cooldown: a second move within the window is gated', async () => { + it('respects the cooldown: a second move within the window is gated', async () => { expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); vi.setSystemTime(BASE + 299_000); // +299s < the 300s global cooldown expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(false); @@ -88,16 +88,26 @@ describe('maybeShowInterstitial (client-mirrored gate)', () => { expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(2); }); - it('tracks move and hint cooldowns independently', async () => { + it('shares one timer across kinds: a hint uses the shorter gap from the last ad', async () => { + // A move ad, then a hint: the hint is held until its own (shorter) 60s gap from THAT ad has + // elapsed — not fired at once (a per-kind timer used to let it fire immediately). expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true); - // A hint right after a move still fires — a separate kind key with its own 60s cooldown. - expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(true); - vi.setSystemTime(BASE + 59_000); // +59s < the 60s hint cooldown + vi.setSystemTime(BASE + 59_000); expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(false); - vi.setSystemTime(BASE + 60_000); + vi.setSystemTime(BASE + 60_000); // the hint's 60s gap from the last ad has elapsed expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(true); }); + it('does not stack a move ad onto a just-shown hint ad (the reported bug)', async () => { + // A vs_ai hint-move fires the hint ad; a plain vs_ai move 30s later must NOT fire — the shared + // timer holds it for the vs_ai gap. Separate per-kind timers let it fire at once (the bug). + const vsAi = { vsAi: true, online: true }; + expect(await maybeShowInterstitial(ADS, 'hint', vsAi)).toBe(true); + vi.setSystemTime(BASE + 30_000); // 30s later, far under the 1800s vs_ai gap + expect(await maybeShowInterstitial(ADS, 'move', vsAi)).toBe(false); + expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(1); // only the hint ad fired + }); + it('uses the longer vs_ai cooldown for a vs_ai move', async () => { expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true); vi.setSystemTime(BASE + 1_799_000); // +1799s < the 1800s vs_ai cooldown diff --git a/ui/src/lib/ads.ts b/ui/src/lib/ads.ts index c3f2a48..ff555a7 100644 --- a/ui/src/lib/ads.ts +++ b/ui/src/lib/ads.ts @@ -50,29 +50,33 @@ export async function showRewarded(): Promise { } // The post-move interstitial gate is client-mirrored: the server sends the cooldowns + suppressed in -// the profile (Profile.ads); the client tracks the last-shown time per kind in localStorage and -// self-gates. Last-shown is per-device (cleared with storage) — acceptable for a frequency gate. +// the profile (Profile.ads); the client tracks the last-shown time in localStorage and self-gates. +// Last-shown is per-device (cleared with storage) — acceptable for a frequency gate. +// +// It is a SINGLE shared timestamp across kinds, not one per kind: the kind only selects the required +// gap (hint's short cooldown vs the move / vs_ai one), while the wait is always measured from the +// last interstitial of ANY kind. A per-kind timer let a hint ad and a move ad stack — after a +// hint-move ad the move timer was still zero, so the next plain move fired an ad immediately. const INTERSTITIAL_LAST_KEY = 'ads.interstitial.last'; -// interstitialLast reads the last-shown epoch millis per kind (0 when absent/corrupt). -function interstitialLast(): { move: number; hint: number } { +// interstitialLast reads the last-shown epoch millis of any interstitial (0 when absent/corrupt, or +// when a pre-shared-timer value — an object — is still stored: it decodes to 0 and self-heals). +function interstitialLast(): number { try { const raw = localStorage.getItem(INTERSTITIAL_LAST_KEY); if (raw) { - const j = JSON.parse(raw) as Partial<{ move: number; hint: number }>; - return { move: j.move ?? 0, hint: j.hint ?? 0 }; + const n = Number(raw); + return Number.isFinite(n) ? n : 0; } } catch { /* a corrupt or unavailable store simply resets the gate */ } - return { move: 0, hint: 0 }; + return 0; } -function recordInterstitial(kind: 'move' | 'hint', now: number): void { +function recordInterstitial(now: number): void { try { - const l = interstitialLast(); - l[kind] = now; - localStorage.setItem(INTERSTITIAL_LAST_KEY, JSON.stringify(l)); + localStorage.setItem(INTERSTITIAL_LAST_KEY, String(now)); } catch { /* ignore a write failure — the worst case is one extra ad next time */ } @@ -81,9 +85,10 @@ function recordInterstitial(kind: 'move' | 'hint', now: number): void { /** * maybeShowInterstitial shows a post-move (`kind='move'`, global / vs_ai cooldown) or post-hint * (`kind='hint'`, its own short cooldown) interstitial when the client-mirrored gate allows: not - * suppressed (no-ads), online, inside VK (the stub bypasses VK), and the mirrored cooldown elapsed. - * The stub (contour test) shows an "ad fired" toast. Returns whether an ad was shown. Fire-and-forget - * — the caller does not await a result to proceed. + * suppressed (no-ads), online, inside VK (the stub bypasses VK), and the required gap has elapsed + * since the last interstitial of any kind. The kind picks the gap; the timer is shared, so a hint ad + * and a move ad never fire within a cooldown of each other. The stub (contour test) shows an "ad + * fired" toast. Returns whether an ad was shown. Fire-and-forget — the caller does not await it. */ export async function maybeShowInterstitial( ads: AdsConfig | undefined, @@ -95,8 +100,8 @@ export async function maybeShowInterstitial( if (!stub && executionContext() !== 'vk') return false; const cooldownS = kind === 'hint' ? ads.cooldownHintS : opts.vsAi ? ads.cooldownVsAiS : ads.cooldownGlobalS; const now = Date.now(); - if (now - interstitialLast()[kind] < cooldownS * 1000) return false; + if (now - interstitialLast() < cooldownS * 1000) return false; const shown = stub ? (showToast('ad fired'), true) : await vkShowInterstitial(); - if (shown) recordInterstitial(kind, now); + if (shown) recordInterstitial(now); return shown; }