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:05:23 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent b379502516
commit d59b5cdfd7
5 changed files with 496 additions and 5 deletions
+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;