feat(client): gate in-game ads by adblock detection + Admiral recovery (#4534)

## What

Two related pieces, wired into the existing `window.adsEnabled` /
`userMeResponse` ad flow:

1. **`AdGatekeeper`** — decides whether the *intrusive* in-game ad may
show. Once a blocker is **ever** detected, the ad is suppressed
**permanently** (terminal state, persisted to
`localStorage["adblock-detected"]`). Ad-block users are highly
ad-sensitive, so disabling the blocker does **not** unlock the ad — in
this or any future session. Detection = a DOM bait probe, refined by
Admiral's `measure.detected` signal (`adblocking && !whitelisted`) when
it fires. Clean users are never latched.
2. **`Admiral.ts`** — injects the ad-recovery tag (command-queue stub +
payload + GAM targeting shim) for **ad-eligible users only**.
Paid/`adfree` users have `window.adsEnabled === false`, so Admiral never
loads and its adblock popup can't fire for them.

Only the in-game ad (`InGamePromo`) is gated — it now loads via
`adGatekeeper.whenClear(...)`. Passive homepage/gutter ads are
unchanged.

## Why

- Paid users (any shop purchase → `adfree` for life) must never see ads
*or* load Admiral.
- Free adblock users get Admiral's recovery popup, but should never be
hit with an intrusive in-game ad even if they disable their blocker.

## How it behaves

| Visitor | Admiral | In-game ad |
|---|---|---|
| Paid (`adfree`) | never loaded | never shown |
| Free, no adblock | loaded | shown |
| Free, adblock on (or ever was) | loaded (recovery popup) | suppressed
forever |
| Free, adblock blocks Admiral too | callback never fires | bait
fallback suppresses |

## Testing

- **Unit:** `tests/AdGatekeeper.test.ts` (9 cases) — terminal latch,
"disabling blocker doesn't unlock", cross-session persistence, seed
path, no-false-positive. `tsc` clean, `eslint` clean.
- **Manual (headless Chromium, real bootstrap):** free user →
`adsEnabled: true`, Admiral tag injected + payload initialized,
`persisted: null` (no false positive); simulated blocker → flag latches
to `"1"`; reload with no blocker → still `"1"` (forever); reset clean
afterward.

## Notes / follow-ups

- The GAM targeting shim (block 3 of the provider's tag) is ported
verbatim but is likely a no-op here since serving is via Playwire RAMP,
not Google Ad Manager. Kept for fidelity; can drop if unused.
- `ADMIRAL_PAYLOAD_SRC` is a disguised, rotating domain — re-sync from
the provider when they reissue the tag.
- Admiral's own popup is dashboard-configured and typically
domain-locked; best verified on the production domain with a real
blocker.
- `res.subscribed` (Admiral's own ad-free pass) is intentionally ignored
— OpenFront's ad-free is the server `adfree` flag.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-16 15:13:23 -07:00
committed by evanpelle
co-authored by Claude Opus 4.8
parent 16be9d7c15
commit e3676439d2
5 changed files with 496 additions and 5 deletions
+181
View File
@@ -0,0 +1,181 @@
export type AdblockState = "blocked" | "clear";
type Listener = (state: AdblockState, canShowAds: boolean) => void;
/**
* Once a blocker is ever detected we suppress the in-game ad forever, so the
* verdict is persisted. Safe to store client-side: forging it only opts a user
* OUT of ads (which a blocker already does), so there's nothing to exploit.
*/
const ADBLOCK_STORAGE_KEY = "adblock-detected";
export interface AdGatekeeperOptions {
/** Background re-check interval. Kept lazy — aggressive polling draws filter-list heat. */
pollMs?: number;
/**
* Adblock probe. Defaults to a DOM bait check; injectable so the state
* machine can be unit-tested without a real blocker (jsdom does no layout,
* so the bait always reads "blocked" there).
*/
probe?: () => Promise<boolean>;
}
/**
* Decides whether the *intrusive* in-game ad may show.
*
* Ad-block users are far more ad-sensitive, so once we ever detect a blocker
* this session the ad is suppressed PERMANENTLY — disabling the blocker does
* NOT unlock it. The ad shows only for users who have been continuously
* blocker-free. `canShowAds` is true only in 'clear'; 'blocked' is terminal.
*
* Orthogonal to `window.adsEnabled` (the entitlement gate for adfree /
* CrazyGames users). Construct/start it only for ad-eligible users — paid /
* adfree users never build one, so no bait element or polling runs for them.
* A fast external signal (e.g. Admiral's `measure.detected`) feeds `seed()`.
*/
export class AdGatekeeper {
private state: AdblockState | null = null;
private listeners = new Set<Listener>();
private pollTimer: ReturnType<typeof setInterval> | null = null;
private started = false;
private readonly pollMs: number;
private readonly probe: () => Promise<boolean>;
constructor(opts: AdGatekeeperOptions = {}) {
this.pollMs = opts.pollMs ?? 15000;
this.probe = opts.probe ?? (() => this.baitBlocked());
}
/** True only once we've confirmed the user has been blocker-free all session. */
get canShowAds(): boolean {
return this.state === "clear";
}
/** Subscribe to state changes. Emits the current state immediately if known. */
subscribe(fn: Listener): () => void {
this.listeners.add(fn);
if (this.state !== null) fn(this.state, this.canShowAds);
return () => this.listeners.delete(fn);
}
/**
* Run `fn` once the gate is (or becomes) clear. Fires synchronously if
* already clear. Never fires once 'blocked' has latched. Returns an
* unsubscribe for the still-pending case.
*/
whenClear(fn: () => void): () => void {
if (this.canShowAds) {
fn();
return () => {};
}
const off = this.subscribe((_state, canShowAds) => {
if (canShowAds) {
off();
fn();
}
});
return off;
}
/**
* Feed an external adblock reading (e.g. Admiral's `measure.detected`) into
* the state machine as a fast, reliable signal. Ignored until started and
* once 'blocked' has latched.
*/
seed(blocked: boolean): void {
if (!this.started) return;
this.applyReading(blocked);
}
start(): void {
if (this.started) return;
this.started = true;
// A blocker detected in any past session suppresses the ad forever — no
// need to probe or listen at all.
if (readPersistedBlock()) {
this.transition("blocked");
return;
}
void this.evaluate();
// Toggling an extension means leaving the tab and coming back — re-check on
// return. Cheap, event-driven, and catches a mid-session enable before the
// in-game ad fires, without hammering a poll.
document.addEventListener("visibilitychange", this.onVisibility);
window.addEventListener("focus", this.onFocus);
this.pollTimer = setInterval(() => void this.evaluate(), this.pollMs);
}
stop(): void {
this.stopProbing();
this.listeners.clear();
this.started = false;
}
private stopProbing(): void {
if (this.pollTimer !== null) clearInterval(this.pollTimer);
this.pollTimer = null;
document.removeEventListener("visibilitychange", this.onVisibility);
window.removeEventListener("focus", this.onFocus);
}
private onVisibility = (): void => {
if (!document.hidden) void this.evaluate();
};
private onFocus = (): void => void this.evaluate();
private async evaluate(): Promise<void> {
if (this.state === "blocked") return; // terminal — nothing left to check
const blocked = await this.probe();
if (!this.started) return; // stopped mid-probe; applyReading guards a late latch
this.applyReading(blocked);
}
/** Fold a raw adblock reading into the state machine. 'blocked' is terminal. */
private applyReading(blocked: boolean): void {
if (this.state === "blocked") return; // once blocked, always blocked
if (blocked) {
persistBlock(); // suppress the in-game ad in this and every future session
this.transition("blocked");
this.stopProbing(); // suppressed forever — no need to keep probing
return;
}
this.transition("clear");
}
private transition(next: AdblockState): void {
if (next === this.state) return;
this.state = next;
for (const fn of this.listeners) fn(next, this.canShowAds);
}
/** DOM bait detector: a blocker hides/collapses elements with ad-like classes. */
private async baitBlocked(): Promise<boolean> {
const bait = document.createElement("div");
bait.className = "adsbox ad-banner pub_300x250";
bait.style.cssText = "position:absolute;left:-9999px;width:1px;height:1px;";
document.body.appendChild(bait);
await new Promise(requestAnimationFrame); // let the blocker act
const blocked = bait.offsetHeight === 0 || bait.offsetParent === null;
bait.remove();
return blocked;
}
}
function readPersistedBlock(): boolean {
try {
return window.localStorage.getItem(ADBLOCK_STORAGE_KEY) === "1";
} catch {
return false; // storage disabled (private mode, etc.)
}
}
function persistBlock(): void {
try {
window.localStorage.setItem(ADBLOCK_STORAGE_KEY, "1");
} catch {
/* best-effort; falls back to session-only suppression */
}
}
export const adGatekeeper = new AdGatekeeper();
+122
View File
@@ -0,0 +1,122 @@
export interface AdmiralMeasureResult {
/** True when the visitor has an adblocker active. */
adblocking?: boolean;
/**
* Admiral's OWN ad-free pass (its paywall), NOT our tier. Intentionally
* ignored: OpenFront ad-free is the server `adfree` flag (any shop purchase →
* ad-free for life), which already zeroes `window.adsEnabled` so Admiral
* never loads for those users. Only wire this if we ever sell via Admiral.
*/
subscribed?: boolean;
/** True when the visitor has whitelisted this site in their blocker (ads render). */
whitelisted?: boolean;
}
type AdmiralFn = {
(
hook: "after",
event: "measure.detected",
cb: (res: AdmiralMeasureResult) => void,
): void;
q?: unknown[];
v?: number;
s?: string;
};
interface GoogleTag {
cmd: Array<() => void>;
pubads?: () => { setTargeting: (key: string, value: string) => void };
}
declare global {
interface Window {
admiral?: AdmiralFn;
googletag?: GoogleTag;
}
}
/**
* Admiral ad-recovery payload. The delivery domain is intentionally disguised
* and ROTATES — when the provider reissues the tag, re-sync this from it.
* This third-party script runs with full page access, so it is injected ONLY
* for ad-eligible (non-adfree) users and NEVER for paid sessions.
*/
const ADMIRAL_PAYLOAD_SRC =
"https://introjava.com/assets/js/gfjjtpm64er_5.v1.js";
/**
* localStorage key Admiral writes its GAM targeting segments (`.lgk`) to.
* Encodes the Admiral property id; re-sync alongside ADMIRAL_PAYLOAD_SRC.
*/
const ADMIRAL_GAM_KEY = "_aQS02Mzg3RDEwMjU5NjBGOUQ0REY5Q0YwOTEtNjc0";
let injected = false;
/**
* Injects the Admiral tag. Call for FREE (ad-eligible) users ONLY — gating
* happens at the single call site so paid users never load Admiral at all
* (its adblock popup fires autonomously once the payload runs).
*/
export function loadAdmiral(): void {
if (injected) return;
injected = true;
// 1. Command-queue stub — must exist before the payload loads so buffered
// admiral(...) calls replay once it initializes (same pattern as gtag).
if (!window.admiral) {
const stub = function (...args: unknown[]): void {
(stub.q = stub.q ?? []).push(args);
} as AdmiralFn;
stub.v = 2;
stub.s = "1";
window.admiral = stub;
}
// 2. Async-load the remote payload.
const s = document.createElement("script");
s.async = true;
s.src = ADMIRAL_PAYLOAD_SRC;
document.head.appendChild(s);
// 3. GAM key-value shim: replay Admiral's stored targeting segments into
// Google Ad Manager. Self-guarded; harmlessly no-ops if GAM isn't used
// to serve (this app serves via Playwire RAMP).
applyGamTargeting();
}
function applyGamTargeting(): void {
const push = (): void => {
try {
const raw = window.localStorage.getItem(ADMIRAL_GAM_KEY);
if (raw === null) return;
const lgk: Array<[string, string?]> = JSON.parse(raw).lgk ?? [];
const pubads = window.googletag?.pubads?.();
if (!pubads) return;
for (const entry of lgk) {
if (entry?.[0]) pubads.setTargeting(entry[0], entry[1] ?? "");
}
} catch {
/* targeting is best-effort */
}
};
try {
const gt = (window.googletag = window.googletag ?? { cmd: [] });
gt.cmd = gt.cmd ?? [];
if (typeof gt.pubads === "function") push();
else gt.cmd.unshift(push);
} catch {
/* targeting is best-effort */
}
}
/**
* Registers Admiral's measurement callback. Safe to call before the payload
* loads (buffered in the command queue). Fires only if Admiral's payload
* actually loads — a blocker that kills the delivery domain also kills this,
* which is why AdGatekeeper's bait detector remains the source of truth.
*/
export function onAdmiralMeasured(
cb: (res: AdmiralMeasureResult) => void,
): void {
window.admiral?.("after", "measure.detected", cb);
}
+18
View File
@@ -14,6 +14,8 @@ import { GameEnv } from "../core/configuration/Config";
import { GameType } from "../core/game/Game";
import { UserSettings } from "../core/game/UserSettings";
import "./AccountModal";
import { adGatekeeper } from "./AdGatekeeper";
import { loadAdmiral, onAdmiralMeasured } from "./Admiral";
import { getUserMe, invalidateUserMe } from "./Api";
import { reauthAfterCrazyGamesChange, userAuth } from "./Auth";
import "./ClanModal";
@@ -486,6 +488,22 @@ class Client {
const isAdFree =
userMeResponse !== false && userMeResponse.player?.adfree === true;
window.adsEnabled = !isAdFree && !crazyGamesSDK.isOnCrazyGames();
// Ad-eligible users only: paid/adfree users must never load Admiral (its
// adblock popup fires autonomously once the payload runs). Start watching
// adblock state; once a blocker is ever detected the in-game ad is
// suppressed forever (persisted) — those users are highly ad-sensitive.
if (window.adsEnabled) {
loadAdmiral();
// Admiral's read is more reliable than our DOM bait, so use it as a
// fast initial signal. A blocker that whitelists this site still shows
// ads, so "blocked" means adblocking AND not whitelisted.
onAdmiralMeasured((res) => {
adGatekeeper.seed(
res.adblocking === true && res.whitelisted !== true,
);
});
adGatekeeper.start();
}
document.dispatchEvent(
new CustomEvent("userMeResponse", {
detail: userMeResponse,
+16 -5
View File
@@ -1,5 +1,6 @@
import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { adGatekeeper } from "../../AdGatekeeper";
import { Controller } from "../../Controller";
import { GameView } from "../../view";
@@ -18,6 +19,7 @@ export class InGamePromo extends LitElement implements Controller {
private bottomRailDestroyed: boolean = false;
private cornerAdShown: boolean = false;
private adCheckInterval: ReturnType<typeof setTimeout> | null = null;
private adGateOff: (() => void) | null = null;
createRenderRoot() {
return this;
@@ -56,12 +58,17 @@ export class InGamePromo extends LitElement implements Controller {
if (!window.adsEnabled) return;
this.shouldShow = true;
this.requestUpdate();
// Show the intrusive in-game ad only to users who have been blocker-free.
// Once a blocker is ever detected the gate latches suppressed forever
// (persisted across sessions), so whenClear never fires for those users.
this.adGateOff = adGatekeeper.whenClear(() => {
this.shouldShow = true;
this.requestUpdate();
this.updateComplete.then(() => {
this.loadAd();
this.checkForAds();
this.updateComplete.then(() => {
this.loadAd();
this.checkForAds();
});
});
}
@@ -111,6 +118,10 @@ export class InGamePromo extends LitElement implements Controller {
}
public hideAd(): void {
if (this.adGateOff) {
this.adGateOff();
this.adGateOff = null;
}
if (this.adCheckInterval) {
clearInterval(this.adCheckInterval);
this.adCheckInterval = null;
+159
View File
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AdGatekeeper } from "../src/client/AdGatekeeper";
const STORAGE_KEY = "adblock-detected";
// A probe whose reading we flip at will, so we can drive the state machine
// without a real adblocker (jsdom does no layout, so the DOM bait is useless).
function controllableProbe() {
const ref = { blocked: false };
return { ref, probe: () => Promise.resolve(ref.blocked) };
}
// Flush the async probe microtask (no timers involved in the state machine now).
const flush = () => vi.advanceTimersByTimeAsync(0);
describe("AdGatekeeper", () => {
beforeEach(() => {
vi.useFakeTimers();
localStorage.clear();
});
afterEach(() => vi.useRealTimers());
it("clears (shows ads) for a user who has been blocker-free", async () => {
const { probe } = controllableProbe(); // adblock off
const gate = new AdGatekeeper({ probe });
let cleared = 0;
gate.whenClear(() => cleared++);
gate.start();
await flush();
expect(gate.canShowAds).toBe(true);
expect(cleared).toBe(1);
expect(localStorage.getItem(STORAGE_KEY)).toBeNull(); // clean users aren't persisted
gate.stop();
});
it("stays blocked while adblock is on", async () => {
const { ref, probe } = controllableProbe();
ref.blocked = true;
const gate = new AdGatekeeper({ probe });
gate.start();
await flush();
expect(gate.canShowAds).toBe(false);
gate.stop();
});
it("is terminal: disabling the blocker does NOT unlock ads", async () => {
const { ref, probe } = controllableProbe();
ref.blocked = true;
const gate = new AdGatekeeper({ probe });
let cleared = 0;
gate.whenClear(() => cleared++);
gate.start();
await flush();
expect(gate.canShowAds).toBe(false);
// User turns the blocker off; a re-check runs but the verdict stands.
ref.blocked = false;
window.dispatchEvent(new Event("focus"));
await flush();
expect(gate.canShowAds).toBe(false);
expect(cleared).toBe(0);
gate.stop();
});
it("latches blocked if the blocker is enabled after a clean start", async () => {
const { ref, probe } = controllableProbe(); // starts off → clear
const gate = new AdGatekeeper({ probe });
gate.start();
await flush();
expect(gate.canShowAds).toBe(true);
ref.blocked = true;
window.dispatchEvent(new Event("focus"));
await flush();
expect(gate.canShowAds).toBe(false);
gate.stop();
});
it("seed latches blocked terminally", async () => {
const { probe } = controllableProbe(); // bait reads unblocked
const gate = new AdGatekeeper({ probe });
gate.start();
await flush();
expect(gate.canShowAds).toBe(true);
gate.seed(true);
expect(gate.canShowAds).toBe(false);
// A later "unblocked" seed cannot revive it.
gate.seed(false);
expect(gate.canShowAds).toBe(false);
gate.stop();
});
it("seed is ignored before start()", () => {
const { probe } = controllableProbe();
const gate = new AdGatekeeper({ probe });
gate.seed(true);
expect(gate.canShowAds).toBe(false);
});
it("whenClear fires synchronously once already clear", async () => {
const { probe } = controllableProbe();
const gate = new AdGatekeeper({ probe });
gate.start();
await flush();
expect(gate.canShowAds).toBe(true);
let fired = false;
gate.whenClear(() => (fired = true));
expect(fired).toBe(true);
gate.stop();
});
it("persists the block so a future session stays suppressed even with adblock off", async () => {
const { ref, probe } = controllableProbe();
ref.blocked = true;
const g1 = new AdGatekeeper({ probe });
g1.start();
await flush();
expect(g1.canShowAds).toBe(false);
expect(localStorage.getItem(STORAGE_KEY)).toBe("1");
g1.stop();
// New session: adblock now OFF, but the persisted verdict stands.
let fired = false;
const g2 = new AdGatekeeper({ probe: () => Promise.resolve(false) });
g2.whenClear(() => (fired = true));
g2.start();
await flush();
expect(g2.canShowAds).toBe(false);
expect(fired).toBe(false);
g2.stop();
});
it("a pre-existing persisted flag latches blocked on start without probing", async () => {
localStorage.setItem(STORAGE_KEY, "1");
let probed = false;
const gate = new AdGatekeeper({
probe: () => {
probed = true;
return Promise.resolve(false);
},
});
gate.start();
await flush();
expect(gate.canShowAds).toBe(false);
expect(probed).toBe(false); // verdict was already final — never probed
gate.stop();
});
});