From d59b5cdfd7933d7d1e209f636e25a06d0fb2eb49 Mon Sep 17 00:00:00 2001 From: Evan Date: Thu, 16 Jul 2026 15:05:23 -0700 Subject: [PATCH] feat(client): gate in-game ads by adblock detection + Admiral recovery (#4534) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- src/client/AdGatekeeper.ts | 181 +++++++++++++++++++++++++++ src/client/Admiral.ts | 122 ++++++++++++++++++ src/client/Main.ts | 18 +++ src/client/hud/layers/InGamePromo.ts | 21 +++- tests/AdGatekeeper.test.ts | 159 +++++++++++++++++++++++ 5 files changed, 496 insertions(+), 5 deletions(-) create mode 100644 src/client/AdGatekeeper.ts create mode 100644 src/client/Admiral.ts create mode 100644 tests/AdGatekeeper.test.ts diff --git a/src/client/AdGatekeeper.ts b/src/client/AdGatekeeper.ts new file mode 100644 index 000000000..fda789f84 --- /dev/null +++ b/src/client/AdGatekeeper.ts @@ -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; +} + +/** + * 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(); + private pollTimer: ReturnType | null = null; + private started = false; + private readonly pollMs: number; + private readonly probe: () => Promise; + + 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 { + 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 { + 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(); diff --git a/src/client/Admiral.ts b/src/client/Admiral.ts new file mode 100644 index 000000000..0dc74c849 --- /dev/null +++ b/src/client/Admiral.ts @@ -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); +} diff --git a/src/client/Main.ts b/src/client/Main.ts index ca9f6e8f7..38cfd17de 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -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"; @@ -498,6 +500,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, diff --git a/src/client/hud/layers/InGamePromo.ts b/src/client/hud/layers/InGamePromo.ts index 2d2e3bfeb..f3f1a9e16 100644 --- a/src/client/hud/layers/InGamePromo.ts +++ b/src/client/hud/layers/InGamePromo.ts @@ -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 | 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; diff --git a/tests/AdGatekeeper.test.ts b/tests/AdGatekeeper.test.ts new file mode 100644 index 000000000..f7970fa3c --- /dev/null +++ b/tests/AdGatekeeper.test.ts @@ -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(); + }); +});