Skip to content

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

Open
evanpelle wants to merge 1 commit into
mainfrom
recovery
Open

feat(client): gate in-game ads by adblock detection + Admiral recovery#4534
evanpelle wants to merge 1 commit into
mainfrom
recovery

Conversation

@evanpelle

Copy link
Copy Markdown
Collaborator

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

Add an AdGatekeeper that permanently suppresses the intrusive in-game ad
for any user ever detected running an adblocker. Ad-block users are highly
ad-sensitive, so the verdict is terminal and persisted to
localStorage("adblock-detected") — disabling the blocker does not unlock the
ad, in this or any future session. Detection uses a DOM bait probe plus, when
available, Admiral's measure.detected signal (adblocking && !whitelisted) as a
faster, more reliable read. Clean users are never latched (no false positives).

Add Admiral.ts, which 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 never fire for them.

Both are wired into the existing window.adsEnabled / userMeResponse flow in
Main.ts; the in-game ad (InGamePromo) now loads via adGatekeeper.whenClear.
Passive homepage/gutter ads are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds a client-side AdGatekeeper state machine that detects ad blockers and permanently latches ad suppression via localStorage, an Admiral module for ad-recovery script injection and Google Ad Manager targeting replay, and wiring in Main.ts/InGamePromo.ts to gate ad display on detection results, plus tests.

Changes

Adblock Gate and Admiral Integration

Layer / File(s) Summary
Gatekeeper state machine core
src/client/AdGatekeeper.ts
Defines AdblockState, AdGatekeeperOptions, constructor wiring, canShowAds, subscribe(), and whenClear().
Gate lifecycle, probing and persistence
src/client/AdGatekeeper.ts
Adds seed(), start()/stop(), internal evaluate()/applyReading()/transition(), default DOM bait probe, localStorage helpers, and exported adGatekeeper singleton.
Admiral script injection and GAM targeting
src/client/Admiral.ts
Adds typings, constants, loadAdmiral() script injection with command-queue stub, applyGamTargeting() replay to Google Ad Manager, and onAdmiralMeasured().
Wiring into client flow
src/client/Main.ts, src/client/hud/layers/InGamePromo.ts
Main.ts loads Admiral and seeds/starts adGatekeeper from measurement results; InGamePromo gates showAd() behind whenClear() and disables the gate in hideAd().
AdGatekeeper test suite
tests/AdGatekeeper.test.ts
Tests clear/blocked behavior, terminal latching, seed(), synchronous whenClear(), and localStorage persistence across sessions using fake timers.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client as Main.ts
  participant Admiral
  participant AdGatekeeper
  participant InGamePromo
  Client->>Admiral: loadAdmiral()
  Admiral-->>Client: onAdmiralMeasured(result)
  Client->>AdGatekeeper: seed(adblocking)
  Client->>AdGatekeeper: start()
  AdGatekeeper->>AdGatekeeper: probe / check localStorage
  InGamePromo->>AdGatekeeper: whenClear(showAd callback)
  AdGatekeeper-->>InGamePromo: fires only when state is "clear"
  InGamePromo->>InGamePromo: loadAd() / checkForAds()
Loading

Poem

A bunny checks for blockers near,
hides bait, then waits to see it clear.
If blocked, it locks the gate for good,
if clear, the ads show as they should.
Hop hop, the ledger's kept in store! 🐰📦

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: gating in-game ads with adblock detection and Admiral recovery.
Description check ✅ Passed The description directly explains the ad gatekeeper, Admiral integration, and in-game ad flow changes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/client/AdGatekeeper.ts`:
- Around line 90-106: The first probe in AdGatekeeper.start() runs too early and
can clear the gate before Admiral’s initial reading arrives. Update the
start()/evaluate()/transition flow so the initial DOM bait is deferred until the
first Admiral result is available, or add a brief grace period before
transitioning to "clear". Make sure whenClear() in InGamePromo cannot fire until
that first authoritative Admiral state has been seen.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2bdb24a6-14f8-4926-bc38-1355e3a24fb9

📥 Commits

Reviewing files that changed from the base of the PR and between f78b5c4 and e63fd73.

📒 Files selected for processing (5)
  • src/client/AdGatekeeper.ts
  • src/client/Admiral.ts
  • src/client/Main.ts
  • src/client/hud/layers/InGamePromo.ts
  • tests/AdGatekeeper.test.ts

Comment on lines +90 to +106
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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

git ls-files src/client/AdGatekeeper.ts src/client/Main.ts src/client | sed -n '1,120p'

Repository: openfrontio/OpenFrontIO

Length of output: 4636


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in src/client/AdGatekeeper.ts src/client/Main.ts src/client/Admiral.ts; do
  echo "### $f"
  wc -l "$f"
  echo
  cat -n "$f" | sed -n '1,260p'
  echo
done

Repository: openfrontio/OpenFrontIO

Length of output: 23061


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "adGatekeeper|onAdmiralMeasured|loadAdmiral|whenClear|seed\\(" src/client/Main.ts src/client/AdGatekeeper.ts src/client/Admiral.ts
echo
cat -n src/client/Main.ts | sed -n '520,760p'

Repository: openfrontio/OpenFrontIO

Length of output: 10096


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "whenClear\\(" src/client/Main.ts src/client -g '!src/client/AdGatekeeper.ts'

Repository: openfrontio/OpenFrontIO

Length of output: 253


🏁 Script executed:

#!/bin/bash
set -euo pipefail

wc -l src/client/hud/layers/InGamePromo.ts
echo
cat -n src/client/hud/layers/InGamePromo.ts | sed -n '1,180p'

Repository: openfrontio/OpenFrontIO

Length of output: 6531


Delay the first probe until Admiral can seed the gate

start() runs the DOM bait immediately, before Admiral has a chance to report. If the bait misses a blocker but Admiral later returns adblocking && !whitelisted, whenClear() in InGamePromo has already fired and the ad load is scheduled, so blocker users can still see the in-game ad. Skip the first evaluate() until the first Admiral reading lands, or add a short grace window before clearing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/client/AdGatekeeper.ts` around lines 90 - 106, The first probe in
AdGatekeeper.start() runs too early and can clear the gate before Admiral’s
initial reading arrives. Update the start()/evaluate()/transition flow so the
initial DOM bait is deferred until the first Admiral result is available, or add
a brief grace period before transitioning to "clear". Make sure whenClear() in
InGamePromo cannot fire until that first authoritative Admiral state has been
seen.

@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management Jul 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Development

Development

Successfully merging this pull request may close these issues.

1 participant