feat(ads): VK post-move interstitial + retire deprecated hint_balance/paid_account
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Interstitial video after a confirmed play or a hint, VK-only, offline banner-only. The gate is client-mirrored: the backend puts the config cooldowns (global 5m / vs_ai 30m / hint 1m) and a suppressed flag (the no-ads / no_banner gate, same as the banner) on Profile.ads via adsFor; the client self-gates on a per-kind last-shown time in localStorage, with the VITE_ADS_STUB contour "ad fired" toast. Never fires after a pass, exchange or resign. maybeShowInterstitial + vkShowInterstitial; the codec and gateway transcode carry the ads block. Also retires the deprecated accounts.hint_balance / paid_account domain usage (expand-contract, code only — the columns stay for a later DROP so image rollback stays DB-safe): drop the Account fields and their scan, the dead account.SpendHint, account.GrantHints and the admin grant-hints action; the in-game hint display now comes wholly from the payments hint benefit. Banner eligibility and account merge no longer read the legacy flags. Tests: ads.test.ts (the client-mirrored gate), the codec ads round-trip, the gateway ads transcode test, and a profile ads-config integration test (cooldowns + suppressed under no-ads / no_banner). Docs: PAYMENTS §10 (+ru) interstitial, the decision amends, backend README, the plan.
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AdsConfig } from './model';
|
||||
|
||||
// Mock ads.ts's three impure imports so the client-mirrored gate is observable without a VK
|
||||
// bridge, a wallet context or the Svelte toast store. ./model is type-only (erased).
|
||||
const mocks = vi.hoisted(() => ({
|
||||
vkShowInterstitial: vi.fn(),
|
||||
vkRewardedReady: vi.fn(),
|
||||
vkShowRewarded: vi.fn(),
|
||||
executionContext: vi.fn(),
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
vi.mock('./vk', () => ({
|
||||
vkShowInterstitial: mocks.vkShowInterstitial,
|
||||
vkRewardedReady: mocks.vkRewardedReady,
|
||||
vkShowRewarded: mocks.vkShowRewarded,
|
||||
}));
|
||||
vi.mock('./wallet', () => ({ executionContext: mocks.executionContext }));
|
||||
vi.mock('./app.svelte', () => ({ showToast: mocks.showToast }));
|
||||
|
||||
import { maybeShowInterstitial } from './ads';
|
||||
|
||||
const ADS: AdsConfig = { cooldownGlobalS: 300, cooldownVsAiS: 1800, cooldownHintS: 60, suppressed: false };
|
||||
const ONLINE = { vsAi: false, online: true };
|
||||
// A realistic epoch base: the never-shown last time is 0, so the first show needs now >> cooldown
|
||||
// (as with a real Date.now); anchoring at 0 would gate the very first call (now - 0 < cooldown).
|
||||
const BASE = 1_700_000_000_000;
|
||||
|
||||
beforeEach(() => {
|
||||
// A minimal in-memory localStorage (node has none) so the per-kind last-shown gate persists
|
||||
// across calls within a test.
|
||||
const store = new Map<string, string>();
|
||||
(globalThis as unknown as { localStorage: Storage }).localStorage = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
key: () => null,
|
||||
length: 0,
|
||||
} as Storage;
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(BASE);
|
||||
mocks.executionContext.mockReturnValue('vk');
|
||||
mocks.vkShowInterstitial.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('maybeShowInterstitial (client-mirrored gate)', () => {
|
||||
it('does not show when the config is absent', async () => {
|
||||
expect(await maybeShowInterstitial(undefined, 'move', ONLINE)).toBe(false);
|
||||
expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show when suppressed (no-ads / no_banner)', async () => {
|
||||
expect(await maybeShowInterstitial({ ...ADS, suppressed: true }, 'move', ONLINE)).toBe(false);
|
||||
expect(mocks.showToast).not.toHaveBeenCalled();
|
||||
expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show when offline', async () => {
|
||||
expect(await maybeShowInterstitial(ADS, 'move', { vsAi: false, online: false })).toBe(false);
|
||||
expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not show outside VK (real ad path)', async () => {
|
||||
mocks.executionContext.mockReturnValue('web');
|
||||
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(false);
|
||||
expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows a VK interstitial inside VK when the cooldown allows', async () => {
|
||||
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
|
||||
expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.showToast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('respects the per-kind 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);
|
||||
vi.setSystemTime(BASE + 300_000); // the cooldown has now elapsed
|
||||
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
|
||||
expect(mocks.vkShowInterstitial).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('tracks move and hint cooldowns independently', async () => {
|
||||
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
|
||||
expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(false);
|
||||
vi.setSystemTime(BASE + 60_000);
|
||||
expect(await maybeShowInterstitial(ADS, 'hint', ONLINE)).toBe(true);
|
||||
});
|
||||
|
||||
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
|
||||
expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(false);
|
||||
vi.setSystemTime(BASE + 1_800_000);
|
||||
expect(await maybeShowInterstitial(ADS, 'move', { vsAi: true, online: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the test-stub toast instead of a real ad when the stub flag is set', async () => {
|
||||
vi.stubEnv('VITE_ADS_STUB', '1');
|
||||
mocks.executionContext.mockReturnValue('web'); // the stub bypasses the VK gate
|
||||
expect(await maybeShowInterstitial(ADS, 'move', ONLINE)).toBe(true);
|
||||
expect(mocks.showToast).toHaveBeenCalledWith('ad fired');
|
||||
expect(mocks.vkShowInterstitial).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+55
-1
@@ -4,7 +4,9 @@
|
||||
// flow is testable without waiting for a real ad — production never sets the flag, so a real ad that
|
||||
// fails to load there must not credit (no stub fallback in prod).
|
||||
import { executionContext } from './wallet';
|
||||
import { vkRewardedReady, vkShowRewarded } from './vk';
|
||||
import { vkRewardedReady, vkShowRewarded, vkShowInterstitial } from './vk';
|
||||
import { showToast } from './app.svelte';
|
||||
import type { AdsConfig } from './model';
|
||||
|
||||
/**
|
||||
* adsStubEnabled reports the contour test stub (VITE_ADS_STUB=1). Production never sets it, so it
|
||||
@@ -46,3 +48,55 @@ export async function showRewarded(): Promise<RewardedResult> {
|
||||
}
|
||||
return { watched: await vkShowRewarded(), stub: false };
|
||||
}
|
||||
|
||||
// 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.
|
||||
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 } {
|
||||
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 };
|
||||
}
|
||||
} catch {
|
||||
/* a corrupt or unavailable store simply resets the gate */
|
||||
}
|
||||
return { move: 0, hint: 0 };
|
||||
}
|
||||
|
||||
function recordInterstitial(kind: 'move' | 'hint', now: number): void {
|
||||
try {
|
||||
const l = interstitialLast();
|
||||
l[kind] = now;
|
||||
localStorage.setItem(INTERSTITIAL_LAST_KEY, JSON.stringify(l));
|
||||
} catch {
|
||||
/* ignore a write failure — the worst case is one extra ad next time */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function maybeShowInterstitial(
|
||||
ads: AdsConfig | undefined,
|
||||
kind: 'move' | 'hint',
|
||||
opts: { vsAi: boolean; online: boolean },
|
||||
): Promise<boolean> {
|
||||
if (!ads || ads.suppressed || !opts.online) return false;
|
||||
const stub = adsStubEnabled();
|
||||
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;
|
||||
const shown = stub ? (showToast('ad fired'), true) : await vkShowInterstitial();
|
||||
if (shown) recordInterstitial(kind, now);
|
||||
return shown;
|
||||
}
|
||||
|
||||
@@ -814,6 +814,42 @@ describe('codec', () => {
|
||||
expect(decodeProfile(b.asUint8Array()).banner).toBeUndefined();
|
||||
});
|
||||
|
||||
it('decodes the profile interstitial-ad config', () => {
|
||||
const b = new Builder(64);
|
||||
const uid = b.createString('u-1');
|
||||
const ads = fb.AdsInfo.createAdsInfo(b, 300, 1800, 60, false);
|
||||
fb.Profile.startProfile(b);
|
||||
fb.Profile.addUserId(b, uid);
|
||||
fb.Profile.addAds(b, ads);
|
||||
b.finish(fb.Profile.endProfile(b));
|
||||
expect(decodeProfile(b.asUint8Array()).ads).toEqual({
|
||||
cooldownGlobalS: 300,
|
||||
cooldownVsAiS: 1800,
|
||||
cooldownHintS: 60,
|
||||
suppressed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('carries the suppressed flag through the ad config', () => {
|
||||
const b = new Builder(64);
|
||||
const uid = b.createString('u-1');
|
||||
const ads = fb.AdsInfo.createAdsInfo(b, 300, 1800, 60, true);
|
||||
fb.Profile.startProfile(b);
|
||||
fb.Profile.addUserId(b, uid);
|
||||
fb.Profile.addAds(b, ads);
|
||||
b.finish(fb.Profile.endProfile(b));
|
||||
expect(decodeProfile(b.asUint8Array()).ads?.suppressed).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves the profile ads undefined when absent', () => {
|
||||
const b = new Builder(64);
|
||||
const uid = b.createString('u-1');
|
||||
fb.Profile.startProfile(b);
|
||||
fb.Profile.addUserId(b, uid);
|
||||
b.finish(fb.Profile.endProfile(b));
|
||||
expect(decodeProfile(b.asUint8Array()).ads).toBeUndefined();
|
||||
});
|
||||
|
||||
it('decodes the profile variant preferences', () => {
|
||||
const b = new Builder(64);
|
||||
const uid = b.createString('u-1');
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
RobotBlockEntry,
|
||||
RobotFriendRequestEntry,
|
||||
Banner,
|
||||
AdsConfig,
|
||||
BannerCampaign,
|
||||
BestMove,
|
||||
BestMoveTile,
|
||||
@@ -461,6 +462,7 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
||||
notificationsInAppOnly: p.notificationsInAppOnly(),
|
||||
variantPreferences: decodeVariantPreferences(p),
|
||||
banner: decodeBanner(p),
|
||||
ads: decodeAds(p),
|
||||
email: s(p.email()),
|
||||
telegramLinked: p.telegramLinked(),
|
||||
vkLinked: p.vkLinked(),
|
||||
@@ -497,6 +499,19 @@ function bannerTriple(bg: string | null, fg: string | null, link: string | null)
|
||||
return bg && fg && link ? { bg, fg, link } : null;
|
||||
}
|
||||
|
||||
// decodeAds projects the optional post-move interstitial config of a Profile, or undefined when
|
||||
// absent (an old backend); the client then shows no interstitial.
|
||||
function decodeAds(p: fb.Profile): AdsConfig | undefined {
|
||||
const a = p.ads();
|
||||
if (!a) return undefined;
|
||||
return {
|
||||
cooldownGlobalS: a.cooldownGlobalS(),
|
||||
cooldownVsAiS: a.cooldownVsAiS(),
|
||||
cooldownHintS: a.cooldownHintS(),
|
||||
suppressed: a.suppressed(),
|
||||
};
|
||||
}
|
||||
|
||||
// decodeBanner projects the optional advertising-banner block of a Profile, or
|
||||
// undefined when the viewer is not eligible (the field is absent).
|
||||
function decodeBanner(p: fb.Profile): Banner | undefined {
|
||||
|
||||
@@ -45,6 +45,8 @@ export const PROFILE: Profile = {
|
||||
// Every variant's current dictionary version, as the backend advertises it on the profile —
|
||||
// the offline preloader targets `variant@version`.
|
||||
dictVersions: { erudit_ru: 'v1.3.0', scrabble_ru: 'v1.3.0', scrabble_en: 'v1.3.0' },
|
||||
// Post-move interstitial config (short cooldowns so the mock stub is exercisable); not suppressed.
|
||||
ads: { cooldownGlobalS: 300, cooldownVsAiS: 1800, cooldownHintS: 60, suppressed: false },
|
||||
};
|
||||
|
||||
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
||||
|
||||
@@ -226,6 +226,8 @@ export interface Profile {
|
||||
variantPreferences: Variant[];
|
||||
/** The advertising-banner block, present only for a viewer eligible to see it. */
|
||||
banner?: Banner;
|
||||
/** The post-move interstitial-ad config for the client-mirrored gate (cooldowns + suppressed). */
|
||||
ads?: AdsConfig;
|
||||
/** The account's confirmed email address ("" when none). */
|
||||
email: string;
|
||||
/** Whether a Telegram / VK identity is attached — drives the Add / Unlink controls. */
|
||||
@@ -237,6 +239,15 @@ export interface Profile {
|
||||
dictVersions: Partial<Record<Variant, string>>;
|
||||
}
|
||||
|
||||
/** The post-move interstitial-ad config for the client-mirrored gate: the cooldowns (seconds) and
|
||||
* whether ads are suppressed in the current context (a no-ads benefit here, or the no_banner role). */
|
||||
export interface AdsConfig {
|
||||
cooldownGlobalS: number;
|
||||
cooldownVsAiS: number;
|
||||
cooldownHintS: number;
|
||||
suppressed: boolean;
|
||||
}
|
||||
|
||||
/** Banner is the advertising-banner block of an eligible viewer's profile. */
|
||||
export interface Banner {
|
||||
campaigns: BannerCampaign[];
|
||||
|
||||
@@ -225,6 +225,20 @@ export async function vkShowRewarded(): Promise<boolean> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkShowInterstitial shows a between-screens interstitial ad (VKWebAppShowNativeAds, interstitial
|
||||
* format) and reports whether it was shown. It carries no reward — it is the post-move fullscreen ad.
|
||||
* A rejected or unavailable call reports false.
|
||||
*/
|
||||
export async function vkShowInterstitial(): Promise<boolean> {
|
||||
try {
|
||||
const data = await (await bridge()).send('VKWebAppShowNativeAds', { ad_format: 'interstitial' });
|
||||
return !!(data as { result?: boolean }).result;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile) —
|
||||
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
|
||||
|
||||
Reference in New Issue
Block a user