Files
scrabble-game/ui/src/lib/ads.ts
T
Ilia Denisov b5c8a04f0b
CI / changes (pull_request) Successful in 1s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
fix(ads): share one interstitial cooldown timer across kinds
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.
2026-07-10 03:36:18 +02:00

108 lines
4.9 KiB
TypeScript

// 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<string, string | undefined>).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<boolean> {
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<RewardedResult> {
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<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() < cooldownS * 1000) return false;
const shown = stub ? (showToast('ad fired'), true) : await vkShowInterstitial();
if (shown) recordInterstitial(now);
return shown;
}