Skip to content

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

Merged
evanpelle merged 1 commit into
mainfrom
recovery
Jul 16, 2026
Merged

feat(client): gate in-game ads by adblock detection + Admiral recovery#4534
evanpelle merged 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

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR adds client-side adblock detection with permanent blocked-state persistence, Admiral measurement and Google Ad Manager targeting integration, startup wiring, gated in-game ad display, and tests for the gatekeeper lifecycle.

Changes

Adblock Gate and Admiral Integration

Layer / File(s) Summary
Gatekeeper state machine core
src/client/AdGatekeeper.ts
Defines the adblock state, options, subscriptions, clear callbacks, and canShowAds.
Gate lifecycle, probing, and persistence
src/client/AdGatekeeper.ts
Adds seeded readings, lifecycle control, async probing, terminal blocked transitions, DOM bait detection, localStorage persistence, and the singleton.
Admiral loading and GAM targeting
src/client/Admiral.ts
Adds Admiral and Google Ad Manager typings, script injection, command buffering, targeting replay, and measurement callbacks.
Startup and promo gating
src/client/Main.ts, src/client/hud/layers/InGamePromo.ts
Initializes Admiral and the gatekeeper from measurement results, then defers promo rendering and ad loading until the gate is clear.
Gatekeeper behavior validation
tests/AdGatekeeper.test.ts
Tests clear and blocked states, terminal latching, seeded readings, synchronous callbacks, and persisted blocked verdicts.

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

Sequence Diagram(s)

sequenceDiagram
  participant Main
  participant Admiral
  participant AdGatekeeper
  participant InGamePromo
  Main->>Admiral: loadAdmiral()
  Admiral-->>Main: measurement result
  Main->>AdGatekeeper: seed(blocked)
  Main->>AdGatekeeper: start()
  InGamePromo->>AdGatekeeper: whenClear(showAd)
  AdGatekeeper-->>InGamePromo: invoke clear callback
  InGamePromo->>InGamePromo: loadAd() and checkForAds()
Loading

Possibly related PRs

Poem

A tiny gate checks ads with care,
Bait disappears from hidden air.
Blocked stays blocked in storage tight,
Clear lets the promo spring to light.
Admiral guides the path just right. 🐰

🚥 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 by adblock detection and Admiral recovery.
Description check ✅ Passed The description is directly related to the PR and explains the ad gating and Admiral loading 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
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 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: 2

🤖 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/Admiral.ts`:
- Around line 105-106: Update the Google Tag command queue handling in the
visible conditional to always enqueue push, replacing the gt.cmd.unshift(push)
branch with gt.cmd.push(push). Preserve the existing immediate push() behavior
when gt.pubads is available.
- Around line 118-122: Ensure the Admiral command queue exists before callbacks
are registered. Add an ensureAdmiralStub helper near loadAdmiral that
initializes window.admiral with the queued-call stub and required metadata, call
it from both loadAdmiral and onAdmiralMeasured, and then register the callback
through window.admiral as before.
🪄 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: 6656e23f-1b1b-4148-afd6-4d3b39414e1c

📥 Commits

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

📒 Files selected for processing (4)
  • src/client/AdGatekeeper.ts
  • src/client/Admiral.ts
  • src/client/Main.ts
  • src/client/hud/layers/InGamePromo.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/client/Main.ts
  • src/client/AdGatekeeper.ts

Comment thread src/client/Admiral.ts
Comment on lines +105 to +106
if (typeof gt.pubads === "function") push();
else gt.cmd.unshift(push);

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 | ⚡ Quick win

Use push instead of unshift for the Google Tag command queue.

When the Google Tag script loads, it replaces the cmd array with a custom object that only understands .push(). It does not support .unshift(). If this code runs after the array is replaced but before pubads is fully ready, .unshift() will throw an error (which is hidden by the catch block), and the targeting will be dropped. Always use .push() for the Google Tag command queue.

🐛 Proposed fix
-    if (typeof gt.pubads === "function") push();
-    else gt.cmd.unshift(push);
+    if (typeof gt.pubads === "function") push();
+    else gt.cmd.push(push);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (typeof gt.pubads === "function") push();
else gt.cmd.unshift(push);
if (typeof gt.pubads === "function") push();
else gt.cmd.push(push);
🤖 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/Admiral.ts` around lines 105 - 106, Update the Google Tag command
queue handling in the visible conditional to always enqueue push, replacing the
gt.cmd.unshift(push) branch with gt.cmd.push(push). Preserve the existing
immediate push() behavior when gt.pubads is available.

Comment thread src/client/Admiral.ts
Comment on lines +118 to +122
export function onAdmiralMeasured(
cb: (res: AdmiralMeasureResult) => void,
): void {
window.admiral?.("after", "measure.detected", cb);
}

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 | ⚡ Quick win

Ensure the command queue stub exists before calling window.admiral.

If onAdmiralMeasured is called before loadAdmiral, window.admiral will be undefined. The optional chaining (?.) will skip the function call, so the callback is never saved. To make this safe, create a helper function that sets up the stub array. Call this helper in both loadAdmiral and onAdmiralMeasured.

🛠️ Proposed fix to extract and use the stub

Add this helper function near line 55 (before loadAdmiral):

function ensureAdmiralStub(): void {
  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;
  }
}

Update loadAdmiral (around line 66) to use the new helper:

  // 1. Command-queue stub — must exist before the payload loads so buffered
  //    admiral(...) calls replay once it initializes (same pattern as gtag).
  ensureAdmiralStub();

Apply this diff to onAdmiralMeasured:

 export function onAdmiralMeasured(
   cb: (res: AdmiralMeasureResult) => void,
 ): void {
+  ensureAdmiralStub();
   window.admiral?.("after", "measure.detected", cb);
 }
🤖 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/Admiral.ts` around lines 118 - 122, Ensure the Admiral command
queue exists before callbacks are registered. Add an ensureAdmiralStub helper
near loadAdmiral that initializes window.admiral with the queued-call stub and
required metadata, call it from both loadAdmiral and onAdmiralMeasured, and then
register the callback through window.admiral as before.

@evanpelle evanpelle added this to the v32 milestone Jul 16, 2026
@evanpelle
evanpelle merged commit d59b5cd into main Jul 16, 2026
13 of 16 checks passed
@evanpelle
evanpelle deleted the recovery branch July 16, 2026 22:05
@github-project-automation github-project-automation Bot moved this from Development to Complete in OpenFront Release Management Jul 16, 2026
evanpelle added a commit that referenced this pull request Jul 16, 2026
#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

1 participant