Torn Growth Advisor

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.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         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();
  }
})();