// The ads-network abstraction (D28). VK is the only network now; a future network for another // platform implements the same rewardedReady/showRewarded surface without touching the callers. On // the contour a build flag (VITE_ADS_STUB) substitutes a toast stub for the real ad, so the reward // 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, 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 * always shows real ads. The mock e2e forces it on so the reward flow is exercisable without a VK * bridge. To capture the real VK ad result on the contour (the diagnostic), deploy with the flag * off first; flip it on afterwards for routine stub testing. */ export function adsStubEnabled(): boolean { if (import.meta.env.MODE === 'mock') return true; return (import.meta.env as Record).VITE_ADS_STUB === '1'; } /** RewardedResult is one rewarded-ad view: whether it was watched, and whether it was the test stub * (so the caller can show the "ad fired" marker toast). */ export interface RewardedResult { watched: boolean; stub: boolean; } /** * rewardedReady reports whether a rewarded ad is ready to show, gating the "watch for chips" button. * The stub is always ready; a real ad is ready only inside VK with preloaded material. Rewarded is * VK-only (D28). */ export async function rewardedReady(): Promise { if (adsStubEnabled()) return true; if (executionContext() !== 'vk') return false; return vkRewardedReady(); } /** * showRewarded shows a rewarded ad and reports whether it was watched, the raw provider result, and * whether it was the stub. On the stub it resolves watched immediately (the caller shows the "ad * fired" toast). */ export async function showRewarded(): Promise { if (adsStubEnabled()) { return { watched: true, stub: true }; } 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 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 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 n = Number(raw); return Number.isFinite(n) ? n : 0; } } catch { /* a corrupt or unavailable store simply resets the gate */ } return 0; } function recordInterstitial(now: number): void { try { localStorage.setItem(INTERSTITIAL_LAST_KEY, String(now)); } 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 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, kind: 'move' | 'hint', opts: { vsAi: boolean; online: boolean }, ): Promise { 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() < cooldownS * 1000) return false; const shown = stub ? (showToast('ad fired'), true) : await vkShowInterstitial(); if (shown) recordInterstitial(now); return shown; }