Zero-config advisor: reads your stats via the Torn API and tells you exactly what to do right now to level faster, earn more, and build battle stats. Tracks your progress over time.
// ==UserScript==
// @name Torn Growth Advisor
// @namespace http://tampermonkey.net/
// @version 1.5.1
// @description Zero-config advisor: reads your stats via the Torn API and tells you exactly what to do right now to level faster, earn more, and build battle stats. Tracks your progress over time.
// @author curtthecoder
// @license MIT
// @match https://www.torn.com/*
// @grant GM_xmlhttpRequest
// @connect api.torn.com
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
const STORAGE_KEY = 'tornGrowthAdvisor';
const API_USER = 'https://api.torn.com/user/?selections=profile,bars,money,battlestats,cooldowns,refills,networth,stocks,missions&key=';
const API_TORN_STOCKS = 'https://api.torn.com/torn/?selections=stocks&key=';
function getStorage() {
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : { apiKey: null };
}
function saveStorage(data) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
// ---------- API ----------
// Works in Tampermonkey (GM_xmlhttpRequest) and in Torn PDA / plain browsers
// (fetch — the Torn API sends permissive CORS headers).
function apiRequest(url) {
return new Promise((resolve) => {
if (typeof GM_xmlhttpRequest === 'function') {
GM_xmlhttpRequest({
method: 'GET',
url: url,
headers: { 'Accept': 'application/json' },
onload: (r) => {
try { resolve(JSON.parse(r.responseText)); }
catch (e) { resolve({ error: { code: -1, error: 'Could not parse API response' } }); }
},
onerror: () => resolve({ error: { code: -1, error: 'Network request to api.torn.com failed' } })
});
} else {
fetch(url)
.then((r) => r.json())
.then(resolve)
.catch(() => resolve({ error: { code: -1, error: 'Network request to api.torn.com failed' } }));
}
});
}
// Transient failures (mobile network hiccups, rate limits, half-loaded
// responses) are common on PDA — retry once before giving up.
function apiRequestWithRetry(url) {
return apiRequest(url).then((data) => {
const transient = data && data.error && (data.error.code === -1 || data.error.code === 5);
if (!transient) return data;
return new Promise((r) => setTimeout(r, 1500)).then(() => apiRequest(url));
});
}
function fetchUser(apiKey) {
return apiRequestWithRetry(API_USER + encodeURIComponent(apiKey)).then((data) => {
if (data.error) {
return { error: data.error }; // { code, error }
}
const user = {
name: data.name,
level: data.level || 1,
rank: data.rank || '',
moneyOnHand: data.money_onhand || 0,
cityBank: data.city_bank ? data.city_bank.amount : 0,
energy: data.energy || { current: 0, maximum: 100 },
nerve: data.nerve || { current: 0, maximum: 15 },
happy: data.happy || { current: 0, maximum: 250 },
refills: data.refills || null, // { energy_refill_used, nerve_refill_used }
cooldowns: data.cooldowns || null, // { drug, medical, booster } seconds
networth: (data.networth && data.networth.total) || 0,
missions: data.missions || null, // { giver: [ { title, status } ] }
myStocks: data.stocks || {}, // { id: { stock_id, total_shares } }
stats: {
strength: data.strength || 0,
speed: data.speed || 0,
defense: data.defense || 0,
dexterity: data.dexterity || 0,
total: data.total || ((data.strength||0)+(data.speed||0)+(data.defense||0)+(data.dexterity||0))
}
};
const storage = getStorage();
storage.cachedUser = user;
storage.cachedAt = Date.now();
saveStorage(storage);
recordSnapshot(user);
return user;
});
}
function fetchTornStocks(apiKey) {
return apiRequest(API_TORN_STOCKS + encodeURIComponent(apiKey)).then((data) => {
if (data.error || !data.stocks) return null;
return data.stocks; // { id: { name, acronym, current_price, benefit: { requirement, description, ... } } }
});
}
function apiErrorHelp(err) {
// Torn API error codes: 2 = incorrect key, 16 = access level too low
if (err.code === 2) {
return 'Your API key looks incorrect. Double-check you copied the whole key with no spaces.';
}
if (err.code === 16) {
return 'Your key does not have enough access. Create a <strong>Limited Access</strong> key: Torn → Settings → API Keys → "Create Key" → Limited Access.';
}
return 'Error from Torn API: ' + err.error + '. Try creating a fresh <strong>Limited Access</strong> key under Settings → API Keys.';
}
// ---------- Progress tracking ----------
// Record at most one snapshot per 6 hours; keep ~200 (about 7 weeks of history).
function recordSnapshot(u) {
const storage = getStorage();
const snaps = storage.snapshots || [];
const last = snaps[snaps.length - 1];
if (!last || Date.now() - last.t > 6 * 3600 * 1000) {
snaps.push({
t: Date.now(),
level: u.level,
str: u.stats.strength,
spd: u.stats.speed,
def: u.stats.defense,
dex: u.stats.dexterity,
total: u.stats.total,
net: u.networth
});
while (snaps.length > 200) snaps.shift();
storage.snapshots = snaps;
saveStorage(storage);
}
}
// Compare current data against the newest snapshot that is at least ~7 days
// old (falling back to the oldest we have).
function getProgress(u) {
const snaps = getStorage().snapshots || [];
if (snaps.length < 2) return null;
const now = Date.now();
let base = snaps[0];
for (const s of snaps) {
if (now - s.t >= 7 * 86400 * 1000) base = s;
else break;
}
const days = (now - base.t) / (86400 * 1000);
if (days < 0.5) return null;
return {
days: Math.max(1, Math.round(days)),
dTotal: u.stats.total - base.total,
dStr: u.stats.strength - base.str,
dSpd: u.stats.speed - base.spd,
dDef: u.stats.defense - base.def,
dDex: u.stats.dexterity - base.dex,
dNet: u.networth - base.net,
dLevel: u.level - base.level
};
}
// ---------- Formatting ----------
function fmt(n) { return '$' + Number(n).toLocaleString(); }
function fmtAbbr(n) {
const abs = Math.abs(n);
if (abs >= 1e9) return (n / 1e9).toFixed(2) + 'B';
if (abs >= 1e6) return (n / 1e6).toFixed(2) + 'M';
if (abs >= 1e3) return (n / 1e3).toFixed(1) + 'k';
return String(Math.round(n));
}
function fmtSigned(n) { return (n >= 0 ? '+' : '−') + fmtAbbr(Math.abs(n)); }
// ---------- Missions ----------
// The API returns missions grouped by giver (e.g. Duke), each with a status
// like "notAccepted" / "accepted" / "completed" / "failed". Parse defensively
// since the exact shape can vary.
function missionCounts(u) {
const counts = { ready: 0, inProgress: 0 };
if (!u.missions) return counts;
Object.values(u.missions).forEach((list) => {
(Array.isArray(list) ? list : []).forEach((m) => {
const s = String(m && m.status || '').toLowerCase();
if (s === 'notaccepted') counts.ready++;
else if (s === 'accepted') counts.inProgress++;
});
});
return counts;
}
// ---------- Advice engine ----------
// Phase is based on BOTH level and total battle stats — a level 70 player with
// 400M stats needs very different advice than a level 70 with 50k stats.
function getPhase(u) {
const total = u.stats.total;
if (u.level < 15 || total < 100000) {
return { key: 'early', name: 'Early Game', blurb: 'Focus: build your nerve bar, train every drop of energy, and stack starter cash. Level 15 unlocks travel — that is your first big milestone.' };
}
if (u.level < 30 || total < 10000000) {
return { key: 'established', name: 'Getting Established', blurb: 'Focus: travel runs for steady money, keep chaining crimes for nerve growth, and keep the gym habit going with high happiness.' };
}
if (total < 100000000) {
return { key: 'mid', name: 'Mid Game', blurb: 'Focus: bigger money loops (stocks, bank investments, better crimes) and serious stat building — let your level come naturally.' };
}
return { key: 'veteran', name: 'Veteran', blurb: 'Focus: passive income engines (stock benefit blocks, bank cycles), Organized Crimes for nerve, and maximizing raw energy volume for training.' };
}
function buildRecommendations(u) {
const recs = [];
const phase = getPhase(u);
const isVet = phase.key === 'veteran';
const isMidPlus = phase.key === 'mid' || isVet;
// 1. Energy about to cap — never waste regen
const energyPct = u.energy.maximum ? u.energy.current / u.energy.maximum : 0;
if (energyPct >= 0.85) {
recs.push({
icon: '⚡',
title: 'Spend your energy NOW',
why: 'Your energy is at ' + u.energy.current + '/' + u.energy.maximum + '. Once it caps, regeneration is wasted. Split it between the gym and attacks.',
priority: 100
});
}
// 2. Daily refills — free growth left on the table
if (u.refills && (!u.refills.energy_refill_used || !u.refills.nerve_refill_used)) {
const which = [
!u.refills.energy_refill_used ? 'energy' : null,
!u.refills.nerve_refill_used ? 'nerve' : null
].filter(Boolean).join(' and ');
recs.push({
icon: '🔋',
title: 'Daily ' + which + ' refill unused',
why: 'Refills reset at midnight Torn time (TCT) — unused ones are gone forever. A full bar for 30 points is one of the best growth deals in the game. Use it before the day rolls over.',
priority: 78
});
}
// 3. Leveling / combat advice
if (u.level < 15) {
recs.push({
icon: '📈',
title: 'Level up: attack & LEAVE targets',
why: 'Attacking a player and choosing LEAVE gives the most XP (mugging only gives ~55%, hospitalizing ~40%). Look for "leveling targets" — higher-level players with weak battle stats. This is the fastest road to level 15, which unlocks travel.',
priority: 90
});
} else if (!isVet) {
recs.push({
icon: '🏋️',
title: 'Level comes naturally — energy belongs in the gym',
why: 'Past level 15, your level barely matters; chasing it wastes energy. Put your energy into gym training and let levels arrive on their own — battle stats are what actually win fights and unlock opportunities.',
priority: 60
});
} else {
recs.push({
icon: '⚔️',
title: 'Put your stats to work: chains & wars',
why: 'With your battle stats, solo leveling targets are trivial — faction chains, ranked wars, and bounty hunting are where your combat power pays off in respect, war rewards, and cash.',
priority: 60
});
}
// 4. Nerve full — do crimes
const nervePct = u.nerve.maximum ? u.nerve.current / u.nerve.maximum : 0;
if (nervePct >= 0.7) {
let crimeTip;
if (u.nerve.maximum < 15) {
crimeTip = 'Start with Search for Cash — the entry crime of Crimes 2.0 — then add Bootlegging and Graffiti as your success rate grows. Easy successes build your hidden Crime Experience, which permanently raises your nerve bar.';
} else if (u.nerve.maximum < 20) {
crimeTip = 'Run the Pickpocketing ladder: start with the Kid, then the Old Woman, then the Businessman (move up every 50–100 successes). Keep your success chain alive — each success boosts skill gain, and a critical fail resets the chain.';
} else if (isMidPlus) {
crimeTip = 'Chain your highest-skill crime for solo nerve, but the real payoff at your stage is Organized Crimes — join your faction\'s OCs for the biggest per-nerve rewards and keep your crime skills leveled so you qualify for better OC roles.';
} else {
crimeTip = 'Chain the best crime you have unlocked. Successful chains boost Crime Experience and skill gain — avoid risky crimes that could critical-fail and reset your chain.';
}
recs.push({
icon: '🔪',
title: 'Nerve is ready — commit crimes',
why: 'Nerve at ' + u.nerve.current + '/' + u.nerve.maximum + '. ' + crimeTip,
priority: 85
});
}
// 5. Happiness check before gym — happiness only meaningfully boosts gym
// gains at lower stats (its term in the gym formula is flat, so it gets
// drowned out as stats grow). Don't nag high-stat players about it.
const happyPct = u.happy.maximum ? u.happy.current / u.happy.maximum : 0;
const happyMatters = u.stats.total < 10000000;
if (happyMatters && happyPct < 0.5 && energyPct >= 0.5) {
recs.push({
icon: '😊',
title: 'Raise happiness before hitting the gym',
why: 'Happiness is at ' + u.happy.current + '/' + u.happy.maximum + ' — at your stat level it has a big effect on gym gains. Eat candy, use boosters, or wait for regen before burning energy on training.',
priority: 70
});
} else if (energyPct >= 0.5) {
recs.push({
icon: '💪',
title: isVet ? 'Train big: it\'s about energy volume now' : 'Train battle stats at the gym',
why: isVet
? 'At your stat level, happiness barely moves the gym formula — gains come from raw energy volume. Use your daily refill, keep energy cooldowns working for you, consider stat enhancers, and pour it all into your best-gain stat.'
: 'Train the stat with the best gains in your current gym even if your build gets unbalanced — raw growth matters most early. Bigger stats also unlock better gyms.',
priority: 65
});
}
// 6. Drug cooldown sitting empty (mid game onwards)
if (isMidPlus && u.cooldowns && u.cooldowns.drug === 0) {
recs.push({
icon: '💊',
title: 'Drug cooldown is empty',
why: 'Serious builds keep a drug cooldown running (Xanax is the standard for +energy). An empty cooldown is unclaimed energy — if you use drugs, now is the time; if you avoid them, ignore this one.',
priority: 58
});
}
// 7. Missions ready or in progress
const mc = missionCounts(u);
if (mc.inProgress > 0) {
recs.push({
icon: '🎯',
title: 'Finish your mission' + (mc.inProgress > 1 ? 's' : '') + ' in progress',
why: 'You have ' + mc.inProgress + ' accepted mission' + (mc.inProgress > 1 ? 's' : '') + ' waiting on you. Rewards (cash and mission credits for the rewards shop) only pay out when you complete them — knock out the remaining objectives before they go stale.',
priority: 72
});
} else if (mc.ready > 0) {
recs.push({
icon: '🎯',
title: mc.ready + ' new mission' + (mc.ready > 1 ? 's' : '') + ' available',
why: 'Duke has mission' + (mc.ready > 1 ? 's' : '') + ' ready for you to accept. Missions pay cash plus mission credits you can spend in the rewards shop — free value that many players forget to pick up.',
priority: 62
});
}
// 8. Money advice
if (u.moneyOnHand > 50000) {
recs.push({
icon: '🏦',
title: 'Get cash off your hands: ' + fmt(u.moneyOnHand),
why: 'Money on hand can be mugged. Stash it in your city bank or vault. Once you have a big pile, bank investments pay serious interest over time.',
priority: 80
});
}
if (u.level < 15) {
recs.push({
icon: '🍺',
title: 'Do the daily beer flip',
why: 'Buy your 100-item daily limit of Beer ($10 each) from Bits \'n\' Bobs and resell for profit. Small, but it is free daily money while you grow. Also: take a starter job (Education is a good first) for working stats and perks.',
priority: 55
});
} else if (!isMidPlus) {
recs.push({
icon: '✈️',
title: 'Travel runs: plushies & flowers',
why: 'At level ' + u.level + ' you can fly. Buying plushies/flowers abroad and selling them in Torn is one of the best steady money-makers at your stage. Check a market tool for today\'s best route before you fly.',
priority: 75
});
} else {
recs.push({
icon: '📊',
title: 'Make your money work: stocks & bank cycles',
why: 'Plushie runs are beneath your bankroll now. Build stock positions with passive benefit blocks (free items, perks, dividends), keep a bank investment cycle running at the longest term you can commit to, and treat travel only as a bulk-buy tool.',
priority: 75
});
}
// 9. Always-on habit reminder
recs.push({
icon: '🔁',
title: 'Daily loop: energy → gym/attacks, nerve → crimes, cash → bank',
why: 'Consistency beats bursts in Torn. Log in a few times a day, never let energy or nerve sit at cap, and your level, money, and stats all grow together.',
priority: 40
});
recs.sort((a, b) => b.priority - a.priority);
return { phase, recs: recs.slice(0, 5) };
}
// ---------- Daily checklist ----------
function buildChecklist(u) {
const items = [
{ label: 'Energy spent', done: u.energy.maximum ? (u.energy.current / u.energy.maximum) < 0.9 : false },
{ label: 'Nerve spent', done: u.nerve.maximum ? (u.nerve.current / u.nerve.maximum) < 0.9 : false },
{ label: 'Cash banked', done: u.moneyOnHand < 50000 }
];
if (u.refills) {
items.push({ label: 'Energy refill used', done: !!u.refills.energy_refill_used });
items.push({ label: 'Nerve refill used', done: !!u.refills.nerve_refill_used });
}
if (u.cooldowns) {
items.push({ label: 'Drug cooldown active', done: u.cooldowns.drug > 0 });
}
if (u.missions) {
const mc = missionCounts(u);
items.push({ label: 'Missions cleared', done: mc.ready === 0 && mc.inProgress === 0 });
}
return items;
}
// ---------- Stock benefit blocks ----------
function buildStockAdvice(u, tornStocks) {
if (!tornStocks) return null;
const cash = u.moneyOnHand + u.cityBank;
const owned = [];
const targets = [];
Object.values(tornStocks).forEach((s) => {
if (!s.benefit || !s.benefit.requirement || !s.current_price) return;
const myShares = Object.values(u.myStocks || {})
.filter((m) => m.stock_id === s.stock_id)
.reduce((sum, m) => sum + (m.total_shares || 0), 0);
const cost = s.benefit.requirement * s.current_price;
const entry = {
acronym: s.acronym || s.name,
description: s.benefit.description || 'benefit',
requirement: s.benefit.requirement,
cost,
affordable: cost <= cash
};
if (myShares >= s.benefit.requirement) owned.push(entry);
else targets.push(entry);
});
targets.sort((a, b) => a.cost - b.cost);
return { owned, targets: targets.slice(0, 3) };
}
// ---------- UI ----------
function removeUI() {
['growthAdvisorPanel', 'growthAdvisorOverlay', 'apiKeySetupOverlay'].forEach(id => {
const el = document.getElementById(id);
if (el) el.remove();
});
}
function showAPIKeySetup(message) {
removeUI();
const html = `
<div id="apiKeySetupOverlay" style="position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 11000; display: flex; align-items: center; justify-content: center;">
<div style="background: #1a1a1a; color: #ccc; border: 2px solid #2a9d5c; border-radius: 8px; padding: 30px; max-width: 480px; width: 90%; font-family: Arial, sans-serif;">
<h2 style="margin-top: 0; color: #3ecf7c;">🌱 Torn Growth Advisor</h2>
${message ? `<div style="background:#3a1212; border-left:3px solid #cc4444; padding:10px; font-size:13px; margin-bottom:15px; color:#f0b0b0;">${message}</div>` : ''}
<p style="font-size: 14px; line-height: 1.6;">
Paste your Torn API key once and the advisor does the rest — no other input needed.
</p>
<div style="background: #0a0a0a; padding: 10px 14px; border-left: 3px solid #2a9d5c; margin-bottom: 15px; font-size: 13px; line-height: 1.7;">
Torn → <strong>Settings → API Keys</strong> → Create a <strong>Limited Access</strong> key
<br>(needed so the advisor can read your bars, money and battle stats — it can never spend or change anything)
</div>
<input type="password" id="gaApiKeyInput" placeholder="Paste your Limited Access API key" style="width: 100%; padding: 10px; background: #222; color: #ccc; border: 1px solid #2a9d5c; border-radius: 4px; box-sizing: border-box; margin-bottom: 12px; font-family: monospace;">
<button id="gaSaveKeyBtn" style="width: 100%; padding: 12px; background: #2a9d5c; color: white; border: none; border-radius: 4px; font-weight: bold; cursor: pointer;">Save & Analyze Me</button>
<p style="font-size: 12px; color: #666; text-align: center; margin: 12px 0 0;">Stored only in your browser. Sent only to api.torn.com.</p>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', html);
document.getElementById('gaSaveKeyBtn').addEventListener('click', () => {
const key = document.getElementById('gaApiKeyInput').value.trim();
if (!key) return;
const storage = getStorage();
storage.apiKey = key;
saveStorage(storage);
showAdvisor();
});
}
async function showAdvisor() {
removeUI();
const storage = getStorage();
if (!storage.apiKey) { showAPIKeySetup(); return; }
// Loading shell first
document.body.insertAdjacentHTML('beforeend', `
<div id="growthAdvisorOverlay" style="position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 9999;"></div>
<div id="growthAdvisorPanel" style="position: fixed; top: 90px; left: 50%; transform: translateX(-50%); background: #131813; color: #ccc; border: 2px solid #2a9d5c; border-radius: 8px; padding: 20px; z-index: 10000; font-family: Arial, sans-serif; max-height: 82vh; overflow-y: auto; width: 92%; max-width: 640px; box-shadow: 0 0 20px rgba(42,157,92,0.35);">
<div style="text-align:center; padding: 40px 0; color:#3ecf7c;">Reading your profile from the Torn API…</div>
</div>
`);
document.getElementById('growthAdvisorOverlay').addEventListener('click', removeUI);
let [u, tornStocks] = await Promise.all([
fetchUser(storage.apiKey),
fetchTornStocks(storage.apiKey)
]);
const panel = document.getElementById('growthAdvisorPanel');
if (!panel) return;
let transientNote = null;
if (u.error) {
// Only a genuine key problem should send the user back to key setup:
// 1 = empty, 2 = incorrect, 13 = disabled (inactivity), 16 = access
// level too low, 18 = paused. Anything else (network blips, rate
// limits, parse failures) is transient — fall back to cached data.
const keyProblem = [1, 2, 13, 16, 18].indexOf(u.error.code) !== -1;
if (keyProblem) {
showAPIKeySetup(apiErrorHelp(u.error));
return;
}
if (storage.cachedUser) {
const mins = Math.max(1, Math.round((Date.now() - (storage.cachedAt || 0)) / 60000));
transientNote = 'Torn API did not respond just now — showing your data from ' + mins + ' minute' + (mins === 1 ? '' : 's') + ' ago. Hit ↻ to retry.';
u = storage.cachedUser;
} else {
panel.innerHTML = `
<div style="text-align: center; padding: 30px 10px;">
<div style="color: #f0b0b0; font-size: 14px; margin-bottom: 16px;">Torn API did not respond (this happens occasionally, especially on mobile). Your API key is safe.</div>
<button id="gaRetryBtn" style="background: #2a9d5c; color: white; border: none; padding: 10px 24px; border-radius: 4px; font-weight: bold; cursor: pointer;">Try Again</button>
</div>
`;
document.getElementById('gaRetryBtn').addEventListener('click', showAdvisor);
return;
}
}
const { phase, recs } = buildRecommendations(u);
const checklist = buildChecklist(u);
const progress = getProgress(u);
const stockAdvice = (phase.key === 'mid' || phase.key === 'veteran' || Object.keys(u.myStocks || {}).length > 0)
? buildStockAdvice(u, tornStocks)
: null;
const checklistHTML = checklist.map((c) => `
<span style="display: inline-flex; align-items: center; gap: 4px; background: ${c.done ? '#0e2417' : '#1a1414'}; border: 1px solid ${c.done ? '#2a9d5c' : '#443333'}; color: ${c.done ? '#3ecf7c' : '#997777'}; border-radius: 12px; padding: 3px 10px; font-size: 11px; white-space: nowrap;">${c.done ? '✓' : '○'} ${c.label}</span>
`).join('');
const progressHTML = progress ? `
<div style="background: #0b0f0b; border: 1px solid #234a33; border-radius: 6px; padding: 10px 14px; margin-bottom: 14px; font-size: 12px; line-height: 1.7;">
<span style="color: #3ecf7c; font-weight: bold;">📈 Last ${progress.days} day${progress.days === 1 ? '' : 's'}:</span>
<span style="color: #aaa;">
Stats <strong style="color:${progress.dTotal >= 0 ? '#3ecf7c' : '#ff6b6b'};">${fmtSigned(progress.dTotal)}</strong>
(STR ${fmtSigned(progress.dStr)} · SPD ${fmtSigned(progress.dSpd)} · DEF ${fmtSigned(progress.dDef)} · DEX ${fmtSigned(progress.dDex)})
· Networth <strong style="color:${progress.dNet >= 0 ? '#3ecf7c' : '#ff6b6b'};">${fmtSigned(progress.dNet)}</strong>
${progress.dLevel > 0 ? '· Level +' + progress.dLevel : ''}
</span>
</div>
` : `
<div style="background: #0b0f0b; border: 1px solid #223322; border-radius: 6px; padding: 8px 14px; margin-bottom: 14px; font-size: 11px; color: #777;">
📈 Progress tracking started — open the advisor again over the next few days to see your gains here.
</div>
`;
const stocksHTML = stockAdvice ? `
<h3 style="color: #3ecf7c; font-size: 15px; margin: 16px 0 10px;">Stock benefit blocks</h3>
<div style="background: #0b0f0b; border: 1px solid #223322; border-radius: 6px; padding: 12px 14px; font-size: 12px; line-height: 1.7;">
${stockAdvice.owned.length
? `<div style="color: #3ecf7c; margin-bottom: 6px;">✓ Owned (${stockAdvice.owned.length}): ${stockAdvice.owned.map(o => o.acronym).join(', ')}</div>`
: '<div style="color: #888; margin-bottom: 6px;">You own no benefit blocks yet — these pay passive rewards forever.</div>'}
${stockAdvice.targets.map(t => `
<div style="color: #aaa;">
<strong style="color: ${t.affordable ? '#3ecf7c' : '#ddd'};">${t.acronym}</strong>
— ${t.description}: ${t.requirement.toLocaleString()} shares ≈ <strong>$${fmtAbbr(t.cost)}</strong>
${t.affordable ? '<span style="color:#3ecf7c;">(affordable now!)</span>' : ''}
</div>
`).join('')}
</div>
` : '';
panel.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<h2 style="margin: 0; color: #3ecf7c; font-size: 20px;">🌱 Growth Advisor</h2>
<div>
<button id="gaChangeKey" title="Change API key" style="background: #333; color: #aaa; border: 1px solid #555; padding: 5px 9px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-right: 6px;">🔑</button>
<button id="gaRefresh" title="Refresh" style="background: #333; color: #aaa; border: 1px solid #555; padding: 5px 9px; border-radius: 4px; cursor: pointer; font-size: 12px; margin-right: 6px;">↻</button>
<button id="gaClose" style="background: #2a9d5c; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer; font-weight: bold;">✕</button>
</div>
</div>
<div style="font-size: 13px; color: #888; margin-bottom: 12px;">
${u.name} · Level ${u.level} · <span style="color:#3ecf7c;">${phase.name}</span>
</div>
${transientNote ? `<div style="background: #2a2416; border-left: 3px solid #ffd24d; color: #e8d9a0; padding: 8px 12px; font-size: 12px; margin-bottom: 12px; border-radius: 0 4px 4px 0;">${transientNote}</div>` : ''}
<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px;">
${checklistHTML}
</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 14px; text-align: center; font-size: 12px;">
<div style="background:#0b0f0b; border:1px solid #234a33; border-radius:6px; padding:8px;">
<div style="color:#888;">Energy</div>
<div style="color:#ffd24d; font-weight:bold; font-size:14px;">${u.energy.current}/${u.energy.maximum}</div>
</div>
<div style="background:#0b0f0b; border:1px solid #234a33; border-radius:6px; padding:8px;">
<div style="color:#888;">Nerve</div>
<div style="color:#ff6b6b; font-weight:bold; font-size:14px;">${u.nerve.current}/${u.nerve.maximum}</div>
</div>
<div style="background:#0b0f0b; border:1px solid #234a33; border-radius:6px; padding:8px;">
<div style="color:#888;">Happy</div>
<div style="color:#7ecbff; font-weight:bold; font-size:14px;">${u.happy.current}/${u.happy.maximum}</div>
</div>
<div style="background:#0b0f0b; border:1px solid #234a33; border-radius:6px; padding:8px;">
<div style="color:#888;">Networth</div>
<div style="color:#3ecf7c; font-weight:bold; font-size:14px;">$${fmtAbbr(u.networth)}</div>
</div>
</div>
${progressHTML}
<div style="font-size: 12px; color: #999; background:#0b0f0b; border-left: 3px solid #2a9d5c; padding: 8px 12px; margin-bottom: 16px; line-height: 1.5;">
${phase.blurb}
</div>
<h3 style="color: #3ecf7c; font-size: 15px; margin: 0 0 10px;">Do this now</h3>
${recs.map((r, i) => `
<div style="background: #0b0f0b; border: 1px solid ${i === 0 ? '#2a9d5c' : '#223322'}; border-radius: 6px; padding: 12px 14px; margin-bottom: 10px;">
<div style="font-weight: bold; color: ${i === 0 ? '#3ecf7c' : '#ddd'}; font-size: 14px; margin-bottom: 4px;">${r.icon} ${i + 1}. ${r.title}</div>
<div style="font-size: 12px; color: #aaa; line-height: 1.55;">${r.why}</div>
</div>
`).join('')}
${stocksHTML}
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-top: 14px; text-align: center; font-size: 11px;">
<div><span style="color:#888;">STR</span> <strong>${u.stats.strength.toLocaleString()}</strong></div>
<div><span style="color:#888;">SPD</span> <strong>${u.stats.speed.toLocaleString()}</strong></div>
<div><span style="color:#888;">DEF</span> <strong>${u.stats.defense.toLocaleString()}</strong></div>
<div><span style="color:#888;">DEX</span> <strong>${u.stats.dexterity.toLocaleString()}</strong></div>
</div>
`;
document.getElementById('gaClose').addEventListener('click', removeUI);
document.getElementById('gaRefresh').addEventListener('click', showAdvisor);
document.getElementById('gaChangeKey').addEventListener('click', () => showAPIKeySetup());
}
// ---------- Menu entry ----------
function injectStyles() {
if (document.getElementById('gaStyles')) return;
const s = document.createElement('style');
s.id = 'gaStyles';
s.textContent = '@keyframes gaPulse { 0% { box-shadow: 0 0 0 0 rgba(255,80,80,0.7); } 70% { box-shadow: 0 0 0 10px rgba(255,80,80,0); } 100% { box-shadow: 0 0 0 0 rgba(255,80,80,0); } }';
document.head.appendChild(s);
}
function setBadge(on) {
const dot = document.getElementById('gaBadgeDot');
if (dot) dot.style.display = on ? 'inline-block' : 'none';
const fab = document.getElementById('growthAdvisorFab');
if (fab) fab.style.animation = on ? 'gaPulse 2s infinite' : 'none';
}
// Preferred placement: a link inside Torn's left sidebar, where players
// expect script shortcuts. Torn's class names are build-hashed, so match
// on stable substrings and fall back gracefully.
function tryAddSidebarLink() {
if (document.getElementById('growthAdvisorLink')) return true;
const sidebar = document.querySelector('#sidebar');
if (!sidebar) return false;
const host = sidebar.querySelector('[class*="toplinks"], [class*="areasWrapper"], [class*="areas-"]') || sidebar;
const link = document.createElement('div');
link.id = 'growthAdvisorLink';
link.innerHTML = '<a href="#" style="display: flex; align-items: center; gap: 8px; padding: 7px 10px; color: #3ecf7c; font-weight: bold; font-size: 12px; text-decoration: none;">🌱 Growth Advisor<span id="gaBadgeDot" style="display: none; width: 8px; height: 8px; border-radius: 50%; background: #ff5050;"></span></a>';
host.appendChild(link);
link.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
showAdvisor();
});
return true;
}
// Fallback: a small circular button bottom-right. Unobtrusive on desktop
// and thumb-reachable on mobile, and it never covers Torn's header.
function addFab() {
if (document.getElementById('growthAdvisorFab')) return;
document.body.insertAdjacentHTML('beforeend', `
<button id="growthAdvisorFab" title="Growth Advisor" style="position: fixed; bottom: 18px; right: 18px; width: 48px; height: 48px; border-radius: 50%; background: #2a9d5c; color: white; border: none; font-size: 22px; cursor: pointer; z-index: 9998; box-shadow: 0 2px 10px rgba(0,0,0,0.5);">🌱</button>
`);
document.getElementById('growthAdvisorFab').addEventListener('click', showAdvisor);
}
// Quiet background check: light the badge when energy or nerve is >= 90%
// (regen about to be wasted) or a daily refill is still unused.
// Uses a 5-minute cache so we don't spam the API.
async function maybeCheckBars() {
const storage = getStorage();
if (!storage.apiKey) return;
let u;
if (storage.cachedUser && Date.now() - (storage.cachedAt || 0) < 5 * 60 * 1000) {
u = storage.cachedUser;
} else {
u = await fetchUser(storage.apiKey);
if (u.error) return;
}
const nearCap = (bar) => bar && bar.maximum && (bar.current / bar.maximum) >= 0.9;
setBadge(nearCap(u.energy) || nearCap(u.nerve));
}
function init() {
injectStyles();
// The sidebar can render after document-end; retry briefly, then fall back.
let attempts = 0;
const timer = setInterval(() => {
attempts++;
if (tryAddSidebarLink()) {
clearInterval(timer);
maybeCheckBars();
} else if (attempts >= 10) {
clearInterval(timer);
addFab();
maybeCheckBars();
}
}, 500);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();