Conversation
WalkthroughThis 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. ChangesAdblock Gate and Admiral Integration
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()
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (5)
src/client/AdGatekeeper.tssrc/client/Admiral.tssrc/client/Main.tssrc/client/hud/layers/InGamePromo.tstests/AdGatekeeper.test.ts
| 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); | ||
| } |
There was a problem hiding this comment.
🎯 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
doneRepository: 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.
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>
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
src/client/AdGatekeeper.tssrc/client/Admiral.tssrc/client/Main.tssrc/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
| if (typeof gt.pubads === "function") push(); | ||
| else gt.cmd.unshift(push); |
There was a problem hiding this comment.
🎯 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.
| 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.
| export function onAdmiralMeasured( | ||
| cb: (res: AdmiralMeasureResult) => void, | ||
| ): void { | ||
| window.admiral?.("after", "measure.detected", cb); | ||
| } |
There was a problem hiding this comment.
🎯 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.
#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>
What
Two related pieces, wired into the existing
window.adsEnabled/userMeResponsead flow:AdGatekeeper— decides whether the intrusive in-game ad may show. Once a blocker is ever detected, the ad is suppressed permanently (terminal state, persisted tolocalStorage["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'smeasure.detectedsignal (adblocking && !whitelisted) when it fires. Clean users are never latched.Admiral.ts— injects the ad-recovery tag (command-queue stub + payload + GAM targeting shim) for ad-eligible users only. Paid/adfreeusers havewindow.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 viaadGatekeeper.whenClear(...). Passive homepage/gutter ads are unchanged.Why
adfreefor life) must never see ads or load Admiral.How it behaves
adfree)Testing
tests/AdGatekeeper.test.ts(9 cases) — terminal latch, "disabling blocker doesn't unlock", cross-session persistence, seed path, no-false-positive.tscclean,eslintclean.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
ADMIRAL_PAYLOAD_SRCis a disguised, rotating domain — re-sync from the provider when they reissue the tag.res.subscribed(Admiral's own ad-free pass) is intentionally ignored — OpenFront's ad-free is the serveradfreeflag.🤖 Generated with Claude Code