Release v1.14.0 — monetization launch (E5-E8) #237
+4
-3
@@ -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
|
**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`
|
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
|
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
|
the client self-gates on a **single shared** last-shown time in `localStorage` — the kind only
|
||||||
server round-trip. The contour `VITE_ADS_STUB` swaps the same "ad fired" toast for the real ad.
|
picks the required gap, so a hint ad and a move ad never fire within a cooldown of each other
|
||||||
Values:
|
(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**.
|
- 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).
|
- **`vs_ai` — 30 min** (aligned with the hint cooldown, so it does not scare casual players).
|
||||||
|
|||||||
@@ -176,10 +176,14 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
|||||||
**независимо** от основного кулдауна, со своим кулдауном **1 мин**. Оффлайн — только
|
**независимо** от основного кулдауна, со своим кулдауном **1 мин**. Оффлайн — только
|
||||||
баннер. Уважать собственные лимиты частоты VK.
|
баннер. Уважать собственные лимиты частоты VK.
|
||||||
**АМЕНД (E6, по факту реализации):** гейт **зеркальный** — сервер отдаёт кулдауны и `suppressed`
|
**АМЕНД (E6, по факту реализации):** гейт **зеркальный** — сервер отдаёт кулдауны и `suppressed`
|
||||||
в профиле (`adsFor`), клиент сам гейтит по времени последнего показа на каждый вид в `localStorage`
|
в профиле (`adsFor`), клиент сам гейтит по **единому** времени последнего показа в `localStorage`
|
||||||
(без раунд-трипа на ход). Ролик показывается **только после подтверждённого хода или подсказки** —
|
(без раунд-трипа на ход). Таймер **общий на все виды** — вид (`hint`/`move`/`vs_ai`) лишь выбирает
|
||||||
**не** после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»).
|
нужный интервал, поэтому «независимость» подсказочного кулдауна значит лишь **более короткий
|
||||||
Interstitial — **только VK** (как и rewarded).
|
интервал от последнего показа**, а не отдельный таймер: hint-ролик и move-ролик не встают подряд
|
||||||
|
(баг раздельных таймеров: после hint-ролика move-таймер оставался нулевым → следующий ход сразу
|
||||||
|
крутил рекламу). Ролик показывается **только после подтверждённого хода или подсказки** — **не**
|
||||||
|
после пропуска, обмена или сдачи (за не-очковое действие рекламой не «награждаем»). Interstitial —
|
||||||
|
**только VK** (как и rewarded). Частота — глобальная на все игры (localStorage на устройство).
|
||||||
- **D31. `paid_account` тоже deprecated** → удаление из схемы (как `hint_balance`), в
|
- **D31. `paid_account` тоже deprecated** → удаление из схемы (как `hint_balance`), в
|
||||||
пользу per-origin бенефитов «без рекламы». Существующий `ads.Eligible`
|
пользу per-origin бенефитов «без рекламы». Существующий `ads.Eligible`
|
||||||
(`backend/internal/ads/ads.go:107`) расширяется: баннер гасится по origin-бенефиту,
|
(`backend/internal/ads/ads.go:107`) расширяется: баннер гасится по origin-бенефиту,
|
||||||
|
|||||||
+4
-2
@@ -282,8 +282,10 @@ build-флаг (`VITE_ADS_STUB`) подменяет ролик тостом; п
|
|||||||
**Полноэкранный ролик** (после подтверждённого хода), **только VK**, конфигурируемые серверные
|
**Полноэкранный ролик** (после подтверждённого хода), **только VK**, конфигурируемые серверные
|
||||||
значения. Гейт **зеркалится на клиенте**: профиль несёт кулдауны и флаг `suppressed` (тот же гейт
|
значения. Гейт **зеркалится на клиенте**: профиль несёт кулдауны и флаг `suppressed` (тот же гейт
|
||||||
«без рекламы» / `no_banner`, что и у баннера, считается на сервере в `adsFor`), а клиент сам
|
«без рекламы» / `no_banner`, что и у баннера, считается на сервере в `adsFor`), а клиент сам
|
||||||
гейтит по времени последнего показа **на каждый вид** в `localStorage` — без серверного раунд-трипа
|
гейтит по **единому** времени последнего показа в `localStorage` — вид лишь выбирает нужный
|
||||||
на каждый ход. Контурный `VITE_ADS_STUB` подменяет ролик тем же тостом «ad fired». Значения:
|
интервал, поэтому hint-ролик и move-ролик не встают подряд в пределах кулдауна (один общий таймер,
|
||||||
|
не по одному на вид) — без серверного раунд-трипа на каждый ход. Контурный `VITE_ADS_STUB` подменяет
|
||||||
|
ролик тем же тостом «ad fired». Значения:
|
||||||
|
|
||||||
- Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**.
|
- Глобальный кулдаун **на пользователя, сквозь все партии**, дефолт **5 мин**.
|
||||||
- **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов).
|
- **`vs_ai` — 30 мин** (соосно кулдауну подсказок, чтобы не отпугивать казуалов).
|
||||||
|
|||||||
+16
-6
@@ -79,7 +79,7 @@ describe('maybeShowInterstitial (client-mirrored gate)', () => {
|
|||||||
expect(mocks.showToast).not.toHaveBeenCalled();
|
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);
|
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
|
||||||
vi.setSystemTime(BASE + 299_000); // +299s < the 300s global cooldown
|
vi.setSystemTime(BASE + 299_000); // +299s < the 300s global cooldown
|
||||||
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(false);
|
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(false);
|
||||||
@@ -88,16 +88,26 @@ describe('maybeShowInterstitial (client-mirrored gate)', () => {
|
|||||||
expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(2);
|
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);
|
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.
|
vi.setSystemTime(BASE + 59_000);
|
||||||
expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(true);
|
|
||||||
vi.setSystemTime(BASE + 59_000); // +59s < the 60s hint cooldown
|
|
||||||
expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(false);
|
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);
|
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 () => {
|
it('uses the longer vs_ai cooldown for a vs_ai move', async () => {
|
||||||
expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true);
|
expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true);
|
||||||
vi.setSystemTime(BASE + 1_799_000); // +1799s < the 1800s vs_ai cooldown
|
vi.setSystemTime(BASE + 1_799_000); // +1799s < the 1800s vs_ai cooldown
|
||||||
|
|||||||
+21
-16
@@ -50,29 +50,33 @@ export async function showRewarded(): Promise<RewardedResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The post-move interstitial gate is client-mirrored: the server sends the cooldowns + suppressed in
|
// 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
|
// the profile (Profile.ads); the client tracks the last-shown time in localStorage and self-gates.
|
||||||
// self-gates. Last-shown is per-device (cleared with storage) — acceptable for a frequency gate.
|
// 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';
|
const INTERSTITIAL_LAST_KEY = 'ads.interstitial.last';
|
||||||
|
|
||||||
// interstitialLast reads the last-shown epoch millis per kind (0 when absent/corrupt).
|
// interstitialLast reads the last-shown epoch millis of any interstitial (0 when absent/corrupt, or
|
||||||
function interstitialLast(): { move: number; hint: number } {
|
// when a pre-shared-timer value — an object — is still stored: it decodes to 0 and self-heals).
|
||||||
|
function interstitialLast(): number {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(INTERSTITIAL_LAST_KEY);
|
const raw = localStorage.getItem(INTERSTITIAL_LAST_KEY);
|
||||||
if (raw) {
|
if (raw) {
|
||||||
const j = JSON.parse(raw) as Partial<{ move: number; hint: number }>;
|
const n = Number(raw);
|
||||||
return { move: j.move ?? 0, hint: j.hint ?? 0 };
|
return Number.isFinite(n) ? n : 0;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* a corrupt or unavailable store simply resets the gate */
|
/* 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 {
|
try {
|
||||||
const l = interstitialLast();
|
localStorage.setItem(INTERSTITIAL_LAST_KEY, String(now));
|
||||||
l[kind] = now;
|
|
||||||
localStorage.setItem(INTERSTITIAL_LAST_KEY, JSON.stringify(l));
|
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore a write failure — the worst case is one extra ad next time */
|
/* 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
|
* 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
|
* (`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.
|
* suppressed (no-ads), online, inside VK (the stub bypasses VK), and the required gap has elapsed
|
||||||
* The stub (contour test) shows an "ad fired" toast. Returns whether an ad was shown. Fire-and-forget
|
* since the last interstitial of any kind. The kind picks the gap; the timer is shared, so a hint ad
|
||||||
* — the caller does not await a result to proceed.
|
* 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(
|
export async function maybeShowInterstitial(
|
||||||
ads: AdsConfig | undefined,
|
ads: AdsConfig | undefined,
|
||||||
@@ -95,8 +100,8 @@ export async function maybeShowInterstitial(
|
|||||||
if (!stub && executionContext() !== 'vk') return false;
|
if (!stub && executionContext() !== 'vk') return false;
|
||||||
const cooldownS = kind === 'hint' ? ads.cooldownHintS : opts.vsAi ? ads.cooldownVsAiS : ads.cooldownGlobalS;
|
const cooldownS = kind === 'hint' ? ads.cooldownHintS : opts.vsAi ? ads.cooldownVsAiS : ads.cooldownGlobalS;
|
||||||
const now = Date.now();
|
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();
|
const shown = stub ? (showToast('ad fired'), true) : await vkShowInterstitial();
|
||||||
if (shown) recordInterstitial(kind, now);
|
if (shown) recordInterstitial(now);
|
||||||
return shown;
|
return shown;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user