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.

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 or Violentmonkey 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         Bustr+
// @namespace    https://greasyfork.org/users/cowboyup
// @version      1.2.2
// @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.
// @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.2';
  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 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,
    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');
  }

  // =========================================================================
  // 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);
    return `Estimated busts left: ${avail}  |  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: () => {
          // Prefer opening the Hub dashboard directly to Bustr+ settings.
          // Fallback: go to jail page and refresh data.
          document.dispatchEvent(new CustomEvent('torn-script-hub:open', {
            detail: { scriptId: HUB_ID }
          }));
          // Also refresh data if we are (or end up) on the jail page
          if (isJailPage()) {
            loadBustData(false).then(() => renderAll());
          }
        },
        close: () => {
          // Called when user disables the script from Hub dashboard
          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);

            // API key: only APPLY a new valid key. Never treat a blank Hub snapshot
            // as "user cleared the key" — password fields often stay empty in
            // the prefs.values copy (registration race / security blanking),
            // and every other setting change would otherwise wipe the key.
            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 (unchanged core, still used for hardness + nav when no Hub)
  // =========================================================================
  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 });
  }

  // Original floating panel – only used when Hub is NOT present
  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 left: ${formatAvailable()} | 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 ? '#5489c9' : '#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);
  }

  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);
        }
        stats.textContent = formatAvailable();
        stats.title = `BUSTR+ ${state.penaltyScore}/${state.penaltyThreshold || '?'}:${formatAvailable()}`;
      } else {
        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) {
    } 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);
  }

  async function init() {
    loadState();
    // Register after state is loaded so Hub prefs.values include the real API key
    registerWithHub();
    armReturnToJailWatcher();
    if (returnToJailAfterQuickBustRedirect()) return;
    injectStyles();
    updateToggleVisibility();
    renderApiKeyGenerator();
    if (!state.settings.enabled) {
      cleanupModifications();
      return;
    }
    await loadBustData(false);
    renderAll();
    // Re-register so Hub status / values reflect loaded bust data
    registerWithHub();
    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, { passive: true });
    window.addEventListener('pageshow', () => {
      armReturnToJailWatcher();
      returnToJailAfterQuickBustRedirect();
    }, { passive: true });
  }

  const ready = document.readyState === 'complete'
    ? Promise.resolve()
    : new Promise((resolve) => window.addEventListener('load', resolve, { once: true }));

  ready.then(init).catch((err) => console.error(`${SCRIPT}:`, err));

  // Register with hub + listen for hub lifecycle
  function tryRegister() {
    // Ensure storage is applied before the Hub snapshots prefs.values
    loadState();
    registerWithHub();
    // Re-check after a short delay in case Hub mounts later
    setTimeout(() => {
      const nowPresent = isHubPresent();
      if (nowPresent !== state.hubPresent) {
        state.hubPresent = nowPresent;
        updateToggleVisibility();
        if (nowPresent) removeSettingsPanel();
      }
      // Refresh registration once more with latest state
      loadState();
      registerWithHub();
    }, 800);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', tryRegister);
  } else {
    tryRegister();
  }
  document.addEventListener('torn-script-hub:ready', tryRegister);
  document.addEventListener('torn-script-hub:dormant', () => {
    // Hub UI disabled – restore our own UI if enabled
    state.hubPresent = false;
    updateToggleVisibility();
  });
  document.addEventListener('torn-script-hub:active', () => {
    state.hubPresent = isHubPresent();
    updateToggleVisibility();
    if (state.hubPresent) removeSettingsPanel();
  });
})();