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.

이 스크립트를 설치하려면 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.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();
  });
})();