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.
// ==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) => ({
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": ''',
})[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();
});
})();