Torn Leveling Helper

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

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

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