BR Achievement Progress Bars

Injects progress bars on locked achievement cards using ach_counters from game.state and API fields.

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==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();
  }

})();