BR Achievement Progress Bars

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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

})();