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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Torn Growth Advisor
// @namespace    http://tampermonkey.net/
// @version      1.7.2
// @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,personalstats,perks&key=';
  // stocks = benefit-block pricing; items = market values so we can put a real
  // dollar figure (and yield) on item-payout blocks, not just cash dividends.
  const API_TORN_STOCKS = 'https://api.torn.com/torn/?selections=stocks,items&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,
        bankTimeLeft: data.city_bank ? (data.city_bank.time_left || 0) : 0, // secs until the bank investment matures (0 = none running)
        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 } }
        revives: (data.personalstats && data.personalstats.revives) || 0, // lifetime revives given
        reviveskill: (data.personalstats && data.personalstats.reviveskill) || 0, // 0-100
        // Reviving requires the Brain Surgeon medical-job perk. Detect it from
        // job_perks; also treat any past revive as proof they can revive.
        canRevive: (Array.isArray(data.job_perks) && data.job_perks.some((p) => /revive/i.test(p)))
                   || ((data.personalstats && data.personalstats.revives) || 0) > 0,
        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;
      // stocks: { id: { name, acronym, current_price, benefit: { requirement, frequency, description, ... } } }
      // items:  { id: { name, market_value, ... } } - optional; used to value item payouts.
      return { stocks: data.stocks, items: data.items || null };
    });
  }

  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)); }

  function fmtYield(y) { return y == null ? '-' : (y * 100).toFixed(1) + '%/yr'; }

  function fmtBreakEven(days) {
    if (days == null) return '-';
    if (days < 14) return Math.max(1, Math.round(days)) + 'd';
    if (days < 730) return Math.round(days / 7) + 'w';
    return (days / 365).toFixed(1) + 'y';
  }

  function fmtEvery(days) {
    if (!days) return '';
    return days === 1 ? 'daily' : 'every ' + days + 'd';
  }

  // ---------- 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;
  }

  // ---------- Revives ----------

  const REVIVE_DAILY_TARGET = 10;

  // Revives done since the start of the current Torn day (TCT = UTC). We only
  // know the lifetime count, so we baseline it the first time we see the user
  // each day and count up from there. If the first check of the day happens
  // after some revives, those aren't counted - an accepted approximation.
  function revivesToday(revivesLifetime) {
    const storage = getStorage();
    const today = new Date().toISOString().slice(0, 10); // UTC date string = TCT date
    let baseline = storage.reviveBaseline;
    if (!baseline || baseline.date !== today || baseline.count > revivesLifetime) {
      baseline = { date: today, count: revivesLifetime };
      storage.reviveBaseline = baseline;
      saveStorage(storage);
    }
    return Math.max(0, revivesLifetime - baseline.count);
  }

  // ---------- 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
      });
    }

    // 7b. Revives - only for players who can actually revive (Brain Surgeon
    // perk). While skill < 100 the goal is building skill; once maxed, the
    // goal shifts to income/faction support. Either way, reviving spends
    // energy (25–75 each), so it competes with the gym rather than adding
    // busywork - hence the daily target instead of "revive endlessly".
    if (u.canRevive) {
      const done = revivesToday(u.revives);
      if (done < REVIVE_DAILY_TARGET) {
        if (u.reviveskill < 100) {
          recs.push({
            icon: '❤️',
            title: 'Revives: ' + done + '/' + REVIVE_DAILY_TARGET + ' today (skill ' + u.reviveskill + '/100)',
            why: 'Reviving spends 25–75 energy each (lower with reviving upgrades) and raises your revive skill. Skill is the exact % of life you return - at 100 you give a full heal with the best success chance, which is what makes a revive service worth paying for. A steady ' + REVIVE_DAILY_TARGET + '/day keeps it climbing without eating all your gym energy.',
            priority: 66
          });
        } else {
          recs.push({
            icon: '❤️',
            title: 'Keep your revive streak: ' + done + '/' + REVIVE_DAILY_TARGET + ' today',
            why: 'Your revive skill is maxed at 100 - full heals and the best success chance. Revives now pay you directly: run a paid revive service and keep your faction alive through chains and wars. A steady ' + REVIVE_DAILY_TARGET + '/day keeps the income and goodwill flowing.',
            priority: 64
          });
        }
      }
    }

    // 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 {
      // Veterans: only nag about a bank cycle when one ISN'T running. If it is,
      // acknowledge it, pivot to stock benefit blocks, and drop the priority so
      // it stops dominating the list once the player is already set up.
      const bankActive = u.cityBank > 0;
      const inStocks = Object.keys(u.myStocks || {}).length > 0;
      if (!bankActive) {
        recs.push({
          icon: '🏦',
          title: 'Put idle cash to work: start a bank investment',
          why: 'You have no Torn City bank investment running. Lock a chunk in at the longest term you can commit to for passive interest, then build stock benefit blocks (free items, perks, dividends) on top. Travel is just a bulk-buy tool at your bankroll.',
          priority: 75
        });
      } else {
        const days = u.bankTimeLeft ? Math.round(u.bankTimeLeft / 86400) : 0;
        const bankNote = 'Your bank investment is running' + (days > 0 ? ' (matures in ~' + days + ' day' + (days === 1 ? '' : 's') + ')' : '') + '. ';
        recs.push({
          icon: '📊',
          title: inStocks ? 'Keep compounding: bank cycle + stock blocks' : 'Next lever: stock benefit blocks',
          why: bankNote + (inStocks
            ? 'Keep it rolling and keep adding stock benefit blocks - the Stocks tab ranks the best cost-to-payout targets.'
            : 'Your next passive-income lever is stock benefit blocks (free items, perks, dividends) - check the Stocks tab for the best yields.') + ' Treat travel only as a bulk-buy tool.',
          priority: 45
        });
      }
    }

    // 9. Always-on habit reminder
    recs.push({
      icon: '🔁',
      title: u.canRevive
        ? 'Daily loop: energy → gym/attacks/revives, nerve → crimes, cash → bank'
        : '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 });
    }
    if (u.canRevive) {
      const done = revivesToday(u.revives);
      items.push({ label: 'Revives ' + done + '/' + REVIVE_DAILY_TARGET, done: done >= REVIVE_DAILY_TARGET });
    }
    return items;
  }

  // ---------- Stock benefit blocks ----------

  // Turn a benefit description into an estimated per-payout dollar value.
  // Cash dividends ("$50,000,000") parse directly; item payouts ("100x Xanax")
  // are valued at current market price via the items map. Returns null value
  // when we can't price it (unknown item, non-cash perk) - those sink in the
  // ranking rather than being dropped.
  function valueBenefit(desc, items) {
    if (!desc) return { value: null, label: 'benefit' };
    const cashMatch = desc.match(/\$\s?([\d,]+)/);
    if (cashMatch) {
      return { value: Number(cashMatch[1].replace(/,/g, '')), label: desc };
    }
    const qtyMatch = desc.match(/(\d[\d,]*)\s*x/i);
    const qty = qtyMatch ? Number(qtyMatch[1].replace(/,/g, '')) : 1;
    if (items) {
      // Match the longest item name contained in the description to avoid
      // partial hits (e.g. "Xanax" inside a longer item name).
      let best = null;
      const lower = desc.toLowerCase();
      Object.values(items).forEach((it) => {
        if (it && it.name && lower.indexOf(it.name.toLowerCase()) !== -1) {
          if (!best || it.name.length > best.name.length) best = it;
        }
      });
      if (best && best.market_value) {
        return { value: qty * best.market_value, label: desc };
      }
    }
    return { value: null, label: desc };
  }

  // Short "what does this payout DO" hint for item payouts, keyword-matched on
  // the benefit description. When we show an item's market $ value we lose its
  // name, so this restores the useful part (Xanax → "⚡ energy"). Returns '' when
  // we have no confident label. Easy to extend as new blocks appear.
  function payoutUtility(desc) {
    if (!desc) return '';
    const d = desc.toLowerCase();
    const map = [
      [/xanax/, '⚡ energy'],
      [/drug pack|vicodin|ecstasy|cannabis|ketamine|\blsd\b|love juice|opium|\bpcp\b|shrooms|speed(?!way)/, '💊 drug'],
      [/medical|first aid|morphine|blood bag|ipecac/, '🩹 medical'],
      [/ammunition|ammo|bullet/, '🔫 ammo'],
      [/lawyer|business card/, '⚖️ jail-out'],
      [/clothing|cache/, '👕 clothing'],
      [/plushie/, '🧸 plushie'],
      [/flower/, '🌸 flower']
    ];
    for (const [re, label] of map) if (re.test(d)) return label;
    return '';
  }

  function buildStockAdvice(u, tornData) {
    if (!tornData || !tornData.stocks) return null;
    const cash = u.moneyOnHand + u.cityBank;
    const items = tornData.items;
    const owned = [];
    const targets = [];

    Object.values(tornData.stocks).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 freq = s.benefit.frequency || 7; // days between payouts
      const payout = valueBenefit(s.benefit.description, items);
      // Annualise the payout against the cost of holding the block so blocks
      // with different payout sizes and frequencies compare on equal footing.
      const perDay = payout.value != null ? payout.value / freq : null;
      const yieldPct = perDay != null ? (perDay * 365) / cost : null;
      const breakEvenDays = perDay != null && perDay > 0 ? cost / perDay : null;
      const entry = {
        acronym: s.acronym || s.name,
        name: s.name,
        description: payout.label,
        payoutTag: payoutUtility(s.benefit.description),
        requirement: s.benefit.requirement,
        frequency: freq,
        cost,
        payoutValue: payout.value,
        yieldPct,
        breakEvenDays,
        affordable: cost <= cash,
        progress: myShares / s.benefit.requirement
      };
      if (myShares >= s.benefit.requirement) owned.push(entry);
      else targets.push(entry);
    });

    // Best cost-to-payout first; blocks we couldn't price fall to the bottom
    // (ordered by cost among themselves).
    targets.sort((a, b) => {
      if (a.yieldPct == null && b.yieldPct == null) return a.cost - b.cost;
      if (a.yieldPct == null) return 1;
      if (b.yieldPct == null) return -1;
      return b.yieldPct - a.yieldPct;
    });
    return { owned, targets };
  }

  // ---------- 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 = buildStockAdvice(u, tornStocks);

    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>
    `;

    // Compact summary shown on the Advice tab: top 3 by yield, with a jump to
    // the full Stocks tab.
    const topTargets = stockAdvice ? stockAdvice.targets.slice(0, 3) : [];
    const totalBlocks = stockAdvice ? stockAdvice.targets.length + stockAdvice.owned.length : 0;
    const stocksSummaryHTML = stockAdvice ? `
      <div style="display:flex; justify-content:space-between; align-items:baseline; margin:16px 0 10px;">
        <h3 style="color:#3ecf7c; font-size:15px; margin:0;">Best benefit blocks</h3>
        <a href="#" id="gaGotoStocks" style="color:#7ecbff; font-size:12px; text-decoration:none;">View all ${totalBlocks} →</a>
      </div>
      <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>'}
        ${topTargets.length ? topTargets.map(t => `
          <div style="color:#aaa;">
            <strong style="color:${t.affordable ? '#3ecf7c' : '#ddd'};">${t.acronym}</strong>
            - <strong>$${fmtAbbr(t.cost)}</strong>${t.yieldPct != null ? ` · <span style="color:#7ecbff;">${fmtYield(t.yieldPct)}</span>` : ''}
            ${t.affordable ? '<span style="color:#3ecf7c;"> ✓ affordable</span>' : ''}
          </div>
        `).join('') : '<div style="color:#888;">Every benefit block owned - nice work.</div>'}
      </div>
    ` : '';

    // Full ranked table for the Stocks tab.
    const stocksTabHTML = !stockAdvice ? `
      <div style="color:#888; font-size:13px; padding:24px 4px; line-height:1.6;">
        Stock data isn't available right now - the Torn API didn't return it (this happens on mobile). Hit ↻ to retry.
      </div>
    ` : `
      <div style="font-size:12px; color:#999; margin-bottom:6px; line-height:1.5;">
        <strong style="color:#bbb;">Cost</strong> = shares needed × current price.
        <strong style="color:#bbb;">Yield</strong> = payout value annualised against that cost - higher means a better cost-to-payout deal. Item payouts are valued at current market price.
      </div>
      <div style="font-size:11px; color:#667; margin-bottom:12px;">
        Prices as of ${new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} · tap ↻ to refresh.
      </div>
      ${stockAdvice.owned.length ? `
        <h3 style="color:#3ecf7c; font-size:14px; margin:0 0 8px;">✓ Owned (${stockAdvice.owned.length})</h3>
        <div style="background:#0b0f0b; border:1px solid #234a33; border-radius:6px; padding:10px 12px; font-size:12px; line-height:1.7; margin-bottom:16px; color:#aaa;">
          ${stockAdvice.owned.map(o => `<div><strong style="color:#3ecf7c;">${o.acronym}</strong> - ${o.description}${o.payoutTag ? ` <span style="color:#7fae90;">${o.payoutTag}</span>` : ''}${o.frequency ? ` <span style="color:#666;">${fmtEvery(o.frequency)}</span>` : ''}</div>`).join('')}
        </div>
      ` : ''}
      <h3 style="color:#3ecf7c; font-size:14px; margin:0 0 8px;">Targets - ranked by yield</h3>
      <div style="overflow-x:auto;">
        <table style="width:100%; border-collapse:collapse; font-size:12px;">
          <thead>
            <tr style="color:#888; text-align:left; border-bottom:1px solid #234a33;">
              <th style="padding:6px 8px 6px 0;">Block</th>
              <th style="padding:6px 8px;">Cost</th>
              <th style="padding:6px 8px;">Payout</th>
              <th style="padding:6px 8px;">Yield</th>
              <th style="padding:6px 8px;">Break-even</th>
            </tr>
          </thead>
          <tbody>
            ${stockAdvice.targets.map(t => `
              <tr style="border-bottom:1px solid #16221a;">
                <td style="padding:7px 8px 7px 0;">
                  <strong style="color:${t.affordable ? '#3ecf7c' : '#ddd'};">${t.acronym}</strong>${t.affordable ? ' <span style="color:#3ecf7c; font-size:10px;">✓</span>' : ''}
                  <div style="color:#666; font-size:10px;">${t.requirement.toLocaleString()} sh</div>
                </td>
                <td style="padding:7px 8px; color:${t.affordable ? '#3ecf7c' : '#ddd'}; white-space:nowrap;">$${fmtAbbr(t.cost)}</td>
                <td style="padding:7px 8px; color:#aaa;">
                  ${t.payoutValue != null ? '$' + fmtAbbr(t.payoutValue) + (t.payoutTag ? ' <span style="color:#7fae90;">' + t.payoutTag + '</span>' : '') : t.description}
                  <div style="color:#666; font-size:10px;">${fmtEvery(t.frequency)}</div>
                </td>
                <td style="padding:7px 8px; color:${t.yieldPct != null ? '#7ecbff' : '#666'}; white-space:nowrap;">${fmtYield(t.yieldPct)}</td>
                <td style="padding:7px 8px; color:#aaa; white-space:nowrap;">${fmtBreakEven(t.breakEvenDays)}</td>
              </tr>
            `).join('')}
          </tbody>
        </table>
      </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; gap:4px; margin-bottom:16px; border-bottom:1px solid #234a33;">
        <button id="gaTabBtnAdvice" data-tab="advice">Advice</button>
        <button id="gaTabBtnStocks" data-tab="stocks">Stocks</button>
      </div>

      <div id="gaTabStocks" style="display:none;">
        ${stocksTabHTML}
      </div>

      <div id="gaTabAdvice">
      <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('')}

      ${stocksSummaryHTML}

      <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>
      </div>
    `;

    // Tab switching between the Advice and Stocks views (no refetch).
    function switchTab(tab) {
      const adv = document.getElementById('gaTabAdvice');
      const stk = document.getElementById('gaTabStocks');
      const ba = document.getElementById('gaTabBtnAdvice');
      const bs = document.getElementById('gaTabBtnStocks');
      if (!adv || !stk || !ba || !bs) return;
      const base = 'padding:8px 18px; border-radius:6px 6px 0 0; cursor:pointer; font-weight:bold; font-size:13px; margin-bottom:-1px;';
      const on = 'background:#0e2417; color:#3ecf7c; border:1px solid #2a9d5c; border-bottom-color:#0e2417;';
      const off = 'background:transparent; color:#888; border:1px solid transparent;';
      adv.style.display = tab === 'advice' ? 'block' : 'none';
      stk.style.display = tab === 'stocks' ? 'block' : 'none';
      ba.setAttribute('style', base + (tab === 'advice' ? on : off));
      bs.setAttribute('style', base + (tab === 'stocks' ? on : off));
      panel.scrollTop = 0;
    }
    switchTab('advice');

    document.getElementById('gaTabBtnAdvice').addEventListener('click', () => switchTab('advice'));
    document.getElementById('gaTabBtnStocks').addEventListener('click', () => switchTab('stocks'));
    const gotoStocks = document.getElementById('gaGotoStocks');
    if (gotoStocks) gotoStocks.addEventListener('click', (e) => { e.preventDefault(); switchTab('stocks'); });

    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();
  }
})();