Bustr+

Safer Torn jail-busting helper with custom user/log key setup, bust budget estimate, hardness scores, filtering, sorting, and confirmation skipping. Now with full Script Hub integration and HOF Busts rank display.

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

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

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Bustr+
// @namespace    https://greasyfork.org/users/cowboyup
// @version      1.2.5
// @description  Safer Torn jail-busting helper with custom user/log key setup, bust budget estimate, hardness scores, filtering, sorting, and confirmation skipping. Now with full Script Hub integration and HOF Busts rank display.
// @author       cowboyup
// @license      MIT
// @match        https://www.torn.com/*
// @run-at       document-end
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_openInTab
// @grant        GM_xmlhttpRequest
// @connect      api.torn.com
// ==/UserScript==

(function () {
  'use strict';

  const SCRIPT = 'Bustr+';
  const VERSION = '1.2.5';
  const STORAGE_PREFIX = 'bustrPlusSafe.';
  const API_KEY_TITLE = 'BUSTRPlusLogs';
  const API_KEY_PERMISSION = 'User / Log';
  const API_KEY_URL = 'https://www.torn.com/preferences.php#tab=api?step=addNewKey&title=BUSTRPlusLogs&user=log';
  const BUST_LOG_ID = 5360;
  const HUB_ID = 'bustr-plus';
  const CACHE_TTL_HOF = 60 * 60 * 1000; // 1 hour

  const DEFAULT_SETTINGS = {
    enabled: true,
    refreshSeconds: 60,
    freshBustScore: 128,
    decayDivisorHours: 10,
    decayWindowHours: 72,
    customPenaltyThreshold: 0,
    hardnessLimit: 460,
    hideHardTargets: true,
    sortByHardness: true,
    showHardness: true,
    skipBustConfirm: true,
    theme: 'dark',
    redLimit: 0,
    greenLimit: 3,
  };

  let state = {
    settings: { ...DEFAULT_SETTINGS },
    apiKey: '',
    timestamps: [],
    penaltyScore: 0,
    penaltyThreshold: 0,
    availableBusts: null,
    hofRank: null,
    isScrapingHof: false,
    lastFetchMs: 0,
    lastLocalBustMs: 0,
    lastLocalBustSignature: '',
    lastBustActionClickMs: 0,
    lastBustActionHref: '',
    loadBustDataPromise: null,
    navStatsPending: false,
    hardnessRendering: false,
    hardnessRenderTimer: null,
    refreshTimerId: null,
    returnToJailTimerId: null,
    observerStarted: false,
    hardnessObserverStarted: false,
    bustConfirmSkipperStarted: false,
    pathWatcherStarted: false,
    hubPresent: false,
  };

  const store = {
    get(key, fallback) {
      const fullKey = STORAGE_PREFIX + key;
      try {
        if (typeof GM_getValue === 'function') return GM_getValue(fullKey, fallback);
      } catch (err) {}
      try {
        const raw = localStorage.getItem(fullKey);
        return raw == null ? fallback : JSON.parse(raw);
      } catch (err) {
        return fallback;
      }
    },
    set(key, value) {
      const fullKey = STORAGE_PREFIX + key;
      try {
        if (typeof GM_setValue === 'function') {
          GM_setValue(fullKey, value);
          return;
        }
      } catch (err) {}
      localStorage.setItem(fullKey, JSON.stringify(value));
    },
    del(key) {
      const fullKey = STORAGE_PREFIX + key;
      try {
        if (typeof GM_deleteValue === 'function') GM_deleteValue(fullKey);
      } catch (err) {}
      localStorage.removeItem(fullKey);
    },
  };

  function loadState() {
    state.settings = { ...DEFAULT_SETTINGS, ...store.get('settings', {}) };
    state.apiKey = store.get('apiKey', '');
    const savedStats = store.get('stats', {});
    state = { ...state, ...savedStats, settings: state.settings, apiKey: state.apiKey };
  }

  function saveSettings() {
    store.set('settings', state.settings);
  }

  function saveStats() {
    store.set('stats', {
      timestamps: state.timestamps,
      penaltyScore: state.penaltyScore,
      penaltyThreshold: state.penaltyThreshold,
      availableBusts: state.availableBusts,
      lastFetchMs: state.lastFetchMs,
    });
  }

  function setApiKey(apiKey) {
    state.apiKey = apiKey.trim();
    store.set('apiKey', state.apiKey);
  }

  function deleteApiKey() {
    state.apiKey = '';
    store.del('apiKey');
  }

  // =========================================================================
  // HOF BUSTS RANK INTEGRATION
  // =========================================================================
  function getCachedHofRank() {
    const raw = store.get('hofRank', null);
    if (!raw || typeof raw !== 'object' || !raw.rank || !raw.ts) return null;
    if (Date.now() - raw.ts < CACHE_TTL_HOF) {
      return raw.rank;
    }
    return null;
  }

  function saveHofRank(rank) {
    store.set('hofRank', { rank, ts: Date.now() });
    state.hofRank = rank;
  }

  function extractHofRankFromDOM(root) {
    const boxes = root.querySelectorAll('[class*="infoBox"]');
    for (const box of boxes) {
      // Specifically look for the "Busts" category rank (e.g., "Busts #1,234")
      const match = box.textContent.match(/Busts\s*#(\d[\d,]*)/i);
      if (match) {
        return parseInt(match[1].replace(/,/g, ''), 10);
      }
    }
    return null;
  }

  function scrapeHofRank() {
    if (state.isScrapingHof || !state.settings.enabled) return;
    state.isScrapingHof = true;
    
    document.querySelector('#bustr-hof-iframe')?.remove();
    const iframe = document.createElement('iframe');
    iframe.id = 'bustr-hof-iframe';
    iframe.src = 'https://www.torn.com/page.php?sid=hof';
    iframe.style.cssText = 'position:fixed;width:1px;height:1px;opacity:0;pointer-events:none;left:-9999px;top:-9999px;';
    document.body.appendChild(iframe);

    let attempts = 0;
    const maxAttempts = 45;
    const iv = setInterval(() => {
      attempts++;
      try {
        const doc = iframe.contentDocument || iframe.contentWindow?.document;
        if (!doc || doc.readyState !== 'complete') return;
        const rank = extractHofRankFromDOM(doc);
        if (rank !== null) {
          saveHofRank(rank);
          console.log('[Bustr+] Auto-scraped HOF Busts rank via iframe:', rank);
          clearInterval(iv);
          iframe.remove();
          state.isScrapingHof = false;
          renderAll();
          return;
        }
      } catch (e) {
        // cross-origin / still loading
      }
      if (attempts > maxAttempts) {
        clearInterval(iv);
        iframe.remove();
        state.isScrapingHof = false;
        console.warn('[Bustr+] HOF iframe scrape timed out');
      }
    }, 350);
  }

  // =========================================================================
  // HUB INTEGRATION
  // =========================================================================
  function isHubPresent() {
    return !!document.getElementById('tsh-sidebar-row');
  }

  function getStatusText() {
    if (!state.apiKey) {
      return 'No API key set — paste a User / Log key below';
    }
    const avail = state.availableBusts == null ? '?' : Math.max(0, state.availableBusts);
    const hof = state.hofRank != null ? `#${state.hofRank.toLocaleString()}` : '?';
    return `Busts: ${avail} | HOF Busts: ${hof} | score ${state.penaltyScore}/${state.penaltyThreshold || '?'}`;
  }

  function registerWithHub() {
    state.hubPresent = isHubPresent();

    document.dispatchEvent(new CustomEvent('torn-script-hub:register', {
      detail: {
        id: HUB_ID,
        name: 'BUSTR+',
        version: VERSION,
        order: 200,
        open: () => {
          document.dispatchEvent(new CustomEvent('torn-script-hub:open', {
            detail: { scriptId: HUB_ID }
          }));
          if (isJailPage()) {
            loadBustData(false).then(() => renderAll());
          }
        },
        close: () => {
          state.settings.enabled = false;
          saveSettings();
          cleanupModifications();
          if (state.refreshTimerId) {
            clearInterval(state.refreshTimerId);
            state.refreshTimerId = null;
          }
        },
        status: getStatusText,
        prefs: {
          fields: [
            { key: 'apiKey', type: 'password', label: 'Custom API key', default: '', placeholder: '16 character Torn custom key', hint: 'User / Log permission only. Used for bust log 5360.' },
            { key: 'generateKey', type: 'button', label: 'Generate User / Log key', hint: 'Opens Torn API key page pre-filled for User / Log only', onClick: () => openUrl(API_KEY_URL) },
            { key: 'enabled', type: 'toggle', label: 'Active', default: true, hint: 'Turn Bustr+ on or off' },
            { key: 'skipBustConfirm', type: 'toggle', label: 'Skip bust confirm', default: true, hint: 'Automatically confirm bust actions' },
            { key: 'hideHardTargets', type: 'toggle', label: 'Hide hard targets', default: true, hint: 'Completely hide targets above the hardness ceiling' },
            { key: 'sortByHardness', type: 'toggle', label: 'Sort easiest first', default: true, hint: 'Sort jail list by hardness score ascending' },
            { key: 'showHardness', type: 'toggle', label: 'Show hardness scores', default: true, hint: 'Display the hardness score column on the jail list' },
            { key: 'hardnessLimit', type: 'number', label: 'Hardness ceiling', default: 460, min: 0, hint: 'Maximum jail target score shown as safe. Lower is stricter.' },
            { key: 'customPenaltyThreshold', type: 'number', label: 'Manual penalty cap', default: 0, min: 0, hint: 'Optional manual cap for bust budget. Leave 0 to auto-estimate from history.' },
            { key: 'refreshSeconds', type: 'number', label: 'Refresh seconds', default: 60, min: 15, hint: 'How often to check user/log for new busts. 60 is calm and API-friendly.' },
            { key: 'decayDivisorHours', type: 'number', label: 'Decay rate (hrs)', default: 10, min: 1, hint: 'How quickly old busts cool off. Smaller = faster forgiveness.' },
            { key: 'decayWindowHours', type: 'number', label: 'Decay window (hrs)', default: 72, min: 1, hint: 'How far back to look when estimating bust fatigue.' },
            { key: 'greenLimit', type: 'number', label: 'Green at busts', default: 3, min: 0, hint: 'When the jail menu counter turns green.' },
            { key: 'redLimit', type: 'number', label: 'Red at busts', default: 0, min: 0, hint: 'When the jail menu counter turns red.' },
          ],
          values: {
            enabled: state.settings.enabled,
            skipBustConfirm: state.settings.skipBustConfirm,
            hideHardTargets: state.settings.hideHardTargets,
            sortByHardness: state.settings.sortByHardness,
            showHardness: state.settings.showHardness,
            hardnessLimit: state.settings.hardnessLimit,
            customPenaltyThreshold: state.settings.customPenaltyThreshold,
            refreshSeconds: state.settings.refreshSeconds,
            decayDivisorHours: state.settings.decayDivisorHours,
            decayWindowHours: state.settings.decayWindowHours,
            greenLimit: state.settings.greenLimit,
            redLimit: state.settings.redLimit,
            apiKey: state.apiKey || '',
          },
          onSave: (values) => {
            const wasEnabled = state.settings.enabled;
            let keyPromise = null;

            state.settings.enabled = !!values.enabled;
            state.settings.skipBustConfirm = !!values.skipBustConfirm;
            state.settings.hideHardTargets = !!values.hideHardTargets;
            state.settings.sortByHardness = !!values.sortByHardness;
            state.settings.showHardness = values.showHardness !== false;
            state.settings.hardnessLimit = Math.max(0, Number(values.hardnessLimit) || 0);
            state.settings.customPenaltyThreshold = Math.max(0, Number(values.customPenaltyThreshold) || 0);
            state.settings.refreshSeconds = Math.max(15, Number(values.refreshSeconds) || 60);
            state.settings.decayDivisorHours = Math.max(1, Number(values.decayDivisorHours) || 10);
            state.settings.decayWindowHours = Math.max(1, Number(values.decayWindowHours) || 72);
            state.settings.greenLimit = Math.max(0, Number(values.greenLimit) || 0);
            state.settings.redLimit = Math.max(0, Number(values.redLimit) || 0);

            if (typeof values.apiKey === 'string') {
              const key = values.apiKey.trim();
              if (key && /^[A-Za-z0-9]{16}$/.test(key) && key !== state.apiKey) {
                setApiKey(key);
                keyPromise = loadBustData(true).then(() => renderAll());
              }
            }

            saveSettings();
            recalcStats();
            saveStats();

            if (!wasEnabled && state.settings.enabled) {
              keyPromise = loadBustData(false).then(() => {
                renderAll();
                startRefreshTimer();
                scanExistingBustSuccesses();
              });
            } else if (wasEnabled && !state.settings.enabled) {
              if (state.refreshTimerId) {
                clearInterval(state.refreshTimerId);
                state.refreshTimerId = null;
              }
              cleanupModifications();
            } else if (state.settings.enabled) {
              renderAll();
              startRefreshTimer();
            }

            return keyPromise;
          }
        }
      }
    }));
  }

  function cleanupModifications() {
    document.querySelectorAll('.bustr-plus-nav, .bustr-plus-pill').forEach(el => el.remove());
    document.body.classList.remove('bustr-plus-green', 'bustr-plus-orange', 'bustr-plus-red');
    document.querySelectorAll('.bustr-plus-hardness-col').forEach(el => el.remove());
    document.querySelectorAll('.bustr-plus-hardness-wrap').forEach(el => el.remove());
    document.querySelectorAll('.bustr-plus-hard-row, .bustr-plus-hidden-hard').forEach(row => {
      row.classList.remove('bustr-plus-hard-row', 'bustr-plus-hidden-hard');
      row.style.order = '';
    });
    document.querySelectorAll("a[href*='breakout']").forEach(link => unmarkQuickBustLink(link));
    removeToggleButton();
    removeSettingsPanel();
  }

  // =========================================================================
  // STYLES
  // =========================================================================
  function injectStyles() {
    if (document.getElementById('bustr-plus-style')) return;
    const style = document.createElement('style');
    style.id = 'bustr-plus-style';
    style.textContent = `
      body.bustr-plus-green { --bustr-plus-color: #85b200; }
      body.bustr-plus-orange { --bustr-plus-color: #d08000; }
      body.bustr-plus-red { --bustr-plus-color: #e64d1a; }
      #nav-jail .bustr-plus-nav {
        display: inline-flex; position: relative; top: 3px; align-items: center; justify-content: center;
        min-width: 16px; margin-left: 4px; color: var(--bustr-plus-color, inherit); font-weight: 700;
        line-height: 1; text-align: center; vertical-align: middle;
      }
      #nav-jail .bustr-plus-pill { position: absolute; top: 2px; right: 4px; min-width: 14px; text-align: center; color: var(--bustr-plus-color, #85b200); font-size: 11px; font-weight: 700; pointer-events: none; }
      #bustr-plus-panel {
        --bustr-bg: #202020; --bustr-fg: #ddd; --bustr-border: #444; --bustr-header-border: #3a3a3a;
        --bustr-input-bg: #111; --bustr-button-bg: #303030; --bustr-toggle-off: #555;
        --bustr-danger-bg: #5b2520; --bustr-danger-border: #a94b3f; --bustr-danger-fg: #fff; --bustr-muted: #aaa;
        position: fixed; z-index: 99999; right: 14px; top: 62px; width: min(470px, calc(100vw - 28px));
        max-height: min(680px, calc(100vh - 92px)); overflow: hidden; background: var(--bustr-bg); color: var(--bustr-fg);
        border: 1px solid var(--bustr-border); border-radius: 8px; box-shadow: 0 10px 30px rgba(0,0,0,.55); font: 12px/1.4 Arial, sans-serif;
      }
      #bustr-plus-panel.bustr-plus-light {
        --bustr-bg: #f7f7f7; --bustr-fg: #242424; --bustr-border: #c9c9c9; --bustr-header-border: #d8d8d8;
        --bustr-input-bg: #fff; --bustr-button-bg: #ececec; --bustr-toggle-off: #d0d0d0;
        --bustr-danger-bg: #fff1f0; --bustr-danger-border: #c75146; --bustr-danger-fg: #8d1f17; --bustr-muted: #666;
      }
      #bustr-plus-panel[hidden] { display: none; }
      #bustr-plus-panel header { display: flex; align-items: center; justify-content: space-between; padding: 9px 11px; border-bottom: 1px solid var(--bustr-header-border); font-weight: 700; }
      #bustr-plus-panel .header-actions { display: flex; align-items: center; gap: 6px; }
      #bustr-plus-panel button, #bustr-plus-panel input, #bustr-plus-panel select {
        font: inherit; border-radius: 5px; border: 1px solid var(--bustr-border); background: var(--bustr-input-bg); color: var(--bustr-fg); min-height: 23px;
      }
      #bustr-plus-panel button { cursor: pointer; padding: 2px 8px; background: var(--bustr-button-bg); }
      #bustr-plus-panel button.primary { background: #3769a8; border-color: #5489c9; color: #fff; }
      #bustr-plus-panel button.danger { background: var(--bustr-danger-bg); border-color: var(--bustr-danger-border); color: var(--bustr-danger-fg); }
      #bustr-plus-panel input { box-sizing: border-box; width: 100%; padding: 3px 7px; }
      #bustr-plus-panel .body { padding: 9px 11px 10px; display: grid; gap: 7px; max-height: calc(min(680px, calc(100vh - 92px)) - 47px); overflow-y: auto; }
      #bustr-plus-panel .row { display: grid; grid-template-columns: 1fr auto; gap: 8px; align-items: center; }
      #bustr-plus-panel .grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 10px; row-gap: 8px; align-items: end; }
      #bustr-plus-panel .grid label { display: grid; grid-template-rows: 30px auto; min-width: 0; }
      #bustr-plus-panel .toggles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
      #bustr-plus-panel .toggles label { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
      #bustr-plus-panel .toggles span { margin: 0; line-height: 1.15; }
      #bustr-plus-panel .toggles input[type="checkbox"] {
        appearance: none; -webkit-appearance: none; position: relative; width: 34px; min-width: 34px; height: 18px;
        min-height: 18px; margin: 0; border: 1px solid var(--bustr-border); border-radius: 999px;
        background: var(--bustr-toggle-off); cursor: pointer; transition: background .16s ease, border-color .16s ease;
      }
      #bustr-plus-panel .toggles input[type="checkbox"]::after {
        content: ""; position: absolute; width: 14px; height: 14px; left: 1px; top: 1px; border-radius: 50%;
        background: var(--bustr-bg); box-shadow: 0 1px 3px rgba(0,0,0,.35); transition: transform .16s ease, background .16s ease;
      }
      #bustr-plus-panel .toggles input[type="checkbox"]:checked { border-color: #5489c9; background: #4278bd; }
      #bustr-plus-panel .toggles input[type="checkbox"]:checked::after { transform: translateX(16px); background: #fff; }
      #bustr-plus-panel .actions { position: sticky; bottom: -10px; padding-top: 6px; background: var(--bustr-bg); }
      #bustr-plus-panel label span { display: block; color: var(--bustr-muted); margin-bottom: 1px; }
      #bustr-plus-panel .label-line { display: flex; align-items: flex-end; align-content: flex-end; gap: 4px; min-width: 0; min-height: 25px; line-height: 1.15; flex-wrap: wrap; overflow: hidden; }
      #bustr-plus-panel .help-dot {
        display: inline-grid; place-items: center; width: 14px; height: 14px; margin-bottom: 1px;
        color: #5489c9; font-size: 12px; line-height: 1; cursor: help; outline: none;
      }
      #bustr-plus-help-tip {
        position: fixed; width: min(270px, calc(100vw - 24px)); padding: 8px 10px; border-radius: 6px;
        background: rgba(18, 18, 18, .95); color: #f4f4f4; box-shadow: 0 8px 22px rgba(0,0,0,.28);
        font-size: 12px; font-weight: 400; line-height: 1.35; text-align: left; white-space: normal;
        opacity: 1; pointer-events: none; z-index: 100001;
      }
      #bustr-plus-help-tip.bustr-plus-light { background: rgba(255, 255, 255, .98); color: #222; box-shadow: 0 8px 24px rgba(0,0,0,.18); }
      #bustr-plus-panel .hint { color: var(--bustr-muted); font-size: 11px; }
      #bustr-plus-panel .status { color: var(--bustr-plus-color, #85b200); font-weight: 700; }
      #bustr-plus-panel .version { color: var(--bustr-muted); font-size: 10px; text-align: right; }
      #bustr-plus-panel .icon-btn { width: 28px; min-width: 28px; padding: 0; display: inline-grid; place-items: center; }
      #bustr-plus-navitem {
        display: flex; justify-content: center; align-items: center; padding: 4px 0;
      }
      #bustr-plus-navitem .bustr-plus-navitem-link {
        display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px;
        width: 50px; min-height: 44px; padding: 4px 2px; border: 0; border-radius: 6px; background: transparent;
        color: #999; cursor: pointer; font: 700 9px/1.1 Arial, sans-serif; text-transform: uppercase;
        letter-spacing: .02em; text-align: center;
      }
      #bustr-plus-navitem .bustr-plus-navitem-link:hover,
      #bustr-plus-navitem .bustr-plus-navitem-link:focus-visible { color: #ccc; background: rgba(255,255,255,.06); outline: none; }
      #bustr-plus-navitem .bustr-plus-navitem-iconwrap { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; }
      #bustr-plus-navitem .bustr-plus-navitem-iconwrap svg { display: block; fill: currentColor; }
      #bustr-plus-navitem .bustr-plus-navitem-label { white-space: nowrap; }
      #bustr-plus-navitem.bustr-plus-navitem-fallback {
        position: fixed; z-index: 99998; right: 10px; bottom: 14px; padding: 0;
        background: rgba(24,24,24,.9); border-radius: 8px; box-shadow: 0 6px 18px rgba(0,0,0,.4);
      }
      #bustr-plus-navitem.bustr-plus-navitem-fallback .bustr-plus-navitem-link { color: #eee; }
      #body .users-list-title { display: flex; justify-content: start; align-items: center; }
      #body .users-list-title .title { width: 269px; }
      #body .users-list-title .time { width: 50px; }
      #body .users-list-title .level { width: 53px; }
      #body .users-list-title .reason { width: 205px; }
      #body .users-list-title .hardness { display: block; width: 79px; text-align: center; }
      #body .user-info-list-wrap { display: flex; flex-direction: column; justify-content: start; align-items: center; }
      #body .user-info-list-wrap > li { display: flex; flex-wrap: wrap; justify-content: start; align-items: center; }
      #body .user-info-list-wrap > li .info-wrap { display: flex; flex-wrap: wrap; justify-content: start; align-items: center; }
      #body .user-info-list-wrap > li .info-wrap .time { width: 54px; }
      #body .user-info-list-wrap > li .info-wrap .level { width: 57px; }
      #body .user-info-list-wrap > li .info-wrap .reason { width: 193px; }
      #body .user-info-list-wrap > li .info-wrap .hardness { display: block; width: 50px; text-align: center; }
      #body .user-info-list-wrap > li .info-wrap .hardness span.title { display: none; }
      .bustr-plus-hardness { display: inline-block; width: 46px; text-align: center; font-weight: 700; color: var(--bustr-plus-hardness-color, #ddd); }
      .bustr-plus-hard-row { opacity: .32; }
      .bustr-plus-hidden-hard { display: none !important; }
      @media screen and (max-width: 784px) {
        #body .users-list-title .hardness { display: none; }
        #body .user-info-list-wrap > li .info-wrap .hardness span.title { display: block; }
        #body .user-info-list-wrap > li .info-wrap .reason { width: 164px; border-right: 1px solid rgb(34, 34, 34); }
        #body .user-info-list-wrap > li .info-wrap .hardness { width: 64px; }
      }
      @media screen and (max-width: 386px) {
        #body .user-info-list-wrap > li .info-wrap .time { width: 98px; height: 37px; }
        #body .user-info-list-wrap > li .info-wrap .level { width: 91px; height: 37px; }
        #body .user-info-list-wrap > li .info-wrap .reason { width: 171px; height: 24px; border-right: 1px solid rgb(34, 34, 34); }
        #body .user-info-list-wrap > li .info-wrap .hardness { width: 107px; }
      }
    `;
    document.head.appendChild(style);
  }

  function getViewportType() {
    return (window.visualViewport ? visualViewport.width : window.innerWidth) > 1000 ? 'desktop' : 'mobile';
  }

  function isJailPage() {
    return window.location.pathname === '/jailview.php';
  }

  function buildToggleElement() {
    const wrap = document.createElement('div');
    wrap.id = 'bustr-plus-navitem';
    wrap.innerHTML = `
      <button type="button" id="bustr-plus-toggle" class="bustr-plus-navitem-link" title="Open BUSTR+ settings">
        <span class="bustr-plus-navitem-iconwrap">
          <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20">
            <path d="M8,10V7a4,4,0,0,1,8,0v3h.5A1.5,1.5,0,0,1,18,11.5v7A1.5,1.5,0,0,1,16.5,20h-9A1.5,1.5,0,0,1,6,18.5v-7A1.5,1.5,0,0,1,7.5,10Zm2,0h4V7a2,2,0,0,0-4,0Zm2,4.35a1.35,1.35,0,0,1,.75,2.47V18h-1.5V16.82A1.35,1.35,0,0,1,12,14.35Z"></path>
          </svg>
        </span>
        <span class="bustr-plus-navitem-label">BUSTR+</span>
      </button>
    `;
    wrap.querySelector('#bustr-plus-toggle').addEventListener('click', () => renderPanel(false));
    return wrap;
  }

  function ensureToggleButton() {
    if (!isJailPage()) return;
    if (isHubPresent()) {
      removeToggleButton();
      return;
    }
    let wrap = document.getElementById('bustr-plus-navitem');
    if (!wrap) wrap = buildToggleElement();
    mountToggleButton(wrap);
  }

  function mountToggleButton(wrap) {
    const sidebar = document.getElementById('sidebar');
    const sidebarVisible = !!sidebar && sidebar.offsetParent !== null && sidebar.getClientRects().length > 0;
    if (sidebarVisible) {
      wrap.classList.remove('bustr-plus-navitem-fallback');
      if (wrap.parentElement !== sidebar) sidebar.appendChild(wrap);
    } else {
      wrap.classList.add('bustr-plus-navitem-fallback');
      if (wrap.parentElement !== document.body) document.body.appendChild(wrap);
    }
  }

  function removeToggleButton() {
    document.getElementById('bustr-plus-navitem')?.remove();
  }

  function removeSettingsPanel() {
    document.getElementById('bustr-plus-panel')?.remove();
    hideHelpTip();
  }

  function updateToggleVisibility() {
    if (!isJailPage()) {
      removeToggleButton();
      removeSettingsPanel();
      return;
    }
    if (isHubPresent()) {
      removeToggleButton();
      removeSettingsPanel();
      return;
    }
    ensureToggleButton();
  }

  function startPathWatcher() {
    if (state.pathWatcherStarted) return;
    state.pathWatcherStarted = true;
    let lastPath = window.location.pathname;
    setInterval(() => {
      if (window.location.pathname === lastPath) return;
      lastPath = window.location.pathname;
      updateToggleVisibility();
      renderApiKeyGenerator();
    }, 500);
    window.addEventListener('popstate', updateToggleVisibility, { passive: true });
  }

  function renderPanel(forceOpen) {
    if (isHubPresent()) {
      removeSettingsPanel();
      return;
    }

    let panel = document.getElementById('bustr-plus-panel');
    if (!panel) {
      panel = document.createElement('section');
      panel.id = 'bustr-plus-panel';
      panel.hidden = true;
      document.body.appendChild(panel);
    }
    if (!forceOpen && !panel.hidden) {
      panel.hidden = true;
      return;
    }

    const s = state.settings;
    panel.classList.toggle('bustr-plus-light', s.theme === 'light');
    panel.innerHTML = `
      <header>
        <span>${SCRIPT}</span>
        <span class="header-actions">
          <button type="button" class="icon-btn" data-action="theme" title="${s.theme === 'light' ? 'Use dark panel' : 'Use light panel'}">${s.theme === 'light' ? '' : '☾'}</button>
          <button type="button" data-action="close">Close</button>
        </span>
      </header>
      <div class="body">
        <div class="status">Estimated busts: ${formatAvailableWithHof()} | score ${state.penaltyScore}/${state.penaltyThreshold || '?'}</div>
        <div class="hint">Custom key: ${API_KEY_PERMISSION} only. Uses user/log ${BUST_LOG_ID}.</div>
        <label>
          <span>Custom API key</span>
          <input id="bustr-plus-key" type="password" autocomplete="off" value="${escapeAttr(state.apiKey)}" placeholder="Paste 16 character Torn custom key">
        </label>
        <div class="row">
          <button type="button" data-action="open-api">Generate User / Log key</button>
          <button type="button" class="primary" data-action="save-key">Save and test</button>
        </div>
        <div class="grid">
          ${numberField('hardnessLimit', 'Hardness ceiling', s.hardnessLimit)}
          ${numberField('customPenaltyThreshold', 'Manual penalty cap', s.customPenaltyThreshold)}
          ${numberField('refreshSeconds', 'Refresh seconds', s.refreshSeconds)}
          ${numberField('decayDivisorHours', 'Decay rate hrs', s.decayDivisorHours)}
          ${numberField('decayWindowHours', 'Decay window hrs', s.decayWindowHours)}
          ${numberField('greenLimit', 'Green at busts', s.greenLimit)}
        </div>
        <div class="toggles">
          <label><span>Hide hard</span><input id="bustr-plus-hideHardTargets" type="checkbox" ${s.hideHardTargets ? 'checked' : ''}></label>
          <label><span>Sort easiest</span><input id="bustr-plus-sortByHardness" type="checkbox" ${s.sortByHardness ? 'checked' : ''}></label>
          <label><span>Skip confirm</span><input id="bustr-plus-skipBustConfirm" type="checkbox" ${s.skipBustConfirm ? 'checked' : ''}></label>
        </div>
        <div class="row actions">
          <button type="button" class="danger" data-action="forget-key">Forget key</button>
          <button type="button" class="primary" data-action="save-settings">Save settings</button>
        </div>
        <div class="hint">Hardness: level x (hours + 3). Bust budget is an estimate from recent user/log entries, not an official Torn value.</div>
        <div class="version">Version ${VERSION}</div>
      </div>
    `;
    panel.hidden = false;
    wirePanel(panel);
  }

  function numberField(name, label, value) {
    const help = {
      hardnessLimit: 'Maximum jail target score you want shown as safe. Lower is stricter. Your current 460-ish setting hides or dims people who are probably too expensive in nerve.',
      customPenaltyThreshold: 'Optional manual cap for your bust budget score. Leave 0 to let Bustr+ estimate from your recent bust history. Set this only if you have your own tested limit.',
      refreshSeconds: 'How often Bustr+ checks your Torn user/log for new busts. 60 seconds is calm and API-friendly; lower feels snappier but creates more requests.',
      decayDivisorHours: 'How quickly old busts cool off in the estimate. Smaller means the script forgives old busts faster. Larger means old busts keep weighing on the counter longer.',
      decayWindowHours: 'How far back Bustr+ looks when estimating bust fatigue. 72 hours is conservative. Shorter windows react faster; longer windows play safer.',
      greenLimit: 'When the jail menu counter turns green. This is just a comfort color threshold, not a Torn rule.',
    }[name];
    const info = help
      ? `<span class="help-dot" tabindex="0" role="note" aria-label="${escapeAttr(help)}" data-tip="${escapeAttr(help)}">ⓘ</span>`
      : '';
    return `<label><span class="label-line"><span>${label}</span>${info}</span><input id="bustr-plus-${name}" type="number" step="1" min="0" value="${escapeAttr(value)}"></label>`;
  }

  function wirePanel(panel) {
    panel.querySelector('[data-action="close"]').addEventListener('click', () => {
      hideHelpTip();
      panel.hidden = true;
    });
    panel.querySelector('[data-action="theme"]').addEventListener('click', () => {
      hideHelpTip();
      state.settings.theme = state.settings.theme === 'light' ? 'dark' : 'light';
      saveSettings();
      renderPanel(true);
      renderApiKeyGenerator(true);
    });
    panel.querySelector('[data-action="open-api"]').addEventListener('click', () => {
      openUrl(API_KEY_URL);
    });
    panel.querySelector('[data-action="save-key"]').addEventListener('click', async () => {
      const key = panel.querySelector('#bustr-plus-key').value.trim();
      if (!/^[A-Za-z0-9]{16}$/.test(key)) {
        showPanelHint(panel, 'That does not look like a 16 character Torn API key.');
        return;
      }
      setApiKey(key);
      showPanelHint(panel, 'Testing key...');
      const ok = await loadBustData(true);
      if (!ok) return;
      renderAll();
      renderPanel(true);
    });
    panel.querySelector('[data-action="forget-key"]').addEventListener('click', () => {
      deleteApiKey();
      state.timestamps = [];
      state.availableBusts = null;
      saveStats();
      renderAll();
      renderPanel(true);
    });
    panel.querySelector('[data-action="save-settings"]').addEventListener('click', () => {
      readSettingsFromPanel(panel);
      saveSettings();
      recalcStats();
      saveStats();
      renderAll();
      startRefreshTimer();
      renderPanel(true);
    });
    wireHelpTips(panel);
  }

  function wireHelpTips(panel) {
    for (const dot of panel.querySelectorAll('.help-dot')) {
      dot.addEventListener('mouseenter', () => showHelpTip(dot));
      dot.addEventListener('focus', () => showHelpTip(dot));
      dot.addEventListener('mouseleave', hideHelpTip);
      dot.addEventListener('blur', hideHelpTip);
    }
  }

  function showHelpTip(anchor) {
    const text = anchor.dataset.tip;
    if (!text) return;
    let tip = document.getElementById('bustr-plus-help-tip');
    if (!tip) {
      tip = document.createElement('div');
      tip.id = 'bustr-plus-help-tip';
      document.body.appendChild(tip);
    }
    tip.textContent = text;
    tip.classList.toggle('bustr-plus-light', state.settings.theme === 'light');
    tip.style.left = '0px';
    tip.style.top = '0px';

    const anchorRect = anchor.getBoundingClientRect();
    const tipRect = tip.getBoundingClientRect();
    const margin = 8;
    let left = anchorRect.left + anchorRect.width / 2 - tipRect.width / 2;
    left = Math.max(margin, Math.min(left, window.innerWidth - tipRect.width - margin));

    let top = anchorRect.top - tipRect.height - margin;
    if (top < margin) top = anchorRect.bottom + margin;
    top = Math.max(margin, Math.min(top, window.innerHeight - tipRect.height - margin));

    tip.style.left = `${Math.round(left)}px`;
    tip.style.top = `${Math.round(top)}px`;
  }

  function hideHelpTip() {
    document.getElementById('bustr-plus-help-tip')?.remove();
  }

  function showPanelHint(panel, message) {
    const hint = panel.querySelector('.hint');
    if (hint) hint.textContent = message;
  }

  function readSettingsFromPanel(panel) {
    const numberRules = {
      hardnessLimit: { min: 0 },
      customPenaltyThreshold: { min: 0 },
      refreshSeconds: { min: 15 },
      decayDivisorHours: { min: 1 },
      decayWindowHours: { min: 1 },
      greenLimit: { min: 0 },
    };
    for (const [key, rule] of Object.entries(numberRules)) {
      const el = panel.querySelector(`#bustr-plus-${key}`);
      const value = Math.floor(Number(el.value));
      if (Number.isFinite(value)) state.settings[key] = Math.max(rule.min, value);
    }
    state.settings.hideHardTargets = panel.querySelector('#bustr-plus-hideHardTargets').checked;
    state.settings.sortByHardness = panel.querySelector('#bustr-plus-sortByHardness').checked;
    state.settings.skipBustConfirm = panel.querySelector('#bustr-plus-skipBustConfirm').checked;
  }

  function openUrl(url) {
    try {
      if (typeof GM_openInTab === 'function') {
        GM_openInTab(url, { active: true, insert: true });
        return;
      }
    } catch (err) {}
    window.open(url, '_blank', 'noopener');
  }

  function renderApiKeyGenerator(forceRefresh) {
    if (window.location.pathname !== '/preferences.php') return;
    if (!window.location.hash.includes('tab=api') || !window.location.hash.includes('title=BUSTRPlusLogs')) return;
    const existing = document.getElementById('bustr-plus-keygen');
    if (existing && !forceRefresh) return;
    if (existing) existing.remove();

    const panel = document.createElement('section');
    panel.id = 'bustr-plus-keygen';
    const light = state.settings.theme === 'light';
    panel.style.cssText = [
      'position:fixed', 'z-index:100000', 'right:14px', 'top:96px',
      'width:min(430px,calc(100vw - 28px))',
      `background:${light ? '#f7f7f7' : '#202020'}`,
      `color:${light ? '#242424' : '#ddd'}`,
      `border:1px solid ${light ? '#c9c9c9' : '#5489c9'}`,
      'border-radius:8px', 'box-shadow:0 10px 30px rgba(0,0,0,.55)',
      'font:12px/1.4 Arial,sans-serif', 'padding:11px',
    ].join(';');
    panel.innerHTML = `
      <div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px;">
        <strong>BUSTR+ Custom Key</strong>
        <button type="button" data-close style="border:1px solid ${light ? '#c9c9c9' : '#555'};background:${light ? '#ececec' : '#303030'};color:${light ? '#242424' : '#eee'};border-radius:5px;min-height:25px;padding:3px 8px;cursor:pointer;">Close</button>
      </div>
      <div style="display:grid;gap:7px;">
        <div>Create a <strong>Custom</strong> Torn API key with this exact setup:</div>
        <label>Title
          <input readonly value="${escapeAttr(API_KEY_TITLE)}" style="box-sizing:border-box;width:100%;margin-top:2px;padding:4px 7px;border:1px solid ${light ? '#c9c9c9' : '#555'};border-radius:5px;background:${light ? '#fff' : '#111'};color:${light ? '#242424' : '#eee'};">
        </label>
        <label>Permission
          <input readonly value="${escapeAttr(API_KEY_PERMISSION)} only" style="box-sizing:border-box;width:100%;margin-top:2px;padding:4px 7px;border:1px solid ${light ? '#c9c9c9' : '#555'};border-radius:5px;background:${light ? '#fff' : '#111'};color:${light ? '#242424' : '#eee'};">
        </label>
        <div style="color:${light ? '#666' : '#aaa'};font-size:11px;">BUSTR+ tries to prefill matching fields. If Torn changes the form, use these values manually: Custom key, User / Log only.</div>
        <div style="display:flex;gap:7px;justify-content:flex-end;">
          <button type="button" data-copy style="border:1px solid ${light ? '#c9c9c9' : '#555'};background:${light ? '#ececec' : '#303030'};color:${light ? '#242424' : '#eee'};border-radius:5px;min-height:25px;padding:3px 8px;cursor:pointer;">Copy title</button>
          <button type="button" data-autofill style="border:1px solid #5489c9;background:#3769a8;color:#fff;border-radius:5px;min-height:25px;padding:3px 8px;cursor:pointer;">Prefill form</button>
        </div>
      </div>
    `;
    document.body.appendChild(panel);
    panel.querySelector('[data-close]').addEventListener('click', () => panel.remove());
    panel.querySelector('[data-copy]').addEventListener('click', async () => {
      try { await navigator.clipboard.writeText(API_KEY_TITLE); } catch (err) {}
    });
    panel.querySelector('[data-autofill]').addEventListener('click', prefillTornApiKeyForm);
    setTimeout(prefillTornApiKeyForm, 800);
    setTimeout(prefillTornApiKeyForm, 2000);
  }

  function prefillTornApiKeyForm() {
    const inputs = [...document.querySelectorAll('input, textarea')];
    const titleInput = inputs.find((input) => {
      const label = getNearbyText(input).toLowerCase();
      return /title|name|label/.test(label) && !/key/.test(label);
    }) || inputs.find((input) => input.type === 'text' && !input.value);
    
    if (titleInput && !titleInput.value) {
      titleInput.value = API_KEY_TITLE;
      titleInput.dispatchEvent(new Event('input', { bubbles: true }));
      titleInput.dispatchEvent(new Event('change', { bubbles: true }));
    }

    for (const control of document.querySelectorAll('input[type="radio"], input[type="checkbox"], option')) {
      const text = getNearbyText(control).toLowerCase();
      const wantsCustom = /\bcustom\b/.test(text);
      const wantsLog = /\blog\b/.test(text) && /\buser\b/.test(text);
      const rejectsBroad = /\bfull\b|\ball\b|\bfaction\b|\bcompany\b|\bforum\b|\bmessage\b|\bbazaar\b|\bnetworth\b/.test(text);
      
      if ((wantsCustom || wantsLog) && !rejectsBroad) {
        if (control.tagName === 'OPTION') {
          control.selected = true;
          control.parentElement?.dispatchEvent(new Event('change', { bubbles: true }));
        } else if (!control.checked) {
          control.click();
        }
      }
      if (rejectsBroad && control.checked && control.type === 'checkbox') {
        control.click();
      }
    }
  }

  function getNearbyText(el) {
    const labelledBy = el.getAttribute('aria-labelledby');
    const ariaText = labelledBy
      ? labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.textContent || '').join(' ')
      : '';
    const escapedId = el.id && window.CSS && typeof CSS.escape === 'function' ? CSS.escape(el.id) : '';
    const labelText = escapedId
      ? [...document.querySelectorAll(`label[for="${escapedId}"]`)].map((label) => label.textContent || '').join(' ')
      : '';
    const parentText = el.closest('label, li, tr, div')?.textContent || '';
    return `${ariaText} ${labelText} ${parentText}`;
  }

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

  function formatAvailable() {
    return state.availableBusts == null ? '?' : Math.max(0, state.availableBusts);
  }

  function formatAvailableWithHof() {
    const avail = formatAvailable();
    const hof = state.hofRank != null ? state.hofRank.toLocaleString() : '?';
    return `${avail} / ${hof}`;
  }

  async function loadBustData(force) {
    if (!state.apiKey) return;
    const ageMs = Date.now() - state.lastFetchMs;
    if (!force && ageMs < state.settings.refreshSeconds * 1000) return;
    if (state.loadBustDataPromise) return state.loadBustDataPromise;

    const url = `https://api.torn.com/user/?selections=log&log=${BUST_LOG_ID}&key=${encodeURIComponent(state.apiKey)}`;
    state.loadBustDataPromise = (async () => {
      const data = await requestJson(url);
      if (data.error) throw new Error(data.error.error || 'Torn API error');
      state.timestamps = extractBustTimestamps(data);
      state.lastFetchMs = Date.now();
      recalcStats();
      saveStats();
      return true;
    })();

    try {
      return await state.loadBustDataPromise;
    } catch (err) {
      console.error(`${SCRIPT}:`, err);
      if (force) renderPanel(true);
      const panel = document.getElementById('bustr-plus-panel');
      if (panel) showPanelHint(panel, `API error: ${err.message}`);
      return false;
    } finally {
      state.loadBustDataPromise = null;
    }
  }

  async function requestJson(url) {
    const text = await universalGet(url);
    try {
      return JSON.parse(text);
    } catch (err) {
      throw new Error('Bad Torn API response');
    }
  }

  async function universalGet(url, timeoutMs = 8000) {
    const tiers = [];
    if (typeof GM_xmlhttpRequest === 'function') tiers.push(() => gmGet(url, timeoutMs));
    if (typeof PDA_httpGet === 'function') tiers.push(() => pdaGet(url, timeoutMs));
    tiers.push(() => fetchGet(url, timeoutMs));

    let lastErr;
    for (const tier of tiers) {
      try {
        return await tier();
      } catch (err) {
        lastErr = err;
      }
    }
    throw lastErr || new Error('All Torn API request methods failed');
  }

  function gmGet(url, timeoutMs) {
    return new Promise((resolve, reject) => {
      let settled = false;
      const timer = setTimeout(() => {
        if (settled) return;
        settled = true;
        reject(new Error('GM_xmlhttpRequest timed out'));
      }, timeoutMs);
      try {
        GM_xmlhttpRequest({
          method: 'GET', url, anonymous: true,
          onload: (response) => {
            if (settled) return;
            settled = true;
            clearTimeout(timer);
            resolve(response.responseText);
          },
          onerror: () => {
            if (settled) return;
            settled = true;
            clearTimeout(timer);
            reject(new Error('GM_xmlhttpRequest failed'));
          },
          ontimeout: () => {
            if (settled) return;
            settled = true;
            clearTimeout(timer);
            reject(new Error('GM_xmlhttpRequest timed out'));
          },
          timeout: timeoutMs,
        });
      } catch (err) {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        reject(err);
      }
    });
  }

  function pdaGet(url, timeoutMs) {
    return new Promise((resolve, reject) => {
      let settled = false;
      const timer = setTimeout(() => {
        if (settled) return;
        settled = true;
        reject(new Error('PDA_httpGet timed out'));
      }, timeoutMs);
      try {
        PDA_httpGet(url, (response) => {
          if (settled) return;
          settled = true;
          clearTimeout(timer);
          if (typeof response === 'string' && response) resolve(response);
          else reject(new Error('PDA_httpGet returned no data'));
        });
      } catch (err) {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        reject(err);
      }
    });
  }

  function fetchGet(url, timeoutMs) {
    const controller = typeof AbortController === 'function' ? new AbortController() : null;
    const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
    return fetch(url, { credentials: 'omit', signal: controller ? controller.signal : undefined })
      .then((response) => response.text())
      .finally(() => {
        if (timer) clearTimeout(timer);
      });
  }

  function extractBustTimestamps(data) {
    if (!data || !data.log) return [];
    return Object.values(data.log)
      .map((entry) => Number(entry.timestamp))
      .filter((timestamp) => Number.isFinite(timestamp))
      .sort((a, b) => b - a);
  }

  function recalcStats() {
    state.penaltyScore = calcPenaltyScore(state.timestamps);
    state.penaltyThreshold = calcPenaltyThreshold(state.timestamps);
    state.availableBusts = state.penaltyThreshold > 0
      ? Math.floor((state.penaltyThreshold - state.penaltyScore) / state.settings.freshBustScore)
      : null;
  }

  function calcPenaltyScore(timestamps) {
    const nowSeconds = Date.now() / 1000;
    const maxAge = state.settings.decayWindowHours;
    return Math.floor(timestamps.reduce((score, timestamp) => {
      const ageHours = (nowSeconds - timestamp) / 3600;
      if (ageHours < 0 || ageHours > maxAge) return score;
      return score + decayedBustScore(ageHours);
    }, 0));
  }

  function calcPenaltyThreshold(timestamps) {
    if (state.settings.customPenaltyThreshold > 0) return state.settings.customPenaltyThreshold;
    if (!timestamps.length) return 0;

    const sorted = [...timestamps].sort((a, b) => b - a);
    const windowSeconds = state.settings.decayWindowHours * 3600;
    let maxScore = 0;

    for (let i = 0; i < sorted.length; i++) {
      const anchor = sorted[i];
      let score = 0;
      for (let j = i; j < sorted.length; j++) {
        const ageSeconds = anchor - sorted[j];
        if (ageSeconds > windowSeconds) break;
        score += decayedBustScore(ageSeconds / 3600);
      }
      maxScore = Math.max(maxScore, score);
    }

    return Math.floor(maxScore);
  }

  function decayedBustScore(ageHours) {
    return state.settings.freshBustScore / (1 + ageHours / state.settings.decayDivisorHours);
  }

  function renderAll() {
    updateToggleVisibility();
    renderNavStats();
    renderColorClass();
    applyQuickBustLinks();
    if (state.settings.showHardness) renderHardnessView();
  }

  async function waitFor(selector, timeoutMs) {
    const existing = document.querySelector(selector);
    if (existing) return existing;
    return new Promise((resolve, reject) => {
      const started = Date.now();
      const timer = setInterval(() => {
        const el = document.querySelector(selector);
        if (el) {
          clearInterval(timer);
          resolve(el);
        } else if (Date.now() - started > timeoutMs) {
          clearInterval(timer);
          reject(new Error(`Missing ${selector}`));
        }
      }, 50);
    });
  }

  async function renderNavStats() {
    if (state.navStatsPending) return;
    state.navStatsPending = true;
    try {
      const jailNav = await waitFor('#nav-jail', 5000);
      const anchor = jailNav.querySelector('a') || jailNav;
      if (getViewportType() === 'desktop') {
        let stats = anchor.querySelector('.bustr-plus-nav');
        if (!stats) {
          stats = document.createElement('span');
          stats.className = 'bustr-plus-nav';
          anchor.appendChild(stats);
        }
        // Desktop gets the new Available / HOF Busts format
        stats.textContent = formatAvailableWithHof();
        const hofText = state.hofRank != null ? `#${state.hofRank.toLocaleString()}` : '?';
        stats.title = `BUSTR+ ${state.penaltyScore}/${state.penaltyThreshold || '?'} | HOF Busts: ${hofText} | Avail: ${formatAvailable()}`;
      } else {
        // Mobile keeps the simple available count
        let pill = jailNav.querySelector('.bustr-plus-pill');
        if (!pill) {
          pill = document.createElement('span');
          pill.className = 'bustr-plus-pill';
          jailNav.appendChild(pill);
        }
        pill.textContent = formatAvailable();
      }
    } catch (err) {
      // Silent fail for nav stats
    } finally {
      state.navStatsPending = false;
    }
  }

  function renderColorClass() {
    document.body.classList.remove('bustr-plus-green', 'bustr-plus-orange', 'bustr-plus-red');
    if (state.availableBusts == null) return;
    if (state.availableBusts <= state.settings.redLimit) {
      document.body.classList.add('bustr-plus-red');
    } else if (state.availableBusts >= state.settings.greenLimit) {
      document.body.classList.add('bustr-plus-green');
    } else {
      document.body.classList.add('bustr-plus-orange');
    }
  }

  function renderHardnessView() {
    if (window.location.pathname !== '/jailview.php') return;
    if (state.hardnessRendering) return;
    const list = document.querySelector('ul.user-info-list-wrap');
    if (!list) return;
    state.hardnessRendering = true;
    try {
      renderHardnessHeader();
      const rows = [...list.querySelectorAll(':scope > li')];
      for (const row of rows) {
        if (!isLikelyJailPlayerRow(row)) continue;
        const info = row.querySelector('.info-wrap') || row;

        const parsed = parseRowLevelAndDuration(row);
        if (!parsed) {
          renderHardnessScore(info, null);
          resetHardnessTreatment(row);
          continue;
        }
        const score = calcHardnessScore(parsed.level, parsed.hours);
        renderHardnessScore(info, score);
        applyHardnessTreatment(row, score);
      }
    } finally {
      setTimeout(() => {
        state.hardnessRendering = false;
      }, 0);
    }
  }

  function parseRowLevelAndDuration(row) {
    const info = row.querySelector('.info-wrap') || row;
    const cells = [...info.children].filter((child) => !child.classList.contains('bustr-plus-hardness-wrap'));
    const levelText = row.querySelector('.level')?.textContent || cells.find((cell) => /\blevel\b/i.test(cell.className || ''))?.textContent || cells[1]?.textContent || '';
    const timeText = row.querySelector('.time')?.textContent || cells.find((cell) => /\btime\b/i.test(cell.className || ''))?.textContent || cells[0]?.textContent || '';
    let level = Number((levelText.match(/\d+/) || [])[0]);
    let rawTimeText = timeText;

    if (!Number.isFinite(level) || !rawTimeText.trim()) {
      const rowText = String(row.textContent || '').replace(/\s+/g, ' ').trim();
      const fallback = rowText.match(/((?:\d+\s*(?:d|day|h|hour|m|min)\s*){1,3})\s+(\d{1,4})\b/i);
      if (fallback) {
        rawTimeText = rawTimeText.trim() ? rawTimeText : fallback[1];
        if (!Number.isFinite(level)) level = Number(fallback[2]);
      }
    }

    if (!Number.isFinite(level)) return null;

    const days = Number((rawTimeText.match(/(\d+)\s*(?:d|day)/i) || [0, 0])[1]);
    const hours = Number((rawTimeText.match(/(\d+)\s*(?:h|hour)/i) || [0, 0])[1]);
    const mins = Number((rawTimeText.match(/(\d+)\s*(?:m|min)/i) || [0, 0])[1]);
    return { level, hours: days * 24 + hours + mins / 60 };
  }

  function isLikelyJailPlayerRow(row) {
    if (!row || row.nodeType !== Node.ELEMENT_NODE) return false;
    if (!row.querySelector("a[href*='XID='], a[href*='/profiles.php?XID='], a.user.name")) return false;
    if (row.querySelector('.time') && row.querySelector('.level')) return true;
    const text = String(row.textContent || '').replace(/\s+/g, ' ').trim();
    return /\d+\s*(?:d|day|h|hour|m|min)\b/i.test(text) && /\b\d{1,4}\b/.test(text);
  }

  function calcHardnessScore(level, hours) {
    return Math.floor(level * (hours + 3));
  }

  function renderHardnessHeader() {
    const title = document.querySelector('.users-list-title');
    if (!title || title.querySelector('.bustr-plus-hardness-col')) return;
    const header = document.createElement('span');
    header.className = 'hardness bustr-plus-hardness-col title-divider divider-spiky';
    header.textContent = 'Score';
    const anchor = findJailColumnAnchor(title, 3);
    if (anchor) anchor.insertAdjacentElement('afterend', header);
    else title.appendChild(header);
  }

  function renderHardnessScore(info, score) {
    let wrap = info.querySelector('.bustr-plus-hardness-wrap');
    for (const extra of [...info.querySelectorAll('.bustr-plus-hardness-wrap')].slice(1)) extra.remove();
    if (!wrap) {
      wrap = document.createElement('span');
      wrap.className = 'hardness reason bustr-plus-hardness-wrap';
      wrap.innerHTML = '<span class="title bold">SCORE</span><span class="bustr-plus-hardness"></span>';
      const reason = findJailColumnAnchor(info, 2);
      if (reason) reason.insertAdjacentElement('afterend', wrap);
      else info.appendChild(wrap);
    }
    let value = wrap.querySelector('.bustr-plus-hardness');
    if (!value) {
      wrap.innerHTML = '<span class="title bold">SCORE</span><span class="bustr-plus-hardness"></span>';
      value = wrap.querySelector('.bustr-plus-hardness');
    }
    const nextText = Number.isFinite(score) ? String(score) : '-';
    if (value.textContent !== nextText) value.textContent = nextText;
    const nextColor = !Number.isFinite(score) ? '#999' : score > state.settings.hardnessLimit ? '#e64d1a' : '#85b200';
    if (value.style.getPropertyValue('--bustr-plus-hardness-color') !== nextColor) {
      value.style.setProperty('--bustr-plus-hardness-color', nextColor);
    }
  }

  function findJailColumnAnchor(container, fallbackIndex) {
    const directChildren = [...container.children];
    return directChildren.find((child) => child.classList.contains('reason') && !child.classList.contains('bustr-plus-hardness-wrap'))
      || directChildren[fallbackIndex]
      || directChildren[directChildren.length - 1]
      || null;
  }

  function applyHardnessTreatment(row, score) {
    row.classList.toggle('bustr-plus-hard-row', score > state.settings.hardnessLimit && !state.settings.hideHardTargets);
    row.classList.toggle('bustr-plus-hidden-hard', score > state.settings.hardnessLimit && state.settings.hideHardTargets);
    const nextOrder = state.settings.sortByHardness ? String(score) : '';
    if (row.style.order !== nextOrder) row.style.order = nextOrder;
  }

  function resetHardnessTreatment(row) {
    row.classList.remove('bustr-plus-hard-row', 'bustr-plus-hidden-hard');
    if (row.style.order) row.style.order = '';
  }

  function applyQuickBustLinks() {
    if (window.location.pathname !== '/jailview.php') return;
    const links = [...document.querySelectorAll("ul.user-info-list-wrap > li a[href*='breakout']")];
    for (const link of links) {
      if (state.settings.skipBustConfirm) markQuickBustLink(link);
      else unmarkQuickBustLink(link);
    }
  }

  function markQuickBustLink(link) {
    const href = link.getAttribute('href') || '';
    if (!href) return;
    if (!link.dataset.bustrPlusOriginalHref) link.dataset.bustrPlusOriginalHref = href;
    if (href === `${link.dataset.bustrPlusOriginalHref}1`) return;
    link.setAttribute('href', `${href}1`);
  }

  function unmarkQuickBustLink(link) {
    if (!link.dataset.bustrPlusOriginalHref) return;
    link.setAttribute('href', link.dataset.bustrPlusOriginalHref);
    delete link.dataset.bustrPlusOriginalHref;
  }

  function startBustConfirmSkipper() {
    if (state.bustConfirmSkipperStarted) return;
    state.bustConfirmSkipperStarted = true;
    document.addEventListener('click', (event) => {
      if (!state.settings.skipBustConfirm || window.location.pathname !== '/jailview.php') return;
      const row = event.target.closest('ul.user-info-list-wrap > li');
      if (!row || !isLikelyJailPlayerRow(row)) return;
      const bustLink = event.target.closest("a[href*='breakout']");
      if (!bustLink) return;
      const bustHref = bustLink.href || bustLink.getAttribute('href') || '';
      if (bustHref === state.lastBustActionHref && Date.now() - state.lastBustActionClickMs < 1200) {
        event.preventDefault();
        event.stopImmediatePropagation();
        return;
      }
      state.lastBustActionClickMs = Date.now();
      state.lastBustActionHref = bustHref;
      sessionStorage.setItem('bustrReturnToJailUntil', String(Date.now() + 8000));
      sessionStorage.setItem('bustrReturnToJailArmedAt', String(Date.now()));
      sessionStorage.setItem('bustrLastQuickBustHref', bustHref);
      armReturnToJailWatcher();
    }, true);
  }

  function returnToJailAfterQuickBustRedirect() {
    const until = Number(sessionStorage.getItem('bustrReturnToJailUntil') || 0);
    if (!until || Date.now() > until) {
      clearReturnToJailWatcher();
      return false;
    }
    if (window.location.pathname === '/jailview.php') {
      const armedAt = Number(sessionStorage.getItem('bustrReturnToJailArmedAt') || 0);
      if (armedAt && Date.now() - armedAt > 2500) clearReturnToJailWatcher();
      return false;
    }
    clearReturnToJailWatcher();
    window.location.replace('/jailview.php');
    return true;
  }

  function armReturnToJailWatcher() {
    if (state.returnToJailTimerId) clearInterval(state.returnToJailTimerId);
    state.returnToJailTimerId = setInterval(() => {
      const until = Number(sessionStorage.getItem('bustrReturnToJailUntil') || 0);
      if (!until || Date.now() > until) {
        clearReturnToJailWatcher();
        return;
      }
      returnToJailAfterQuickBustRedirect();
    }, 100);
  }

  function clearReturnToJailWatcher() {
    if (state.returnToJailTimerId) clearInterval(state.returnToJailTimerId);
    state.returnToJailTimerId = null;
    sessionStorage.removeItem('bustrReturnToJailUntil');
    sessionStorage.removeItem('bustrReturnToJailArmedAt');
  }

  function scheduleHardnessRender() {
    if (state.hardnessRendering || window.location.pathname !== '/jailview.php') return;
    clearTimeout(state.hardnessRenderTimer);
    state.hardnessRenderTimer = setTimeout(() => {
      applyQuickBustLinks();
      if (state.settings.showHardness) renderHardnessView();
    }, 150);
  }

  function recordLocalBustSuccess(sourceText) {
    if (window.location.pathname !== '/jailview.php') return false;
    const match = String(sourceText || '').match(/You busted\s+(.+?)\s+out of jail/i);
    if (!match) return false;
    const signature = `${match[1].trim().toLowerCase()}|${state.lastBustActionHref || ''}`;
    if (signature === state.lastLocalBustSignature) return false;
    if (Date.now() - state.lastLocalBustMs < 750 && state.lastLocalBustSignature) return false;
    state.lastLocalBustMs = Date.now();
    state.lastLocalBustSignature = signature;
    state.penaltyScore += state.settings.freshBustScore;
    state.availableBusts = state.penaltyThreshold > 0
      ? Math.floor((state.penaltyThreshold - state.penaltyScore) / state.settings.freshBustScore)
      : null;
    saveStats();
    renderAll();
    return true;
  }

  function scanExistingBustSuccesses() {
    if (window.location.pathname !== '/jailview.php') return;
    const text = document.querySelector('#mainContainer')?.textContent || '';
    recordLocalBustSuccess(text);
  }

  function startObservers() {
    if (!state.observerStarted) {
      state.observerStarted = true;
      const target = document.querySelector('#mainContainer') || document.body;
      new MutationObserver((mutations) => {
        if (window.location.pathname !== '/jailview.php') return;
        for (const mutation of mutations) {
          if (recordLocalBustSuccess(mutation.target?.textContent || '')) return;
        }
      }).observe(target, { childList: true, subtree: true });
    }

    if (!state.hardnessObserverStarted) {
      state.hardnessObserverStarted = true;
      const target = document.querySelector('#mainContainer') || document.body;
      new MutationObserver((mutations) => {
        if (mutations.every((mutation) => {
          const targetEl = mutation.target?.nodeType === Node.ELEMENT_NODE ? mutation.target : mutation.target?.parentElement;
          return targetEl?.closest?.('.bustr-plus-hardness-wrap, #bustr-plus-panel, #bustr-plus-navitem');
        })) return;
        scheduleHardnessRender();
      }).observe(target, { childList: true, subtree: true });
    }
  }

  function startRefreshTimer() {
    if (state.refreshTimerId) clearInterval(state.refreshTimerId);
    state.refreshTimerId = setInterval(async () => {
      await loadBustData(false);
      renderAll();
    }, Math.max(15, state.settings.refreshSeconds) * 1000);
  }

  // =========================================================================
  // INITIALIZATION (Cleaned & Consolidated)
  // =========================================================================
  async function init() {
    loadState();
    registerWithHub();
    
    armReturnToJailWatcher();
    if (returnToJailAfterQuickBustRedirect()) return;
    
    injectStyles();
    updateToggleVisibility();
    renderApiKeyGenerator();
    
    if (!state.settings.enabled) {
      cleanupModifications();
      return;
    }
    
    // Scrape HOF Busts rank if not cached
    if (getCachedHofRank() !== null) {
      state.hofRank = getCachedHofRank();
    } else {
      scrapeHofRank();
    }

    await loadBustData(false);
    renderAll();
    scanExistingBustSuccesses();
    
    if (!state.apiKey && isJailPage() && window.location.hash.includes('bustrPlus=openSettings') && !isHubPresent()) {
      renderPanel(true);
    }
    
    startBustConfirmSkipper();
    startObservers();
    startRefreshTimer();
    startPathWatcher();
    
    window.addEventListener('resize', renderAll, { passive: true });
    window.addEventListener('hashchange', () => {
      renderApiKeyGenerator();
      updateToggleVisibility();
    }, { passive: true });
    window.addEventListener('pageshow', () => {
      armReturnToJailWatcher();
      returnToJailAfterQuickBustRedirect();
    }, { passive: true });

    // Hub lifecycle listeners
    document.addEventListener('torn-script-hub:ready', () => {
      state.hubPresent = isHubPresent();
      registerWithHub();
      updateToggleVisibility();
    });
    document.addEventListener('torn-script-hub:dormant', () => {
      state.hubPresent = false;
      updateToggleVisibility();
    });
    document.addEventListener('torn-script-hub:active', () => {
      state.hubPresent = isHubPresent();
      updateToggleVisibility();
      if (state.hubPresent) removeSettingsPanel();
    });
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();