Torn Leveling Help [PDA Version BETA]

Compact Torn applet with HoF level progress and live Baldr targets.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Torn Leveling Help [PDA Version BETA]
// @namespace    torn-leveling-help-pda-beta
// @version      2.1.4
// @description  Compact Torn applet with HoF level progress and live Baldr targets.
// @author       RyanMundu
// @license      MIT
// @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
// ==/UserScript==

/*
MIT License

Copyright (c) 2026 RyanMundu

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files, to deal in the Software
without restriction, including without limitation the rights to use, copy,
modify, merge, publish, distribute, sublicense, and/or sell copies of the
Software, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

(function () {
  'use strict';

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

  const HOF_CACHE_MS = 2 * 60 * 1000;
  const TARGET_REFRESH_MS = 25 * 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 activeApiKey = '';
  let targetScanRunning = 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;
      box-sizing: border-box;
    }

    @media (max-width: 700px) {
      #xpPctBox {
        width: calc(100vw - 16px);
        max-width: none;
        margin: 8px auto;
        padding: 10px 12px;
        border-radius: 16px;
        font-size: 11px;
      }

      #xpPctPercent {
        font-size: 24px !important;
      }

      .xpHeartbeat {
        height: 22px !important;
        margin: 5px 0 !important;
      }

      .xpHeartbeat svg {
        height: 22px !important;
        width: 100% !important;
      }

      .xpTargetRow {
        padding: 5px !important;
        gap: 5px !important;
      }

      .xpTargetName {
        max-width: none !important;
      }

      .xpTargetMeta {
        font-size: 9px !important;
      }

      .xpAttackBtn {
        font-size: 10px !important;
        padding: 3px 7px !important;
      }
    }

    #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: 10px;
      padding-top: 7px;
      border-top: 1px solid rgba(139,195,74,.25);
    }

    .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: 160px;
    }

    .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);
    }

    .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 FULL ACCESS 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);
      } else {
        injectBox('Loading progress...');
      }

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

      await refreshHofPercent(key, cached);
      startTargetLoop(key);
    } catch (err) {
      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
    );
  }

  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 profile.');
    }

    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 HoF rank.');
    }

    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 HoF progress.');
    }

    const linear = (bottomRank - Number(myRank)) / (bottomRank - topRank);
    let percent = (Math.log1p(linear * 9) / Math.log1p(9)) * 100;

    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.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;
      }

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

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

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

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

    if (!forceScan && cached?.targets?.length && Date.now() - cached.time < TARGET_REFRESH_MS) {
      renderTargets(cached.targets || [], cached.myStats || emptyStats());
      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);

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

        liveTargets.push({
          ...target,
          name: live.name || target.name,
          level: live.level || target.level,
          statusState: live.statusState,
          statusText: live.statusText
        });
      }

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

      renderTargets(liveTargets, myStats);
    } catch (err) {
      const cached = GM_getValue('liveTargetCache', null);

      if (cached?.targets?.length) {
        renderTargets(cached.targets, cached.myStats || emptyStats());
      } else {
        panel.innerHTML = `
          ${renderHeartbeat(0)}
          Targets unavailable<br>
          <small>${escapeHtml(err.message)}</small>
        `;
      }
    } finally {
      targetScanRunning = false;
    }
  }

  function watchAttackPageForHospitalizedTarget() {
    let attackedId = '';

    try {
      const url = new URL(location.href);

      attackedId =
        url.searchParams.get('user2ID') ||
        url.searchParams.get('userID') ||
        '';
    } catch {
      return;
    }

    if (!attackedId) return;

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

    const downed =
      text.includes('hospitalized') ||
      text.includes('hospitalised') ||
      text.includes('is in hospital') ||
      text.includes('left them in hospital');

    if (!downed) return;

    if (lastHospitalDetectedId === attackedId) return;
    lastHospitalDetectedId = attackedId;

    removeTargetFromCache(attackedId);

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

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

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

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

    GM_setValue('liveTargetCache', cached);

    renderTargets(cached.targets, cached.myStats || emptyStats());
  }

  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">
        <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.
      `;
      return;
    }

    panel.innerHTML =
      renderHeartbeat(targets.length) +
      targets.map(t => `
        <div class="xpTargetRow">
          <div>
            <div class="xpTargetName">${escapeHtml(t.name)}</div>
            <div class="xpTargetMeta">
              Lv ${t.level}
              · ${shortNumber(t.total)} total
              · ${shortNumber(t.speed)} spd
            </div>
          </div>

          <a
            class="xpAttackBtn"
            href="https://www.torn.com/page.php?sid=attack&user2ID=${t.id}"
            target="_blank"
            data-target-id="${t.id}"
          >Go</a>
        </div>
      `).join('') +
      `
        <div class="xpTiny">
          Total ${shortNumber(myStats.total)}
        </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) {
    const safePercent = Math.max(0, Math.min(99.99, Number(percent) || 0));

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

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

      <div id="xpRefreshText">
        ${cached ? 'Cached HoF data' : 'HoF synced'}
      </div>

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

      <div id="xpTargetsBox">
        <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}%`;
    });
  }

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

  function updateTargetCountdownText() {
    const el = document.querySelector('#targetRefreshText');
    if (!el) return;

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

    el.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;
    }

    let lastError = null;

    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 (err) {
        lastError = err;
      }
    }

    throw lastError || 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(state, text) {
    const combined = `${state} ${text}`.toLowerCase();

    if (combined.includes('hospital')) return false;
    if (combined.includes('dead')) 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 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) {
    function parseJson(text) {
      const json = JSON.parse(text);

      if (json.error) {
        throw new Error(json.error.error || json.error);
      }

      return json;
    }

    function gmRequest() {
      return new Promise((resolve, reject) => {
        if (typeof GM_xmlhttpRequest !== 'function') {
          reject(new Error('GM unavailable'));
          return;
        }

        GM_xmlhttpRequest({
          method: 'GET',
          url,
          timeout: 20000,
          headers: {
            Accept: 'application/json,text/plain,*/*'
          },
          onload: res => {
            try {
              resolve(parseJson(res.responseText));
            } catch (err) {
              reject(err);
            }
          },
          onerror: () => reject(new Error('GM request failed')),
          ontimeout: () => reject(new Error('GM request timed out'))
        });
      });
    }

    function fetchRequest() {
      return fetch(url, {
        method: 'GET',
        cache: 'no-store',
        credentials: 'omit'
      })
        .then(r => r.text())
        .then(parseJson);
    }

    return gmRequest().catch(() => {
      if (typeof fetch === 'function') {
        return fetchRequest();
      }

      throw new Error('No request method available.');
    });
  }

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

      const urlChanged = location.href !== lastUrl;

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

      if (!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);
    window.addEventListener('focus', scheduleInit);
    document.addEventListener('visibilitychange', () => {
      if (!document.hidden) 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 || document.documentElement).appendChild(style);
  }

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