Files
OpenFrontIO/src/client/hud/layers/InGamePromo.ts
T
d59b5cdfd7 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>
2026-07-16 15:05:23 -07:00

170 lines
4.5 KiB
TypeScript

import { LitElement, html } from "lit";
import { customElement } from "lit/decorators.js";
import { adGatekeeper } from "../../AdGatekeeper";
import { Controller } from "../../Controller";
import { GameView } from "../../view";
const AD_TYPES = [
{ type: "standard_iab_left1", selectorId: "in-game-bottom-left-ad" },
{ type: "standard_iab_left3", selectorId: "in-game-bottom-left-ad3" },
{ type: "standard_iab_left4", selectorId: "in-game-bottom-left-ad4" },
];
@customElement("in-game-promo")
export class InGamePromo extends LitElement implements Controller {
public game: GameView;
private shouldShow: boolean = false;
private adsVisible: boolean = false;
private bottomRailDestroyed: boolean = false;
private cornerAdShown: boolean = false;
private adCheckInterval: ReturnType<typeof setTimeout> | null = null;
private adGateOff: (() => void) | null = null;
createRenderRoot() {
return this;
}
init() {}
tick() {
if (!this.game.inSpawnPhase()) {
if (!this.bottomRailDestroyed) {
this.bottomRailDestroyed = true;
this.destroyBottomRail();
}
if (!this.cornerAdShown) {
this.cornerAdShown = true;
console.log("[InGamePromo] Spawn phase ended, triggering showAd");
this.showAd();
}
}
}
private destroyBottomRail(): void {
if (!window.ramp) return;
try {
window.ramp.destroyUnits("pw-oop-bottom_rail");
console.log("Bottom rail ad destroyed after spawn phase");
} catch (e) {
console.error("Error destroying bottom_rail ad:", e);
}
}
private showAd(): void {
if (window.innerWidth < 1100) return;
if (window.innerHeight < 750) return;
if (!window.adsEnabled) return;
// 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();
});
});
}
private checkForAds(): void {
if (this.adCheckInterval) {
clearInterval(this.adCheckInterval);
}
this.adCheckInterval = setInterval(() => {
const hasAds = AD_TYPES.some(({ selectorId }) => {
const el = document.getElementById(selectorId);
return el && el.clientHeight > 50;
});
if (hasAds) {
this.adsVisible = true;
this.requestUpdate();
if (this.adCheckInterval) {
clearInterval(this.adCheckInterval);
this.adCheckInterval = null;
}
}
}, 1000);
}
private loadAd(): void {
if (!window.ramp) {
console.warn("Playwire RAMP not available for in-game ad");
return;
}
try {
window.ramp.que.push(() => {
try {
window.ramp.spaAddAds(
AD_TYPES.map(({ type, selectorId }) => ({ type, selectorId })),
);
console.log(
"In-game bottom-left ads loaded:",
AD_TYPES.map((a) => a.type),
);
} catch (e) {
console.error("Failed to add in-game ads:", e);
}
});
} catch (error) {
console.error("Failed to load in-game ads:", error);
}
}
public hideAd(): void {
if (this.adGateOff) {
this.adGateOff();
this.adGateOff = null;
}
if (this.adCheckInterval) {
clearInterval(this.adCheckInterval);
this.adCheckInterval = null;
}
this.adsVisible = false;
this.destroyBottomRail();
if (!window.ramp) {
console.warn("Playwire RAMP not available for in-game ad");
return;
}
this.shouldShow = false;
try {
for (const { type } of AD_TYPES) {
window.ramp.destroyUnits(type);
}
console.log("successfully destroyed in-game bottom-left ads");
} catch (e) {
console.error("error destroying in-game ads:", e);
}
this.requestUpdate();
}
render() {
if (!this.shouldShow) {
return html``;
}
return html`
<div
id="in-game-promo-container"
class="fixed left-0 z-[100] pointer-events-auto flex flex-col-reverse ${this
.adsVisible
? "bg-gray-800 rounded-tr-lg p-1"
: ""}"
style="bottom: -0.7cm"
>
${AD_TYPES.map(
({ selectorId }) =>
html`<div id="${selectorId}" style="margin:0;padding:0"></div>`,
)}
</div>
`;
}
}