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

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:
Ilia Denisov
2026-07-10 02:48:10 +02:00
parent e08d3301bd
commit 13be7c3d9a
33 changed files with 805 additions and 220 deletions
+116
View File
@@ -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();
});
});