mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 17:20:23 +00:00
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:
committed by
evanpelle
co-authored by
Claude Opus 4.8
parent
16be9d7c15
commit
e3676439d2
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user