Torn Leveling Helper

Fitbit-style level progress tracker plus dynamic live Baldr targets.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Advertisement:

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

Advertisement:

// ==UserScript==
// @name         Torn Leveling Helper
// @namespace    Torn Leveling Helper
// @version      2.0.8
// @description  Fitbit-style level progress tracker plus dynamic live Baldr targets.
// @match        https://www.torn.com/*
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @connect      api.torn.com
// @connect      raw.githubusercontent.com
// @connect      oran.pw
// @license MIT
// ==/UserScript==

(function () {
  'use strict';

  const KEY_VERSION = '2.0.8';
  const XP_CACHE_SCHEMA = 'hof-progress-v2';

  const HOF_CACHE_MS = 5 * 60 * 1000;

  const TARGET_REFRESH_MS = 25 * 1000;
  const LIVE_TARGET_CACHE_MS = 90 * 1000;
  const DISPLAYED_TARGET_RECHECK_MS = 20 * 1000;
  const TARGET_STATUS_DELAY_MS = 1500;
  const BALDR_RAW_CACHE_MS = 6 * 60 * 60 * 1000;

  const BALDR_DATA_URLS = [
    'https://raw.githubusercontent.com/OranWeb/tc-baldrs-levelling-list/master/data.json',
    'https://oran.pw/baldrstargets/data.json'
  ];

  const MAX_TARGETS_SHOWN = 6;
  const CANDIDATES_TO_CHECK = 8;

  const MIN_TOTAL_SAFETY_RATIO = 2.0;
  const MAX_TARGET_SPEED_RATIO = 1.0;

  const BOX_ID = 'xpPctBox';
  const STYLE_ID = 'xpPctStyle';

  let lastUrl = location.href;
  let mountTimer = null;
  let initRunning = false;
  let appStarted = false;
  let activeApiKey = '';
  let targetScanRunning = false;
  let displayedRecheckRunning = false;
  let nextTargetRefreshAt = 0;
  let lastHospitalDetectedId = '';

  if (GM_getValue('keyVersion') !== KEY_VERSION) {
    GM_deleteValue('xpPctCache');
    GM_deleteValue('baldrRawCache');
    GM_deleteValue('liveTargetCache');
    GM_setValue('keyVersion', KEY_VERSION);
  }

  addStyle(`
    #xpPctBox {
      margin: 6px 0;
      padding: 12px;
      background: radial-gradient(circle at top, #1f3b2b, #07110b 70%);
      border: 1px solid rgba(139,195,74,.55);
      border-radius: 18px;
      color: #dfffd2;
      font-size: 12px;
      max-width: 285px;
      z-index: 999999;
      box-shadow:
        0 0 16px rgba(139,195,74,.35),
        inset 0 0 18px rgba(0,0,0,.85);
      font-family: Arial, sans-serif;
    }

    #xpPctBox b {
      color: #b8ff7a;
      text-transform: uppercase;
      letter-spacing: .6px;
    }

    #xpPctPercent {
      margin-top: 4px;
      font-size: 28px;
      font-weight: bold;
      color: #9cff57;
      text-shadow: 0 0 10px rgba(156,255,87,.8);
    }

    #xpRefreshText,
    #targetRefreshText {
      opacity: .75;
      font-size: 11px;
      margin-top: 3px;
      color: #c9ffbd;
    }

    #xpPctBarOuter {
      height: 12px;
      background: #102017;
      border-radius: 999px;
      overflow: hidden;
      margin-top: 7px;
      border: 1px solid rgba(139,195,74,.25);
      box-shadow: inset 0 0 6px rgba(0,0,0,.8);
    }

    #xpPctBarInner {
      height: 100%;
      width: 0%;
      background: linear-gradient(90deg, #39ff88, #b8ff3d);
      box-shadow: 0 0 12px rgba(57,255,136,.85);
      border-radius: 999px;
      transition: width 1.2s ease-out;
      animation: xpPulse 1.8s ease-in-out infinite;
    }

    @keyframes xpPulse {
      0%, 100% { filter: brightness(1); }
      50% { filter: brightness(1.35); }
    }

    .xpHeartbeat {
      margin: 7px 0 6px;
      height: 28px;
      overflow: hidden;
      border-radius: 10px;
      background: rgba(0,0,0,.28);
      border: 1px solid rgba(139,195,74,.18);
    }

    .xpHeartbeat svg {
      width: 220px;
      height: 28px;
      animation: xpHeartMove var(--heart-speed, 1.6s) linear infinite;
    }

    .xpHeartbeat path {
      fill: none;
      stroke: #39ff88;
      stroke-width: 3;
      filter: drop-shadow(0 0 5px rgba(57,255,136,.9));
    }

    .xpHeartbeat.dead {
      border-color: rgba(255,70,70,.35);
    }

    .xpHeartbeat.dead svg {
      animation: none;
    }

    .xpHeartbeat.dead path {
      stroke: #ff4545;
      filter: drop-shadow(0 0 5px rgba(255,69,69,.9));
    }

    @keyframes xpHeartMove {
      from { transform: translateX(0); }
      to { transform: translateX(-110px); }
    }

    #xpTargetsBox {
      margin-top: 11px;
      padding-top: 8px;
      border-top: 1px solid rgba(139,195,74,.25);
    }

    #xpTargetsHeader {
      display: flex;
      justify-content: space-between;
      align-items: center;
      gap: 6px;
      margin-bottom: 5px;
    }

    .xpTargetRow {
      display: grid;
      grid-template-columns: 1fr auto;
      gap: 6px;
      align-items: center;
      background: rgba(255,255,255,.035);
      border-radius: 10px;
      padding: 6px;
      margin-top: 5px;
      border: 1px solid rgba(139,195,74,.10);
    }

    .xpTargetName {
      color: #f0ffe8;
      font-weight: bold;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      max-width: 170px;
    }

    .xpTargetMeta {
      opacity: .78;
      font-size: 10px;
      margin-top: 1px;
      line-height: 1.25;
      color: #d6ffc9;
    }

    .xpAttackBtn {
      text-decoration: none;
      background: linear-gradient(180deg, #53d769, #1f8f3a);
      color: #041006 !important;
      border-radius: 999px;
      padding: 4px 8px;
      font-size: 11px;
      font-weight: bold;
      box-shadow: 0 0 8px rgba(83,215,105,.45);
    }

    .xpAttackBtn:hover {
      filter: brightness(1.15);
    }

    .xpTiny {
      opacity: .68;
      font-size: 10px;
      margin-top: 4px;
      color: #d6ffc9;
    }
  `);

  installNavigationWatcher();
  setInterval(watchAttackPageForHospitalizedTarget, 1500);
  scheduleInit();

  function scheduleInit() {
    clearTimeout(mountTimer);
    mountTimer = setTimeout(() => init(), 300);
  }

  async function init() {
    if (initRunning) return;
    initRunning = true;

    try {
      let key = GM_getValue('tornApiKey', '');

      if (!key) {
        key = prompt('Enter your Torn API key:');
        if (!key) return;
        GM_setValue('tornApiKey', key.trim());
      }

      activeApiKey = key;

      const cached = GM_getValue('xpPctCache', null);

      if (isValidXpCache(cached)) {
        renderPercent(cached.percent, cached.time, true);
        startTargetLoop(key);
        appStarted = true;
      } else {
        GM_deleteValue('xpPctCache');

        if (!document.querySelector(`#${BOX_ID}`)) {
          injectBox('Loading progress...');
        }
      }

      if (isValidXpCache(cached) && Date.now() - cached.time < HOF_CACHE_MS) {
        return;
      }

      await refreshHofPercent(key, cached);

      startTargetLoop(key);
      appStarted = true;
    } catch (err) {
      const cached = GM_getValue('xpPctCache', null);

      if (isValidXpCache(cached)) {
        renderPercent(cached.percent, cached.time, true, `HoF update failed: ${err.message}`);
      } else {
        injectBox(`Progress unavailable<br><small>${escapeHtml(err.message)}</small>`);
      }
    } finally {
      initRunning = false;
    }
  }

  function isValidXpCache(cached) {
    return (
      cached &&
      cached.schema === XP_CACHE_SCHEMA &&
      typeof cached.percent === 'number' &&
      Number.isFinite(cached.percent) &&
      cached.percent >= 0 &&
      cached.percent < 100 &&
      cached.time
    );
  }

  async function refreshHofPercent(key, oldCache) {
    updateProgressText(isValidXpCache(oldCache)
      ? 'Showing cached progress while updating HoF...'
      : 'Loading HoF progress...'
    );

    const profile = await apiV1('/user/?selections=profile', key);

    const userId = profile.player_id || profile.user_id || profile.id;
    const level = profile.level || profile.Level;

    if (!userId || !level) {
      throw new Error('Could not read player ID or level.');
    }

    updateProgressText('Reading level HoF rank...');

    const hof = await apiV2(`/user/${userId}/hof`, key);

    const myRank =
      hof.hof?.level?.rank ||
      hof.level?.rank ||
      hof.level_rank ||
      hof.rank;

    if (!myRank) {
      throw new Error('Could not read level HoF rank.');
    }

    updateProgressText('Calculating HoF progress...');

    const topRank = await findBoundary(key, Number(level), Number(myRank), 'top');
    const bottomRank = await findBoundary(key, Number(level), Number(myRank), 'bottom');

    if (!Number.isFinite(topRank) || !Number.isFinite(bottomRank) || bottomRank <= topRank) {
      throw new Error('Could not calculate valid HoF boundary.');
    }

    const linear = (bottomRank - Number(myRank)) / (bottomRank - topRank);

    let percent = (Math.log1p(linear * 9) / Math.log1p(9)) * 100;

    if (!Number.isFinite(percent)) {
      throw new Error('HoF percentage calculation returned invalid data.');
    }

    percent = Math.max(0, Math.min(99.99, percent));

    const cacheTime = Date.now();

    GM_setValue('xpPctCache', {
      schema: XP_CACHE_SCHEMA,
      time: cacheTime,
      percent
    });

    renderPercent(percent, cacheTime, false);
  }

  async function findBoundary(key, level, myRank, direction) {
    let step = 100;
    let low = myRank;
    let high = myRank;

    while (step <= 500000) {
      const testRank =
        direction === 'top'
          ? Math.max(1, myRank - step)
          : myRank + step;

      const testLevel = await getLevelAtRank(key, testRank);

      if (direction === 'top') {
        if (testLevel > level || testRank === 1) {
          low = testRank;
          high = myRank;
          break;
        }
      } else {
        if (testLevel < level || testLevel === null) {
          low = myRank;
          high = testRank;
          break;
        }
      }

      step *= 2;
    }

    while (low < high) {
      const mid = Math.floor((low + high) / 2);
      const midLevel = await getLevelAtRank(key, mid);

      if (direction === 'top') {
        if (midLevel >= level) high = mid;
        else low = mid + 1;
      } else {
        if (midLevel >= level) low = mid + 1;
        else high = mid;
      }
    }

    return direction === 'top' ? low : low - 1;
  }

  async function getLevelAtRank(key, rank) {
    const offset = Math.max(0, rank - 1);

    const data = await apiV2(
      `/torn/hof?cat=level&limit=1&offset=${offset}`,
      key
    );

    const row =
      data.hof?.[0] ||
      data.hof?.level?.[0] ||
      data.level?.[0] ||
      Object.values(data.hof || {})[0];

    if (!row) return null;

    return Number(row.value || row.level || row.score || row.amount || 0);
  }

  function startTargetLoop(key) {
    clearInterval(window.xpTargetRefreshTimer);
    clearInterval(window.xpDisplayedTargetRecheckTimer);
    clearInterval(window.xpTargetCountdownTimer);

    nextTargetRefreshAt = Date.now() + TARGET_REFRESH_MS;

    loadTargetsPanel(key, true);

    window.xpTargetRefreshTimer = setInterval(() => {
      nextTargetRefreshAt = Date.now() + TARGET_REFRESH_MS;

      if (!document.querySelector(`#${BOX_ID}`)) {
        scheduleInit();
        return;
      }

      recheckDisplayedTargets(key, true);
    }, TARGET_REFRESH_MS);

    window.xpDisplayedTargetRecheckTimer = setInterval(() => {
      if (!document.querySelector(`#${BOX_ID}`)) return;
      recheckDisplayedTargets(key, false);
    }, DISPLAYED_TARGET_RECHECK_MS);

    window.xpTargetCountdownTimer = setInterval(() => {
      updateTargetCountdownText();
    }, 1000);
  }

  async function loadTargetsPanel(key, forceScan = false) {
    const panel = document.querySelector('#xpTargetsContent');
    if (!panel || !key) return;

    const cached = GM_getValue('liveTargetCache', null);

    if (
      !forceScan &&
      cached &&
      Date.now() - cached.time < LIVE_TARGET_CACHE_MS
    ) {
      renderTargets(cached.targets || [], cached.myStats || emptyStats());
      updateTargetRefreshText(cached.totalRawTargets || 0, true);
      return;
    }

    if (targetScanRunning) return;
    targetScanRunning = true;

    panel.innerHTML = `
      ${renderHeartbeat(0)}
      Checking target pulse...
    `;

    try {
      const myStats = await getMyBattleStats(key);
      const rawTargets = await getBaldrTargets();

      const candidates = rawTargets
        .map(normalizeTarget)
        .filter(t => t.id && t.level)
        .filter(t =>
          t.total <= 0 ||
          myStats.total >= t.total * MIN_TOTAL_SAFETY_RATIO
        )
        .filter(t =>
          t.speed <= 0 ||
          t.speed <= myStats.speed * MAX_TARGET_SPEED_RATIO
        )
        .sort(sortMaxPotentialXp)
        .slice(0, CANDIDATES_TO_CHECK);

      const liveTargets = [];

      for (const target of candidates) {
        if (liveTargets.length >= MAX_TARGETS_SHOWN) break;

        await sleep(TARGET_STATUS_DELAY_MS);

        const live = await getTargetLiveStatus(key, target.id);

        target.name = live.name || target.name;
        target.level = live.level || target.level;
        target.statusState = live.statusState;
        target.statusText = live.statusText;

        if (!isAliveAvailable(live.statusState, live.statusText)) {
          continue;
        }

        liveTargets.push(target);
      }

      GM_setValue('liveTargetCache', {
        time: Date.now(),
        targets: liveTargets,
        myStats,
        totalRawTargets: rawTargets.length
      });

      renderTargets(liveTargets, myStats);
      updateTargetRefreshText(rawTargets.length, false);
    } catch (err) {
      const cached = GM_getValue('liveTargetCache', null);

      if (cached?.targets) {
        renderTargets(cached.targets, cached.myStats || emptyStats());
        updateTargetRefreshText(cached.totalRawTargets || 0, true, 'API busy');
      } else {
        panel.innerHTML =
          `${renderHeartbeat(0)}
          <span style="color:#f99;">Targets unavailable</span><br>
          <small>${escapeHtml(err.message)}</small>`;
      }
    } finally {
      targetScanRunning = false;
    }
  }

  async function recheckDisplayedTargets(key, forceDeepIfEmpty = false) {
    if (displayedRecheckRunning || targetScanRunning || !key) return;

    const cached = GM_getValue('liveTargetCache', null);

    if (!cached?.targets?.length) {
      if (forceDeepIfEmpty) {
        await loadTargetsPanel(key, true);
      }
      return;
    }

    displayedRecheckRunning = true;

    try {
      const checked = [];

      for (const target of cached.targets) {
        await sleep(TARGET_STATUS_DELAY_MS);

        try {
          const live = await getTargetLiveStatus(key, target.id);

          if (isAliveAvailable(live.statusState, live.statusText)) {
            checked.push({
              ...target,
              name: live.name || target.name,
              level: live.level || target.level,
              statusState: live.statusState,
              statusText: live.statusText
            });
          }
        } catch {
          checked.push(target);
        }
      }

      cached.targets = checked;
      cached.time = Date.now();

      GM_setValue('liveTargetCache', cached);

      renderTargets(checked, cached.myStats || emptyStats());
      updateTargetRefreshText(cached.totalRawTargets || 0, true, 'fresh live check');

      if (!checked.length && forceDeepIfEmpty) {
        await loadTargetsPanel(key, true);
      }
    } finally {
      displayedRecheckRunning = false;
    }
  }

  function removeTargetFromCache(id) {
    const cached = GM_getValue('liveTargetCache', null);
    if (!cached?.targets) return;

    cached.targets = cached.targets.filter(
      t => Number(t.id) !== Number(id)
    );

    cached.time = Date.now();

    GM_setValue('liveTargetCache', cached);

    renderTargets(cached.targets, cached.myStats || emptyStats());
    updateTargetRefreshText(cached.totalRawTargets || 0, true, 'removed target');
  }

  function watchAttackPageForHospitalizedTarget() {
    let attackedId = '';

    try {
      const url = new URL(location.href);
      attackedId =
        url.searchParams.get('user2ID') ||
        url.searchParams.get('userID') ||
        url.searchParams.get('XID') ||
        '';
    } catch {
      return;
    }

    if (!attackedId) return;

    const text = document.body?.innerText?.toLowerCase() || '';

    const targetDowned =
      text.includes('hospitalized') ||
      text.includes('hospitalised') ||
      text.includes('you hospitalized') ||
      text.includes('you hospitalised') ||
      text.includes('is in hospital') ||
      text.includes('left them in hospital') ||
      text.includes('mugged') ||
      text.includes('attacked and left');

    if (!targetDowned) return;

    if (lastHospitalDetectedId === `${attackedId}:${location.href}`) return;
    lastHospitalDetectedId = `${attackedId}:${location.href}`;

    removeTargetFromCache(attackedId);

    const key = activeApiKey || GM_getValue('tornApiKey', '');

    if (key) {
      GM_deleteValue('liveTargetCache');
      nextTargetRefreshAt = Date.now();
      loadTargetsPanel(key, true);
    }
  }

  function renderHeartbeat(aliveCount) {
    const dead = aliveCount <= 0;

    const speed =
      dead
        ? 0
        : Math.max(0.45, 1.8 - aliveCount * 0.22);

    return `
      <div
        class="xpHeartbeat ${dead ? 'dead' : ''}"
        style="--heart-speed:${speed}s"
        title="${dead ? 'No live targets' : `${aliveCount} live targets`}"
      >
        <svg viewBox="0 0 220 28" preserveAspectRatio="none">
          <path d="${
            dead
              ? 'M0 14 H220'
              : 'M0 14 H25 L32 14 L38 5 L47 23 L56 14 H82 L89 14 L95 7 L104 22 L113 14 H140 L147 14 L153 5 L162 23 L171 14 H220'
          }"></path>
        </svg>
      </div>
    `;
  }

  function renderTargets(targets, myStats) {
    const panel = document.querySelector('#xpTargetsContent');
    if (!panel) return;

    if (!targets.length) {
      panel.innerHTML = `
        ${renderHeartbeat(0)}
        No alive available easy targets right now.
        <div class="xpTiny">
          Next live check in ${formatCountdown(nextTargetRefreshAt - Date.now())}.
        </div>
      `;
      return;
    }

    panel.innerHTML =
      renderHeartbeat(targets.length) +
      targets.map(t => `
        <div class="xpTargetRow">
          <div>
            <div class="xpTargetName">${escapeHtml(t.name)}</div>

            <div class="xpTargetMeta">
              Lv ${Number(t.level).toLocaleString()}
              · ${t.total ? `${shortNumber(t.total)} total` : 'stats ?'}
              ${t.speed ? `· ${shortNumber(t.speed)} spd` : ''}
              <br>
              ${escapeHtml(t.statusState || 'Okay')}
              · ${escapeHtml(t.listName)}
            </div>
          </div>

          <a
            class="xpAttackBtn"
            data-target-id="${encodeURIComponent(t.id)}"
            href="https://www.torn.com/page.php?sid=attack&user2ID=${encodeURIComponent(t.id)}"
            target="_blank"
            rel="noopener noreferrer"
          >Go</a>
        </div>
      `).join('') +
      `
        <div class="xpTiny">
          Total ${shortNumber(myStats.total)}
          · SPD ${shortNumber(myStats.speed)}
          · max XP mode
        </div>
      `;

    document.querySelectorAll('.xpAttackBtn').forEach(btn => {
      btn.onclick = () => {
        const id = btn.getAttribute('data-target-id');
        if (id) removeTargetFromCache(id);
      };
    });
  }

  function renderPercent(percent, cacheTime, cached = false, warning = '') {
    const safePercent =
      Math.max(0, Math.min(99.99, Number(percent)));

    injectBox(`
      <div><b>Level Progress</b></div>

      <div id="xpPctPercent">
        ${safePercent.toFixed(2)}%
      </div>

      <div id="xpRefreshText">
        ${cached ? 'Cached HoF while syncing' : 'HoF synced'}
        · <span id="xpRefreshCountdown">${formatCountdown(getRemainingMs(cacheTime))}</span>
        ${warning ? `<br><small>${escapeHtml(warning)}</small>` : ''}
      </div>

      <div id="xpPctBarOuter">
        <div id="xpPctBarInner"></div>
      </div>

      <div id="xpTargetsBox">
        <div id="xpTargetsHeader">
          <b>Target Pulse</b>
        </div>

        <div id="targetRefreshText">
          Next live check in ${formatCountdown(TARGET_REFRESH_MS)}
        </div>

        <div id="xpTargetsContent">
          Loading targets...
        </div>
      </div>
    `);

    requestAnimationFrame(() => {
      const bar =
        document.querySelector('#xpPctBarInner');

      if (bar) {
        bar.style.width = `${safePercent}%`;
      }
    });

    startRefreshCountdown(cacheTime);
  }

  function updateProgressText(text) {
    const el = document.querySelector('#xpRefreshText');
    if (el) el.innerHTML = escapeHtml(text);
  }

  function startRefreshCountdown(cacheTime) {
    clearInterval(window.xpRefreshCountdownTimer);

    window.xpRefreshCountdownTimer = setInterval(() => {
      const el =
        document.querySelector('#xpRefreshCountdown');

      if (!el) return;

      const remainingMs = getRemainingMs(cacheTime);

      el.textContent = formatCountdown(remainingMs);

      if (remainingMs <= 0) {
        clearInterval(window.xpRefreshCountdownTimer);
        scheduleInit();
      }
    }, 1000);
  }

  function updateTargetRefreshText(totalRawTargets, cached = false, note = '') {
    const el =
      document.querySelector('#targetRefreshText');

    if (!el) return;

    const now = new Date();
    const remaining = Math.max(0, nextTargetRefreshAt - Date.now());

    el.textContent =
      `${cached ? 'Cached' : 'Updated'} ${now.toLocaleTimeString()} · ${totalRawTargets} targets · next live check in ${formatCountdown(remaining)}${note ? ` · ${note}` : ''}`;
  }

  function updateTargetCountdownText() {
    const el = document.querySelector('#targetRefreshText');
    const cached = GM_getValue('liveTargetCache', null);

    if (!el) return;

    const remaining = Math.max(0, nextTargetRefreshAt - Date.now());

    if (cached?.targets) {
      el.textContent =
        `Showing ${cached.targets.length} live targets · next live check in ${formatCountdown(remaining)}`;
    } else {
      el.textContent =
        `Next live check in ${formatCountdown(remaining)}`;
    }

    const tiny = document.querySelector('#xpTargetsContent .xpTiny');
    if (tiny && !cached?.targets?.length) {
      tiny.textContent = `Next live check in ${formatCountdown(remaining)}.`;
    }
  }

  async function getBaldrTargets() {
    const cached = GM_getValue('baldrRawCache', null);

    if (
      cached &&
      Date.now() - cached.time < BALDR_RAW_CACHE_MS
    ) {
      return cached.targets;
    }

    for (const url of BALDR_DATA_URLS) {
      try {
        const data = await httpJson(url);
        const targets = normalizeBaldrTargets(data);

        if (targets.length) {
          GM_setValue('baldrRawCache', {
            time: Date.now(),
            targets
          });

          return targets;
        }
      } catch {}
    }

    throw new Error('Could not load Baldr targets.');
  }

  async function getMyBattleStats(key) {
    const data =
      await apiV1('/user/?selections=battlestats', key);

    const strength = Number(data.strength || 0);
    const defense = Number(data.defense || data.defence || 0);
    const speed = Number(data.speed || 0);
    const dexterity = Number(data.dexterity || 0);

    return {
      strength,
      defense,
      speed,
      dexterity,
      total:
        strength + defense + speed + dexterity
    };
  }

  async function getTargetLiveStatus(key, id) {
    const data =
      await apiV1(`/user/${id}?selections=profile`, key);

    const statusObj = data.status || {};

    return {
      name: data.name,
      level: Number(data.level || 0),
      statusState:
        statusObj.state ||
        data.status_state ||
        '',
      statusText:
        statusObj.description ||
        statusObj.details ||
        data.status ||
        ''
    };
  }

  function normalizeBaldrTargets(data) {
    const out = [];
    const seen = new Set();

    function walk(value, listName = '') {
      if (Array.isArray(value)) {
        value.forEach(v => walk(v, listName));
        return;
      }

      if (!value || typeof value !== 'object') return;

      const id =
        value.id ||
        value.ID ||
        value.user_id ||
        value.userid ||
        value.player_id ||
        value.torn_id ||
        value.uid ||
        value.UserID;

      const level =
        value.level ||
        value.Level ||
        value.lvl ||
        value.LVL;

      if (id && level) {
        const numericId = Number(id);

        if (!seen.has(numericId)) {
          seen.add(numericId);

          out.push({
            ...value,
            _listName: listName || 'Baldr'
          });
        }

        return;
      }

      for (const [key, child] of Object.entries(value)) {
        walk(child, key || listName);
      }
    }

    walk(data, 'Baldr');

    return out;
  }

  function normalizeTarget(t) {
    const id = Number(
      t.id ||
      t.ID ||
      t.user_id ||
      t.userid ||
      t.player_id ||
      t.torn_id ||
      t.uid ||
      t.UserID ||
      0
    );

    return {
      id,
      name: String(t.name || t.Name || t.username || `User ${id}`),
      level: Number(t.level || t.Level || t.lvl || 0),
      speed: parseStat(t.speed || t.Speed || 0),
      total: parseStat(
        t.total ||
        t.Total ||
        t.total_stats ||
        t.stats ||
        t.Stats ||
        0
      ),
      listName: String(t._listName || 'Baldr')
    };
  }

  function sortMaxPotentialXp(a, b) {
    if (b.level !== a.level) {
      return b.level - a.level;
    }

    return (a.total || 0) - (b.total || 0);
  }

  function isAliveAvailable(statusState, statusText) {
    const combined =
      `${statusState} ${statusText}`.toLowerCase();

    if (combined.includes('dead')) return false;
    if (combined.includes('hospital')) return false;
    if (combined.includes('jail')) return false;
    if (combined.includes('travel')) return false;
    if (combined.includes('abroad')) return false;
    if (combined.includes('federal')) return false;

    return combined.includes('okay') || combined.trim() === '';
  }

  function emptyStats() {
    return {
      strength: 0,
      defense: 0,
      speed: 0,
      dexterity: 0,
      total: 0
    };
  }

  function getRemainingMs(cacheTime) {
    return Math.max(
      0,
      HOF_CACHE_MS - (Date.now() - cacheTime)
    );
  }

  function formatCountdown(ms) {
    const totalSeconds =
      Math.ceil(Math.max(0, ms) / 1000);

    const mins = Math.floor(totalSeconds / 60);
    const secs = totalSeconds % 60;

    return `${mins}:${String(secs).padStart(2, '0')}`;
  }

  function parseStat(value) {
    if (typeof value === 'number') return value;

    const str =
      String(value)
        .toLowerCase()
        .replace(/,/g, '')
        .trim();

    const n = parseFloat(str);

    if (!Number.isFinite(n)) return 0;

    if (str.endsWith('k')) return n * 1_000;
    if (str.endsWith('m')) return n * 1_000_000;
    if (str.endsWith('b')) return n * 1_000_000_000;

    return n;
  }

  function shortNumber(num) {
    num = Number(num || 0);

    if (num >= 1_000_000_000) {
      return `${(num / 1_000_000_000).toFixed(1)}b`;
    }

    if (num >= 1_000_000) {
      return `${(num / 1_000_000).toFixed(1)}m`;
    }

    if (num >= 1_000) {
      return `${(num / 1_000).toFixed(1)}k`;
    }

    return String(Math.round(num));
  }

  function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  function apiV1(path, key) {
    return api(
      `https://api.torn.com${path}${path.includes('?') ? '&' : '?'}key=${encodeURIComponent(key)}`
    );
  }

  function apiV2(path, key) {
    return api(
      `https://api.torn.com/v2${path}${path.includes('?') ? '&' : '?'}key=${encodeURIComponent(key)}`
    );
  }

  function api(url) {
    return httpJson(url);
  }

  function httpJson(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        timeout: 20000,

        onload: res => {
          try {
            const json = JSON.parse(res.responseText);

            if (json.error) {
              reject(
                new Error(
                  json.error.error || json.error
                )
              );
            } else {
              resolve(json);
            }
          } catch {
            reject(new Error('Bad API response.'));
          }
        },

        onerror: () => reject(new Error('Request failed.')),
        ontimeout: () => reject(new Error('Request timed out.'))
      });
    });
  }

  function installNavigationWatcher() {
    const observer = new MutationObserver(() => {
      watchAttackPageForHospitalizedTarget();

      const urlChanged = location.href !== lastUrl;

      if (urlChanged) {
        lastUrl = location.href;
        scheduleInit();
        return;
      }

      if (
        appStarted &&
        !document.querySelector(`#${BOX_ID}`)
      ) {
        scheduleInit();
      }
    });

    observer.observe(document.documentElement, {
      childList: true,
      subtree: true
    });

    ['pushState', 'replaceState'].forEach(fn => {
      const original = history[fn];

      history[fn] = function () {
        const result =
          original.apply(this, arguments);

        scheduleInit();

        return result;
      };
    });

    window.addEventListener('popstate', scheduleInit);
    window.addEventListener('hashchange', scheduleInit);
  }

  function getInjectTarget() {
    return (
      document.querySelector('#sidebarroot') ||
      document.querySelector('.content-title') ||
      document.querySelector('#mainContainer') ||
      document.body
    );
  }

  function injectBox(html) {
    let box =
      document.querySelector(`#${BOX_ID}`);

    const target = getInjectTarget();

    if (!target) return;

    if (!box) {
      box = document.createElement('div');
      box.id = BOX_ID;
    }

    if (
      !box.isConnected ||
      box.parentElement !== target
    ) {
      target.prepend(box);
    }

    box.innerHTML = html;
  }

  function addStyle(css) {
    if (document.querySelector(`#${STYLE_ID}`)) {
      return;
    }

    const style = document.createElement('style');
    style.id = STYLE_ID;
    style.textContent = css;

    document.head.appendChild(style);
  }

  function escapeHtml(str) {
    return String(str).replace(/[&<>"']/g, s => ({
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&#39;'
    }[s]));
  }
})();