Displays recommended stat training order based on your custom Str/Def/Spd/Dex ratios.
// ==UserScript==
// @name Torn Gym Ratio
// @namespace torn.gym.ratio
// @description Displays recommended stat training order based on your custom Str/Def/Spd/Dex ratios.
// @version 1.0
// @match https://www.torn.com/gym.php*
// @grant GM_setValue
// @grant GM_getValue
// ==/UserScript==
(function() {
'use strict';
const STATS = ['str', 'def', 'spd', 'dex'];
const NAMES = { str: 'Strength', def: 'Defense', spd: 'Speed', dex: 'Dexterity' };
const CLASSES = {
str: 'strength___',
def: 'defense___',
spd: 'speed___',
dex: 'dexterity___'
};
// Helper: Parse stat value from DOM
function getStat(key) {
const el = document.querySelector(`[class*="${CLASSES[key]}"] [class*="propertyValue___"]`);
return el ? parseInt(el.textContent.replace(/,/g, '')) : 0;
}
// Helper: Check if stats are loaded
function statsReady() {
return STATS.every(s => getStat(s) > 0);
}
// Helper: Load settings (defaulting lock to 'auto')
function load() {
try {
const defaults = { lock: 'auto', str: 1, def: 1, spd: 1, dex: 1 };
const saved = JSON.parse(GM_getValue('gymRatio', '{}'));
return { ...defaults, ...saved };
} catch {
return { lock: 'auto', str: 1, def: 1, spd: 1, dex: 1 };
}
}
// Helper: Save settings
function save(data) {
GM_setValue('gymRatio', JSON.stringify(data));
}
// Main Calculation Logic
function calculate() {
if (!statsReady()) return;
const data = load();
let lock = data.lock;
const rec = document.querySelector('#gr-rec');
const current = {};
// 1. Get current stats
STATS.forEach(s => current[s] = getStat(s));
// 2. Handle Auto-Lock Logic
// If set to auto, find the stat that is "Furthest Ahead" relative to ratio
if (lock === 'auto') {
let maxScore = -1;
let bestStat = null;
STATS.forEach(s => {
const ratio = data[s] || 0;
if (ratio > 0) {
const score = current[s] / ratio;
if (score > maxScore) {
maxScore = score;
bestStat = s;
}
}
});
lock = bestStat;
}
// If we still don't have a lock (e.g., all ratios are 0), stop.
if (!lock) {
if (rec) rec.textContent = 'Set ratios > 0';
return;
}
// 3. Calculate Goals based on the Lock
const base = current[lock] / (data[lock] || 1); // Avoid division by zero
const needs = {};
const ratios = {};
STATS.forEach(s => {
if (s === lock) {
// The lock is our baseline (100% complete relative to itself)
ratios[s] = 1;
needs[s] = 0;
return;
}
const target = base * (data[s] || 0);
needs[s] = Math.ceil(target - current[s]);
// Calculate completion ratio for sorting
// If target is 0, we treat it as infinite completion (don't need to train it)
ratios[s] = target > 0 ? current[s] / target : Infinity;
});
// 4. Sort stats by ratio (lowest = most behind = needs training)
const sorted = STATS
.filter(s => s !== lock && (data[s] || 0) > 0)
.sort((a, b) => ratios[a] - ratios[b]);
// 5. Update Grid UI (Needs / Checks)
STATS.forEach(s => {
const row = document.querySelector(`#gr-row-${s}`);
const needEl = document.querySelector(`#gr-need-${s}`);
if (!row || !needEl) return;
if (s === lock) {
needEl.innerHTML = `<span style="color:#999; font-weight:bold;">LOCKED</span>`;
row.style.opacity = '0.5';
row.style.borderColor = '#555';
} else {
row.style.opacity = '1';
row.style.borderColor = '#333';
const need = needs[s];
if (need > 0) {
needEl.innerHTML = `<span style="color:#e07850">+${need.toLocaleString()}</span>`;
} else {
const surplus = Math.abs(need).toLocaleString();
needEl.innerHTML = `<span style="color:#6c9">✓ +${surplus}</span>`;
}
}
});
// 6. Generate Recommendation Text
if (rec) {
// Check if we actually have deficits
const worst = sorted[0];
if (worst && needs[worst] > 0) {
// We have a stat to train
if (sorted.length >= 2) {
// Logic: Train worst until it matches second worst
const secondWorst = sorted[1];
const targetWorst = base * (data[worst] || 0);
// The formula: How much to gain until worst's ratio == secondWorst's ratio
const gainUntilSwitch = Math.ceil((targetWorst * ratios[secondWorst]) - current[worst]);
if (gainUntilSwitch > 0) {
rec.innerHTML = `Train <b>${NAMES[worst]}</b> <span style="color:#aaa">(+${gainUntilSwitch.toLocaleString()} → ${NAMES[secondWorst]})</span>`;
} else {
// Edge case: math says switch is close, just train worst
rec.innerHTML = `Train <b>${NAMES[worst]}</b>`;
}
} else {
// Only 1 stat behind (or only 2 stats tracked total)
rec.innerHTML = `Train <b>${NAMES[worst]}</b>`;
}
rec.style.color = '#fc0'; // Orange/Yellow text
} else {
// Everything is balanced (or better than the lock)
// In Auto mode, this technically shouldn't happen often unless perfectly balanced
rec.innerHTML = `Balanced! Train <b>${NAMES[lock]}</b> (Lock)`;
rec.style.color = '#6c9'; // Green text
}
}
}
// Build the User Interface
function createUI() {
if (document.querySelector('#gym-ratio')) return false;
const gym = document.querySelector('[class*="gymContent___"]');
if (!gym) return false;
const data = load();
const container = document.createElement('div');
container.id = 'gym-ratio';
container.innerHTML = `
<style>
#gym-ratio {
background: linear-gradient(to bottom, #242424, #1a1a1a);
border: 1px solid #444;
border-radius: 5px;
margin-bottom: 10px;
font-family: Arial, sans-serif;
font-size: 12px;
color: #ccc;
}
#gr-header {
background: linear-gradient(to bottom, #333, #2a2a2a);
padding: 8px 12px;
border-bottom: 1px solid #444;
font-size: 13px;
font-weight: bold;
color: #fff;
}
#gr-body { padding: 10px 12px; }
#gr-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 10px;
}
.gr-stat {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 4px;
padding: 8px;
text-align: center;
transition: opacity 0.3s;
}
.gr-stat-name {
font-size: 11px;
color: #888;
text-transform: uppercase;
margin-bottom: 4px;
}
.gr-stat-input {
width: 40px;
background: #111;
border: 1px solid #444;
border-radius: 3px;
color: #fff;
text-align: center;
padding: 4px;
font-size: 13px;
margin-bottom: 6px;
}
.gr-stat-input:focus { border-color: #69c; outline: none; }
.gr-stat-need { font-size: 11px; min-height: 16px; white-space: nowrap; }
#gr-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 8px;
border-top: 1px solid #333;
}
#gr-lock-wrap { display: flex; align-items: center; gap: 6px; }
#gr-lock {
background: #111;
border: 1px solid #444;
border-radius: 3px;
color: #fff;
padding: 4px 8px;
font-size: 12px;
cursor: pointer;
}
#gr-rec { font-size: 13px; }
</style>
<div id="gr-header">Gym Ratio Trainer</div>
<div id="gr-body">
<div id="gr-grid">
${STATS.map(s => `
<div class="gr-stat" id="gr-row-${s}">
<div class="gr-stat-name">${NAMES[s]}</div>
<input type="number" class="gr-stat-input" id="gr-${s}" value="${data[s] !== undefined ? data[s] : 1}" min="0">
<div class="gr-stat-need" id="gr-need-${s}">-</div>
</div>
`).join('')}
</div>
<div id="gr-footer">
<div id="gr-lock-wrap">
<span>Lock:</span>
<select id="gr-lock">
<option value="auto" ${data.lock === 'auto' ? 'selected' : ''}>Auto</option>
${STATS.map(s => `<option value="${s}"${data.lock === s ? ' selected' : ''}>${NAMES[s]}</option>`).join('')}
</select>
</div>
<div id="gr-rec">Initializing...</div>
</div>
</div>
`;
gym.parentNode.insertBefore(container, gym);
// Add event listeners to inputs and select
container.querySelectorAll('input, select').forEach(el => {
el.addEventListener('input', () => {
const d = {};
STATS.forEach(s => d[s] = parseFloat(document.querySelector(`#gr-${s}`).value) || 0);
d.lock = document.querySelector('#gr-lock').value;
save(d);
calculate();
});
});
return true;
}
// Init Logic: Wait for elements to exist
function init() {
const gym = document.querySelector('[class*="gymContent___"]');
if (!gym) return;
createUI();
if (statsReady()) calculate();
}
// Start-up Interval
let attempts = 0;
const interval = setInterval(() => {
init();
attempts++;
if (statsReady() && document.querySelector('#gym-ratio')) {
calculate();
clearInterval(interval);
}
if (attempts > 50) clearInterval(interval);
}, 200);
// Watch for stat updates (polling)
let lastStats = '';
setInterval(() => {
if (!statsReady()) return;
const current = STATS.map(s => getStat(s)).join(',');
if (current !== lastStats) {
lastStats = current;
calculate();
}
}, 1000);
})();