Injects progress bars on locked achievement cards using ach_counters from game.state and API fields.
// ==UserScript==
// @name BR Achievement Progress Bars
// @namespace blackridge
// @version 2.0.0
// @description Injects progress bars on locked achievement cards using ach_counters from game.state and API fields.
// @match https://blackridgerpg.com/*
// @grant unsafeWindow
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
'use strict';
const rootWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const STYLE_ID = 'br-ach-progress-style';
// ── Cached API achievement data keyed by title (and key if present) ──────────
// Populated by the fetch hook when /api/achievements is intercepted.
const _achDataByTitle = new Map(); // title → raw ach object
const _achDataByKey = new Map(); // key → raw ach object
// ── Tracker state ────────────────────────────────────────────────────────────
let _allAchievements = [];
let _trackerActive = false;
let _trackerSort = 'closest';
let _trackerFilter = 'all';
let _gridOriginalHTML = null;
const TIER_ORDER = { bronze: 1, silver: 2, gold: 3, platinum: 4, diamond: 5 };
const TIER_COLORS = {
bronze: { bg: '#8b6914', border: '#a47d1a', label: 'BRONZE' },
silver: { bg: '#6b7b8d', border: '#8a9aac', label: 'SILVER' },
gold: { bg: '#b8972b', border: '#d4af37', label: 'GOLD' },
platinum: { bg: '#6a5acd', border: '#8470ff', label: 'PLATINUM' },
diamond: { bg: '#9b30ff', border: '#bf5fff', label: 'DIAMOND' },
};
const CAT_LABELS = {
underworld: 'The Underworld', enforcer: 'The Enforcer',
high_roller: 'The High Roller', specialist: 'The Specialist',
working_class: 'Working Class', scavenger: 'The Scavenger',
survivor: 'The Survivor', chronicles: 'Chronicles',
overseas: 'Overseas', recruiter: 'The Recruiter',
mystery: 'Classified',
};
// ── CSS ──────────────────────────────────────────────────────────────────────
function ensureStyle() {
if (document.getElementById(STYLE_ID)) return;
const s = document.createElement('style');
s.id = STYLE_ID;
s.textContent = `
.br-ach-prog-wrap {
margin-top: 6px;
}
.br-ach-prog-track {
width: 100%;
height: 6px;
background: rgba(0,0,0,0.18);
border-radius: 3px;
overflow: hidden;
position: relative;
}
.br-ach-prog-fill {
height: 100%;
border-radius: 3px;
transition: width 0.4s ease;
background: linear-gradient(90deg, #4a7a3c, #7ab94e);
}
.br-ach-prog-fill.br-prog-done {
background: linear-gradient(90deg, #6a5acd, #8470ff);
}
.br-ach-prog-label {
font-size: 0.62rem;
letter-spacing: 0.5px;
color: #5a5040;
margin-top: 3px;
font-weight: 600;
text-transform: uppercase;
}
/* ── Tracker button in dossier-filters ── */
.br-tracker-btn {
color: #c9a227 !important;
border-color: rgba(201,162,39,0.55) !important;
background: transparent !important;
}
.br-tracker-btn:hover {
color: #e8c040 !important;
border-color: #c9a227 !important;
background: rgba(201,162,39,0.08) !important;
}
.br-tracker-btn.active {
background: rgba(201,162,39,0.18) !important;
color: #b8880a !important;
border-color: #c9a227 !important;
}
/* ── Tracker sort/filter controls bar ── */
#br-tracker-controls {
display: flex;
align-items: center;
gap: 5px;
flex-wrap: wrap;
padding: 8px 0;
margin-bottom: 4px;
}
.br-tc-label {
font-size: 0.55rem;
font-weight: 700;
letter-spacing: 1.2px;
text-transform: uppercase;
opacity: 0.6;
min-width: 28px;
}
.br-tc-sep { width: 1px; height: 14px; background: rgba(0,0,0,0.15); margin: 0 3px; }
.br-tc-btn {
font-size: 0.58rem;
font-weight: 700;
letter-spacing: 0.7px;
padding: 3px 9px;
border-radius: 3px;
border: 1px solid rgba(0,0,0,0.2);
cursor: pointer;
text-transform: uppercase;
background: transparent;
color: inherit;
transition: opacity 0.12s;
}
.br-tc-btn:hover { opacity: 0.7; }
.br-tc-btn.active { background: rgba(201,162,39,0.2); border-color: #c9a227; color: #9b7a20; }
#br-tc-count { opacity: 0.6; font-size: 0.55rem; }
`;
(document.head || document.documentElement).appendChild(s);
}
// ── Fetch hook — intercepts /api/achievements and caches full ach objects ───
function installFetchHook() {
const origFetch = rootWindow.fetch;
rootWindow.fetch = async function (...args) {
const res = await origFetch.apply(this, args);
try {
const url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url) || '';
if (url.includes('/api/achievements')) {
const clone = res.clone();
clone.json().then(data => {
_achDataByTitle.clear();
_achDataByKey.clear();
if (Array.isArray(data && data.achievements)) {
_allAchievements = data.achievements;
data.achievements.forEach(a => {
if (a.title) _achDataByTitle.set(a.title.trim().toLowerCase(), a);
if (a.key) _achDataByKey.set(a.key, a);
});
if (_trackerActive) renderTrackerView();
}
}).catch(() => {});
}
} catch (e) {
// never break the game
}
return res;
};
}
// ── Progress resolution ────────────────────────────────────────────────────
// Returns { current: number, target: number } or null
// Helper: parse "$1,000,000" → 1000000
function parseDollar(str) {
return Number(str.replace(/[$,]/g, ''));
}
function resolveProgress(card) {
const titleEl = card.querySelector('.ach-title');
const descEl = card.querySelector('.ach-desc');
if (!titleEl) return null;
const titleText = titleEl.textContent.trim();
const descText = descEl ? descEl.textContent.trim() : '';
// 1. Try API progress fields first (server may return progress/current/target)
const apiAch = _achDataByTitle.get(titleText.toLowerCase());
if (apiAch) {
const cur = apiAch.progress ?? apiAch.current ?? apiAch.count ?? null;
const tgt = apiAch.target ?? apiAch.required ?? apiAch.progress_max ?? null;
if (cur !== null && tgt !== null && Number.isFinite(Number(cur)) && Number.isFinite(Number(tgt))) {
return { current: Number(cur), target: Number(tgt) };
}
}
// 2. Infer from ach_counters
return resolveProgressFromText(descText);
}
function resolveProgressFromText(descText) {
const g = rootWindow.game;
const state = g && g.state ? g.state : null;
if (!state) return null;
const ac = state.ach_counters || {};
// ── Level ─────────────────────────────────────────────────────────────────
let m;
if ((m = descText.match(/reach level (\d+)/i))) {
return { current: Number(state.level || 1), target: Number(m[1]) };
}
// ── Bank balance (hold) ───────────────────────────────────────────────────
if ((m = descText.match(/hold (\$[\d,]+) in your bank balance/i))) {
return { current: Number(state.bankBalance || 0), target: parseDollar(m[1]) };
}
// ── Bank deposits total ───────────────────────────────────────────────────
if ((m = descText.match(/deposit (\$[\d,]+) total/i))) {
return { current: Number(ac.totalBankDeposited || 0), target: parseDollar(m[1]) };
}
// ── Lifetime earnings (accumulate / reach) ────────────────────────────────
if ((m = descText.match(/accumulate (\$[\d,]+) in total lifetime earnings?/i)) ||
(m = descText.match(/reach (\$[\d,]+) in lifetime earnings?/i))) {
return { current: Number(ac.totalMoneyEarned || 0), target: parseDollar(m[1]) };
}
// ── Bank interest ─────────────────────────────────────────────────────────
if ((m = descText.match(/earn (\$[\d,]+) in (?:bank )?interest/i))) {
return { current: Number(ac.totalInterestEarned || 0), target: parseDollar(m[1]) };
}
// ── Shop spending ─────────────────────────────────────────────────────────
if ((m = descText.match(/spend (\$[\d,]+) (?:at|in) (?:the )?(?:corner )?store/i)) ||
(m = descText.match(/spend (\$[\d,]+) (?:at|in) (?:the )?shop/i))) {
return { current: Number(ac.totalShopSpent || 0), target: parseDollar(m[1]) };
}
// ── City events (total) ───────────────────────────────────────────────────
if ((m = descText.match(/experience (\d+) random city events?/i))) {
return { current: Number(ac.totalRandomEvents || 0), target: Number(m[1]) };
}
// ── Windfall events ───────────────────────────────────────────────────────
if ((m = descText.match(/experience (\d+) windfall events?/i))) {
return { current: Number(ac.totalWindfalls || 0), target: Number(m[1]) };
}
// ── Hazard events ─────────────────────────────────────────────────────────
if ((m = descText.match(/endure (\d+) hazard events?/i))) {
return { current: Number(ac.totalHazards || 0), target: Number(m[1]) };
}
// ── Overseas events ───────────────────────────────────────────────────────
if ((m = descText.match(/experience (\d+) overseas events?/i))) {
return { current: Number(ac.totalOverseasEvents || 0), target: Number(m[1]) };
}
// ── Crimes ────────────────────────────────────────────────────────────────
if ((m = descText.match(/complete (\d+) crimes?/i))) {
return { current: Number(ac.totalCrimes || 0), target: Number(m[1]) };
}
if ((m = descText.match(/commit (\d+) crimes?/i))) {
return { current: Number(ac.totalCrimes || 0), target: Number(m[1]) };
}
// ── Unique crime types ────────────────────────────────────────────────────
if ((m = descText.match(/complete (\d+) unique crime types?/i))) {
return { current: Number(ac.uniqueCrimesCompleted || 0), target: Number(m[1]) };
}
// ── PvP wins ──────────────────────────────────────────────────────────────
if ((m = descText.match(/win (\d+) pvp/i)) ||
(m = descText.match(/defeat (\d+) players?/i)) ||
(m = descText.match(/win (\d+) (?:street )?(?:fights?|attacks?)/i))) {
return { current: Number(ac.totalPvpWins || 0), target: Number(m[1]) };
}
// ── Damage dealt ─────────────────────────────────────────────────────────
if ((m = descText.match(/deal (\d[\d,]*) (?:total )?damage/i))) {
return { current: Number(ac.totalDamageDealt || 0), target: Number(m[1].replace(/,/g, '')) };
}
// ── Gym sessions ─────────────────────────────────────────────────────────
if ((m = descText.match(/complete (\d+) gym sessions?/i))) {
return { current: Number(ac.totalGymSessions || 0), target: Number(m[1]) };
}
// ── Energy spent at gym ───────────────────────────────────────────────────
if ((m = descText.match(/spend (\d[\d,]*) energy (?:at the gym|training)/i))) {
return { current: Number(ac.totalEnergyGym || 0), target: Number(m[1].replace(/,/g, '')) };
}
// ── Nerve spent ───────────────────────────────────────────────────────────
if ((m = descText.match(/spend (\d[\d,]*) nerve/i))) {
return { current: Number(ac.totalNerveSpent || 0), target: Number(m[1].replace(/,/g, '')) };
}
// ── Jailings ──────────────────────────────────────────────────────────────
if ((m = descText.match(/(?:be|get) jailed (\d+) times?/i)) ||
(m = descText.match(/jailed (\d+) times?/i))) {
return { current: Number(ac.totalJailings || 0), target: Number(m[1]) };
}
// ── Hospitalizations ─────────────────────────────────────────────────────
if ((m = descText.match(/(?:be )?hospitali[sz]ed (\d+) times?/i))) {
return { current: Number(ac.totalHospitalizations || 0), target: Number(m[1]) };
}
// ── Travels ───────────────────────────────────────────────────────────────
if ((m = descText.match(/travel (\d+) times?/i))) {
return { current: Number(ac.totalTravels || 0), target: Number(m[1]) };
}
// ── Mexico visits ─────────────────────────────────────────────────────────
if ((m = descText.match(/visit mexico (\d+) times?/i))) {
return { current: Number(ac.mexicoVisits || 0), target: Number(m[1]) };
}
// ── London visits ─────────────────────────────────────────────────────────
if ((m = descText.match(/visit london (\d+) times?/i))) {
return { current: Number(ac.londonVisits || 0), target: Number(m[1]) };
}
// ── Flights in own plane ──────────────────────────────────────────────────
if ((m = descText.match(/(?:make|complete) (\d+) flights? in (?:your )?own(?:ed)? plane/i))) {
return { current: Number(ac.flightsInOwnedPlane || 0), target: Number(m[1]) };
}
// ── Slots pulls ───────────────────────────────────────────────────────────
if ((m = descText.match(/(?:pull|spin) the slots? (\d+) times?/i)) ||
(m = descText.match(/pull (\d+) slots?/i))) {
return { current: Number(ac.totalSlotsPulls || 0), target: Number(m[1]) };
}
// ── Gambling wins ─────────────────────────────────────────────────────────
if ((m = descText.match(/win (\d+) times? (?:at|in|gambling)/i))) {
return { current: Number(ac.totalGamblingWins || 0), target: Number(m[1]) };
}
// ── Speakeasy winnings ────────────────────────────────────────────────────
if ((m = descText.match(/win (\$[\d,]+) (?:at|in) (?:the )?speakeasy/i))) {
return { current: Number(ac.totalSpeakeasyWon || 0), target: parseDollar(m[1]) };
}
// ── Market consignments ───────────────────────────────────────────────────
if ((m = descText.match(/complete (\d+) (?:market )?consignments?/i)) ||
(m = descText.match(/list (\d+) items? on the (?:black )?market/i))) {
return { current: Number(ac.totalConsignments || 0), target: Number(m[1]) };
}
// ── Quick sells ───────────────────────────────────────────────────────────
if ((m = descText.match(/quick.?sell (\d+) items?/i))) {
return { current: Number(ac.totalQuickSells || 0), target: Number(m[1]) };
}
// ── Shares traded ─────────────────────────────────────────────────────────
if ((m = descText.match(/trade (\d[\d,]*) shares?/i))) {
return { current: Number(ac.totalSharesTraded || 0), target: Number(m[1].replace(/,/g, '')) };
}
// ── Trade executions ─────────────────────────────────────────────────────
if ((m = descText.match(/execute (\d+) trades?/i)) ||
(m = descText.match(/make (\d+) stock trades?/i))) {
return { current: Number(ac.totalTradesExecuted || 0), target: Number(m[1]) };
}
// ── War bonds spent ───────────────────────────────────────────────────────
if ((m = descText.match(/spend (\d+) war ?bonds?/i))) {
return { current: Number(ac.totalWarBondsSpent || 0), target: Number(m[1]) };
}
// ── War bond purchases ────────────────────────────────────────────────────
if ((m = descText.match(/purchase (\d+) war ?bond items?/i))) {
return { current: Number(ac.totalWarbondPurchases || 0), target: Number(m[1]) };
}
// ── Daily missions ────────────────────────────────────────────────────────
if ((m = descText.match(/complete (\d+) daily (?:missions?|assignments?)/i))) {
return { current: Number(ac.totalDailyCompleted || 0), target: Number(m[1]) };
}
// ── Weekly missions ───────────────────────────────────────────────────────
if ((m = descText.match(/complete (\d+) weekly (?:missions?|assignments?)/i))) {
return { current: Number(ac.totalWeeklyCompleted || 0), target: Number(m[1]) };
}
// ── Contracts ────────────────────────────────────────────────────────────
if ((m = descText.match(/complete (\d+) (?:fixer )?contracts?/i))) {
return { current: Number(ac.totalContractsCompleted || 0), target: Number(m[1]) };
}
// ── Items consumed ───────────────────────────────────────────────────────
if ((m = descText.match(/consume (\d+) items?/i)) ||
(m = descText.match(/use (\d+) items?/i))) {
return { current: Number(ac.totalItemsConsumed || 0), target: Number(m[1]) };
}
// ── Coffees ───────────────────────────────────────────────────────────────
if ((m = descText.match(/drink (\d+) coffees?/i)) ||
(m = descText.match(/use (\d+) coffees?/i))) {
return { current: Number(ac.totalCoffeesUsed || 0), target: Number(m[1]) };
}
// ── Scouts ────────────────────────────────────────────────────────────────
if ((m = descText.match(/scout (\d+) (?:players?|targets?)/i))) {
return { current: Number(ac.totalScouts || 0), target: Number(m[1]) };
}
// ── Messages sent ─────────────────────────────────────────────────────────
if ((m = descText.match(/send (\d+) messages?/i))) {
return { current: Number(ac.totalMessagesSent || 0), target: Number(m[1]) };
}
// ── Collections completed ─────────────────────────────────────────────────
if ((m = descText.match(/complete (\d+) collections?/i))) {
return { current: Number(ac.totalCollectionsCompleted || 0), target: Number(m[1]) };
}
// ── Login streak ─────────────────────────────────────────────────────────
if ((m = descText.match(/(?:reach|maintain) (?:a )?(\d+)[- ]day login streak/i))) {
return { current: Number(ac.maxLoginStreak || 0), target: Number(m[1]) };
}
return null;
}
// ── Card patcher ────────────────────────────────────────────────────────────
function patchCard(card) {
if (card.dataset.brProgFixed === '1') return;
card.dataset.brProgFixed = '1';
// Skip already-unlocked cards (stamp = APPROVED)
const stamp = card.querySelector('.ach-stamp');
if (stamp && stamp.textContent.trim() === 'APPROVED') return;
const prog = resolveProgress(card);
if (!prog) return;
const { current, target } = prog;
const clampedCurrent = Math.min(current, target);
const pct = target > 0 ? Math.round((clampedCurrent / target) * 100) : 0;
const done = pct >= 100;
const body = card.querySelector('.ach-body');
if (!body) return;
const wrap = document.createElement('div');
wrap.className = 'br-ach-prog-wrap';
const track = document.createElement('div');
track.className = 'br-ach-prog-track';
const fill = document.createElement('div');
fill.className = 'br-ach-prog-fill' + (done ? ' br-prog-done' : '');
fill.style.width = pct + '%';
const label = document.createElement('div');
label.className = 'br-ach-prog-label';
label.textContent = `${clampedCurrent.toLocaleString()} / ${target.toLocaleString()} (${pct}%)`;
track.appendChild(fill);
wrap.appendChild(track);
wrap.appendChild(label);
body.appendChild(wrap);
}
function patchAllCards() {
document.querySelectorAll('#dossier-grid .ach-card').forEach(patchCard);
}
// ── Tracker ───────────────────────────────────────────────────────────────────────
function buildTrackerCard(a) {
const tc = TIER_COLORS[a.tier] || TIER_COLORS.bronze;
const prog = resolveProgressFromText(a.desc || '');
const pct = prog && prog.target > 0
? Math.min(100, Math.round((prog.current / prog.target) * 100)) : null;
const rewardParts = [];
if (a.reward_money > 0) rewardParts.push('$' + Number(a.reward_money).toLocaleString());
if (a.reward_xp > 0) rewardParts.push(Number(a.reward_xp).toLocaleString() + ' XP');
const rewardStr = rewardParts.join(' · ') || '—';
const progHTML = pct !== null
? `<div class="br-ach-prog-wrap"><div class="br-ach-prog-track"><div class="br-ach-prog-fill" style="width:${pct}%"></div></div><div class="br-ach-prog-label">${prog.current.toLocaleString()} / ${prog.target.toLocaleString()} (${pct}%)</div></div>`
: '';
const catLabel = CAT_LABELS[a.cat] || a.cat || '';
const stampCls = a.secret ? 'ach-stamp-classified' : 'ach-stamp-pending';
const stampTxt = a.secret ? 'CLASSIFIED' : 'PENDING';
const title = a.secret ? 'CLASSIFIED' : (a.title || '');
const desc = a.secret ? 'Complete unknown criteria to declassify this commendation.' : (a.desc || '');
return `<div class="ach-card ach-locked" data-tier="${a.tier}"><div class="ach-tier-ribbon" style="background:${tc.bg};border-color:${tc.border}">${tc.label}</div><div class="ach-stamp ${stampCls}">${stampTxt}</div><div class="ach-body"><div class="ach-cat-label">${catLabel}</div><h3 class="ach-title">${title}</h3><p class="ach-desc">${desc}</p><div class="ach-reward">${rewardStr}</div>${progHTML}</div></div>`;
}
function getTrackerItems() {
let items = _allAchievements.filter(a => !a.unlocked);
if (_trackerFilter === 'xp') items = items.filter(a => a.reward_xp > 0);
if (_trackerFilter === 'money') items = items.filter(a => a.reward_money > 0);
if (_trackerFilter === 'any_reward') items = items.filter(a => a.reward_xp > 0 || a.reward_money > 0);
return [...items].sort((a, b) => {
if (_trackerSort === 'xp') return (b.reward_xp || 0) - (a.reward_xp || 0);
if (_trackerSort === 'money') return (b.reward_money || 0) - (a.reward_money || 0);
if (_trackerSort === 'tier_asc') return (TIER_ORDER[a.tier] || 0) - (TIER_ORDER[b.tier] || 0);
if (_trackerSort === 'tier_desc') return (TIER_ORDER[b.tier] || 0) - (TIER_ORDER[a.tier] || 0);
if (_trackerSort === 'name') return (a.title || '').localeCompare(b.title || '');
// 'closest': highest completion % first, untracked last
const pa = resolveProgressFromText(a.desc || ''), pb = resolveProgressFromText(b.desc || '');
const pcta = pa && pa.target > 0 ? pa.current / pa.target : -1;
const pctb = pb && pb.target > 0 ? pb.current / pb.target : -1;
return pctb - pcta;
});
}
function renderTrackerView() {
const grid = document.getElementById('dossier-grid');
if (!grid || !_trackerActive) return;
const items = getTrackerItems();
grid.innerHTML = items.length > 0
? items.map(buildTrackerCard).join('')
: '<div style="padding:40px;text-align:center;color:#8a7a60;font-style:italic;grid-column:1/-1;">No achievements match the current filter.</div>';
document.querySelectorAll('#br-tracker-controls .br-tc-btn[data-sort]').forEach(b => b.classList.toggle('active', b.dataset.sort === _trackerSort));
document.querySelectorAll('#br-tracker-controls .br-tc-btn[data-filter]').forEach(b => b.classList.toggle('active', b.dataset.filter === _trackerFilter));
const countEl = document.getElementById('br-tc-count');
if (countEl) countEl.textContent = items.length + ' shown · ' + _allAchievements.filter(a => !a.unlocked).length + ' incomplete';
}
function showTracker() {
const grid = document.getElementById('dossier-grid');
if (!grid) return;
_trackerActive = true;
_gridOriginalHTML = grid.innerHTML;
if (!document.getElementById('br-tracker-controls')) {
const ctrl = document.createElement('div');
ctrl.id = 'br-tracker-controls';
ctrl.innerHTML = `
<span class="br-tc-label">SORT</span>
<button class="br-tc-btn active" data-sort="closest">Closest</button>
<button class="br-tc-btn" data-sort="xp">Best XP</button>
<button class="br-tc-btn" data-sort="money">Best $</button>
<button class="br-tc-btn" data-sort="tier_asc">Easy First</button>
<button class="br-tc-btn" data-sort="tier_desc">Hard First</button>
<button class="br-tc-btn" data-sort="name">A–Z</button>
<span class="br-tc-sep"></span>
<span class="br-tc-label">SHOW</span>
<button class="br-tc-btn active" data-filter="all">All</button>
<button class="br-tc-btn" data-filter="any_reward">Has Reward</button>
<button class="br-tc-btn" data-filter="xp">XP Only</button>
<button class="br-tc-btn" data-filter="money">$ Only</button>
<span class="br-tc-sep"></span>
<span id="br-tc-count" class="br-tc-label"></span>
`;
grid.parentElement.insertBefore(ctrl, grid);
ctrl.querySelectorAll('.br-tc-btn[data-sort]').forEach(btn =>
btn.addEventListener('click', () => { _trackerSort = btn.dataset.sort; renderTrackerView(); })
);
ctrl.querySelectorAll('.br-tc-btn[data-filter]').forEach(btn =>
btn.addEventListener('click', () => { _trackerFilter = btn.dataset.filter; renderTrackerView(); })
);
}
if (_allAchievements.length > 0) {
renderTrackerView();
} else {
grid.innerHTML = '<div style="padding:40px;text-align:center;color:#8a7a60;font-style:italic;grid-column:1/-1;">Loading…</div>';
const token = localStorage.getItem('blackridge_token');
if (token) fetch((rootWindow.API_BASE || '') + '/api/achievements', { headers: { Authorization: 'Bearer ' + token } }).catch(() => {});
}
}
function hideTracker() {
if (!_trackerActive) return;
_trackerActive = false;
const ctrl = document.getElementById('br-tracker-controls');
if (ctrl) ctrl.remove();
const grid = document.getElementById('dossier-grid');
if (grid && _gridOriginalHTML !== null) {
grid.innerHTML = _gridOriginalHTML;
_gridOriginalHTML = null;
patchAllCards();
}
}
function injectTrackerButton() {
if (document.querySelector('.br-tracker-btn')) return;
const filters = document.getElementById('dossier-filters');
if (!filters) return;
const btn = document.createElement('button');
btn.className = 'dossier-filter br-tracker-btn';
btn.textContent = '⬡ TRACKER';
btn.addEventListener('click', e => {
e.preventDefault(); e.stopPropagation();
if (_trackerActive) {
hideTracker();
btn.classList.remove('active');
// Re-fire the ALL filter so game shows normal grid
const allBtn = filters.querySelector('.dossier-filter[data-cat="all"]');
if (allBtn) allBtn.click();
} else {
filters.querySelectorAll('.dossier-filter:not(.br-tracker-btn)').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
showTracker();
}
});
filters.appendChild(btn);
// Exit tracker when any game category filter is clicked
filters.querySelectorAll('.dossier-filter:not(.br-tracker-btn)').forEach(b => {
b.addEventListener('click', () => {
if (_trackerActive) { hideTracker(); btn.classList.remove('active'); }
});
});
}
// ── Observer — re-patch whenever the dossier grid is updated ────────────────
function initObserver() {
const obs = new MutationObserver(() => {
if (_trackerActive) return; // grid content is ours — don't patch
document.querySelectorAll('#dossier-grid .ach-card:not([data-br-prog-fixed])').forEach(patchCard);
patchAllCards();
});
const tryObserve = () => {
const grid = document.getElementById('dossier-grid');
if (grid) {
// SPA navigated back to dossier — reset tracker state
if (_trackerActive) {
_trackerActive = false;
_gridOriginalHTML = null;
const ctrl = document.getElementById('br-tracker-controls');
if (ctrl) ctrl.remove();
}
obs.observe(grid, { childList: true, subtree: false });
patchAllCards();
injectTrackerButton();
return true;
}
return false;
};
if (!tryObserve()) {
const docObs = new MutationObserver(() => {
if (tryObserve()) docObs.disconnect();
});
docObs.observe(document.documentElement, { childList: true, subtree: true });
}
}
// ── Init ─────────────────────────────────────────────────────────────────────
installFetchHook();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
ensureStyle();
initObserver();
}, { once: true });
} else {
ensureStyle();
initObserver();
}
})();