War Clicks Helper

Optimal purchase ranking, auto-buy, war zone automation, prestige advisor, and ad removal for War Clicks

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         War Clicks Helper
// @name:zh-CN   War Clicks 助手
// @name:es      War Clicks Helper
// @name:fr      War Clicks Helper
// @name:ru      War Clicks Helper
// @name:de      War Clicks Helper
// @name:it      War Clicks Helper
// @name:nl      War Clicks Helper
// @namespace    https://greasyfork.org/users/warclicks-helper
// @version      2.01
// @description  Optimal purchase ranking, auto-buy, war zone automation, prestige advisor, and ad removal for War Clicks
// @description:zh-CN 最优购买排名、自动购买、战区自动化、声望建议及广告移除
// @description:es  Clasificación de compras óptima, compra automática, automatización de la zona de guerra, asesor de prestigio y eliminación de anuncios
// @description:fr  Classement d'achats optimal, achat automatique, automatisation de la zone de guerre, conseiller de prestige et suppression des publicités
// @description:ru  Оптимальное ранжирование покупок, автопокупка, автоматизация зоны войны, советник престижа и удаление рекламы
// @description:de  Optimales Kaufranking, Auto-Kauf, Kriegszone-Automatisierung, Prestige-Berater und Werbeentfernung
// @description:it  Classifica acquisti ottimale, acquisto automatico, automazione zona di guerra, consulente prestigio e rimozione annunci
// @description:nl  Optimale aankooprangschikking, automatisch kopen, oorlogszone-automatisering, prestige-adviseur en advertentieverwijdering
// @author       Dmitry Verkhoturov <[email protected]>
// @license      MIT
// @match        *://warclicks.com/*
// @match        *://*.warclicks.com/*
// @match        *://www.kongregate.com/games/gamexstudio/war-clicks*
// @match        *://cache.armorgames.com/files/games/war-clicks-18268/*
// @run-at       document-idle
// @grant        none
// ==/UserScript==

// MIT License
// Copyright (c) 2025 Dmitry Verkhoturov <[email protected]>
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

(function () {
  'use strict';

  // set window.__WC_DEBUG = true in console or in the dev wrapper to enable logging
  function log(...args) { if (window.__WC_DEBUG) console.log('[WC]', ...args); }

  // --- deploy thresholds (see CHANGELOG v1.19–2.0) ---
  const STALL_RATIO = 0.04;       // deploy when 5min growth < 4% of total boost
  const BOOST_CAP_PCT = 1000;     // safety cap: deploy at +1000% regardless
  const STALL_LOOKBACK_S = 300;   // 5 minutes of boost samples for stall detection

  // --- ad banner removal ---
  setInterval(() => {
    document.querySelectorAll(
      'div[style*="position: fixed"][style*="bottom: 0px"][style*="font-family: Roboto, Arial"]'
    ).forEach(el => el.remove());
  }, 2000);

  // --- number suffixes ---
  const SUFFIXES = [
    ['', 1], ['K', 1e3], ['M', 1e6], ['B', 1e9], ['T', 1e12],
    ['q', 1e15], ['Q', 1e18], ['s', 1e21], ['S', 1e24],
    ['O', 1e27], ['N', 1e30], ['d', 1e33], ['U', 1e36], ['D', 1e39],
    ['!', 1e42], ['@', 1e45], ['#', 1e48], ['$', 1e51], ['%', 1e54],
    ['^', 1e57], ['&', 1e60], ['*', 1e63],
  ];
  const SUFFIX_MAP = Object.fromEntries(SUFFIXES);

  function parseNum(str) {
    if (!str) return 0;
    str = str.replace(/\/s$/, '').replace(/,/g, '').trim();
    const m = str.match(/^([\d.]+)\s*([A-Za-z!@#$%^&*]?)$/);
    if (!m) return 0;
    return parseFloat(m[1]) * (SUFFIX_MAP[m[2]] || 1);
  }

  function fmtNum(n) {
    if (n === 0) return '0';
    for (let i = SUFFIXES.length - 1; i >= 0; i--) {
      if (Math.abs(n) >= SUFFIXES[i][1]) {
        const val = n / SUFFIXES[i][1];
        const s = SUFFIXES[i][0];
        if (Math.abs(val) >= 100) return Math.round(val) + s;
        if (Math.abs(val) >= 10) return val.toFixed(1) + s;
        return val.toFixed(2) + s;
      }
    }
    return n.toFixed(2);
  }

  function fmtWait(seconds) {
    if (!isFinite(seconds) || seconds <= 0) return '';
    if (seconds < 60) return Math.ceil(seconds) + 's';
    if (seconds < 3600) return Math.round(seconds / 60) + 'm';
    if (seconds < 86400) {
      const h = Math.floor(seconds / 3600);
      if (h >= 2) return '~' + h + 'h';
      const m = Math.round((seconds % 3600) / 60);
      return h + 'h' + (m > 0 ? m + 'm' : '');
    }
    const d = Math.floor(seconds / 86400);
    const h = Math.round((seconds % 86400) / 3600);
    return d + 'd' + (h > 0 ? h + 'h' : '');
  }

  function fmtPenalty(seconds) {
    if (!isFinite(seconds)) return '?';
    const abs = Math.abs(seconds);
    const sign = seconds < 0 ? '-' : '+';
    if (abs < 60) return sign + Math.round(abs) + 's';
    if (abs < 3600) return sign + Math.round(abs / 60) + 'm';
    if (abs < 86400) {
      const h = Math.floor(abs / 3600);
      const m = Math.round((abs % 3600) / 60);
      return sign + h + 'h' + (m > 0 ? m + 'm' : '');
    }
    const d = Math.floor(abs / 86400);
    const h = Math.round((abs % 86400) / 3600);
    return sign + d + 'd' + (h > 0 ? h + 'h' : '');
  }

  function getCurrentMoney() {
    const el = document.querySelector('.currency[data-title*="Power Points"] .value');
    return el ? parseNum(el.textContent.trim()) : 0;
  }

  function getCurrentIncome() {
    // authoritative: game engine computes totalUnitGPS = sum(cycleGain[i] / cycleTime[i])
    try {
      const gps = trainingCamp.game.db.totalUnitGPS; // eslint-disable-line
      if (gps && gps > 0) return gps;
    } catch (e) { /* not ready */ }
    // fallback: DOM display (only populated when setting #33 is enabled)
    const el = document.querySelector('.total-gps .amount');
    const displayed = el ? parseNum(el.textContent.trim()) : 0;
    if (displayed > 0) return displayed;
    // last resort: estimate from bar_value / cycle_time for each unit type;
    // note: bar_value shows per-cycle gain (setting #34 off) — divide by cycle time to get /s
    let estimated = 0;
    document.querySelectorAll('.tents > div').forEach(tent => {
      const barVal = tent.querySelector('.bar_value');
      const timeEl = tent.querySelector('.time');
      const numEl = tent.querySelector('.unitNumber');
      if (!barVal || !timeEl || !numEl) return;
      const count = parseInt(numEl.textContent.trim(), 10);
      if (!count || count <= 0) return;
      const timeStr = timeEl.textContent.trim().replace(/s$/, '');
      const cycleSecs = parseFloat(timeStr);
      if (!cycleSecs || cycleSecs <= 0) return;
      estimated += parseNum(barVal.textContent.trim()) / cycleSecs;
    });
    return estimated;
  }

  // --- CSS ---
  // fix 1: label uses left:50%+transform to center and extend beyond narrow button width;
  //         no overflow:hidden so text is never truncated
  const style = document.createElement('style');
  style.textContent = `
    .tents > div { z-index: 0; }
    .button[unit-id] { position: relative; }
    .upgrade[data-id] { position: relative; }
    .wc-lbl {
      position: absolute; bottom: -1px; left: 50%; transform: translateX(-50%);
      font-size: 9px; font-weight: bold; text-align: center;
      background: rgba(0,0,0,0.85); color: #fff; padding: 2px 4px;
      pointer-events: auto !important; cursor: help !important; white-space: nowrap; z-index: 6;
      line-height: 1.3; border-radius: 3px; min-width: 100%;
    }
    .button.wc-top { opacity: 1 !important; filter: none !important; }
    .upgrade[data-id] .wc-lbl {
      position: static; border-radius: 3px; margin-top: 2px;
      transform: none; left: auto; min-width: 0;
      pointer-events: auto !important; cursor: help !important;
    }
    body.wc-upgrades-open .tents > div { outline: none !important; }
    body.wc-upgrades-open .tents .wc-lbl { display: none !important; }
    body.wc-panel-open #wc-top-info { display: none !important; }
    body.wc-upgrades-open .tents .button[unit-id] {
      box-shadow: none !important;
    }
    #wc-top-info {
      position: absolute; top: 100%; left: 50%; transform: translateX(-50%);
      font-size: 10px; font-weight: bold; color: #0f0;
      text-align: center; padding: 1px 4px; white-space: nowrap;
      background: rgba(0,0,0,0.75); border-radius: 3px; z-index: 5;
      pointer-events: auto !important; cursor: help !important;
    }
  `;
  document.head.appendChild(style);

  function getUnitProduction() {
    const unitProd = {};
    document.querySelectorAll('.tents > div').forEach(tent => {
      const unitEl = tent.querySelector('.unit[unit-id]');
      const barVal = tent.querySelector('.bar_value');
      if (!unitEl || !barVal) return;
      const uid = unitEl.getAttribute('unit-id');
      const text = barVal.textContent.trim();
      if (uid !== null && text) unitProd[uid] = parseNum(text);
    });
    return unitProd;
  }

  function rankColor(i) {
    if (i >= 10) return '#c00';
    const ratio = i / 9;
    const r = Math.round(255 * ratio);
    const g = Math.round(200 - 35 * ratio);
    return `rgb(${r}, ${g}, 0)`;
  }

  function upgradesPanelOpen() {
    const panel = document.querySelector('.menu_upgrades');
    if (!panel) return false;
    return window.getComputedStyle(panel).display !== 'none';
  }

  function getUnitCount(tent, uid) {
    try {
      const army = trainingCamp.game.player.army; // eslint-disable-line
      if (army && army[uid] != null) {
        const val = army[uid];
        return typeof val === 'object' ? (val.count || val.amount || val.qty) : val;
      }
    } catch (e) { /* not available */ }
    for (const el of tent.querySelectorAll('div, span')) {
      if (el.closest('.button') || el.classList.contains('bar_value')) continue;
      if (el.children.length > 0) continue;
      const text = el.textContent.trim();
      if (/^\d{1,6}$/.test(text)) return parseInt(text, 10);
    }
    return null;
  }

  function getBuyAmount(buyBtn) {
    const m = buyBtn.textContent.match(/BUY\s+(\d+)/i);
    return m ? parseInt(m[1], 10) : 100;
  }

  // compute combined milestone multiplier for buying count → count+buyAmount units;
  // scans all uncrossed milestones (from unitMilestoneID) that trigger_n falls within new count;
  // type 0 = production multiplier (×), type 1 = speed multiplier (÷ cycleTime = effectively ×)
  // returns {prod, speed}; both 1.0 means no milestone crossed (pure linear gain)
  function nextMilestoneMult(uid, count, buyAmount) {
    try {
      const g = trainingCamp.game; // eslint-disable-line
      const milestones = g.const.unitMilestone[uid];
      if (!milestones) return { prod: 2, speed: 1 }; // fallback: assume 2×
      const nextID = g.db.unitMilestoneID[uid];
      if (nextID >= milestones.length) return { prod: 1, speed: 1 }; // all milestones passed
      const newCount = count + buyAmount;
      let prod = 1.0, speed = 1.0;
      for (let mid = nextID; mid < milestones.length; mid++) {
        const ms = milestones[mid];
        if (newCount < parseInt(ms.trigger_n, 10)) break;
        if (parseInt(ms.type, 10) === 0) prod *= parseFloat(ms.multiplier);
        else if (parseInt(ms.type, 10) === 1) speed *= parseFloat(ms.multiplier);
      }
      return { prod, speed };
    } catch (e) { return { prod: 2, speed: 1 }; } // fallback
  }

  // single-unit cost cache — mode switch only happens every 3rd collectAll call
  // so single-unit buys are considered at most once every ~9s (3 × 3s idle cycle)
  let _singleCostCache = {};
  let _collectAllCycle = -1; // start at -1 so first increment gives 0 → immediate refresh

  function getSingleUnitCosts() {
    _collectAllCycle++;
    if (_collectAllCycle % 3 !== 0) return _singleCostCache;
    const costs = {};
    try {
      const g = trainingCamp.game; // eslint-disable-line
      setBuyMode(0); // temporarily switch to 1X to read per-unit costs
      document.querySelectorAll('.tents > div .unit[unit-id]').forEach(el => {
        const uid = el.getAttribute('unit-id');
        const c = g.db.unitCostNew[uid];
        if (c > 0) costs[uid] = c;
      });
    } catch (e) { /* trainingCamp not loaded */ }
    setBuyMode(5); // restore MaxOCD
    _singleCostCache = costs;
    return costs;
  }

  function collectAll(unitProd) {
    let upgradeConst = null, unitConst = null;
    try {
      upgradeConst = trainingCamp.game.const.unitUpgrade; // eslint-disable-line
      unitConst = trainingCamp.game.const.unit; // eslint-disable-line
    } catch (e) { /* not loaded yet */ }

    const upgrades = [];
    const units = [];

    document.querySelectorAll('.upgrade[data-id]').forEach(el => {
      const dataId = parseInt(el.getAttribute('data-id'), 10);
      let unitId, multiplier;
      const costEl = el.querySelector('.value .number');
      if (!costEl) return;
      const cost = parseNum(costEl.textContent.trim());

      if (upgradeConst && upgradeConst[dataId]) {
        const upg = upgradeConst[dataId];
        unitId = String(upg.unit_id);
        multiplier = upg.multiplier;
      } else {
        const desc = el.querySelector('.desc');
        if (!desc) return;
        const spans = desc.querySelectorAll('span:not(.spec-arrow-up):not(.spec-arrow-down)');
        const multText = spans[1] ? spans[1].textContent.trim() : '2';
        multiplier = parseFloat(multText) || 2;
        if (unitConst) {
          const unitName = spans[0] ? spans[0].textContent.trim() : '?';
          const found = unitConst.find(u => u.name === unitName);
          unitId = found ? String(found.id) : null;
        }
      }

      const prod = unitProd[unitId];
      if (prod == null || prod === 0 || cost <= 0) return;
      const gain = prod * (multiplier - 1);
      const buyBtn = el.querySelector('.button') || el;
      // store unitId and multiplier for combo building
      upgrades.push({ type: 'upgrade', el, btn: buyBtn, gain, cost, efficiency: gain / cost, unitId, multiplier });
    });

    document.querySelectorAll('.tents > div').forEach(tent => {
      const unitEl = tent.querySelector('.unit[unit-id]');
      const barVal = tent.querySelector('.bar_value');
      const buyBtn = tent.querySelector('.button[unit-id]');
      if (!unitEl || !barVal || !buyBtn) return;

      const uid = unitEl.getAttribute('unit-id');
      const totalProd = unitProd[uid];
      const costEl = buyBtn.querySelector('.value');
      if (!costEl) return;
      const cost = parseNum(costEl.textContent.trim());
      if (!totalProd || totalProd === 0 || cost <= 0) return;

      const count = getUnitCount(tent, uid);
      const buyAmount = getBuyAmount(buyBtn);
      // compute actual milestone multiplier for this buy (may be 2×, 4×, 3×, or 1× if none)
      const ms = (count && count > 0) ? nextMilestoneMult(uid, count, buyAmount) : { prod: 1, speed: 1 };
      const milestoneMult = ms.prod * ms.speed;
      const gain = (count && count > 0)
        ? (milestoneMult > 1
          ? totalProd * (milestoneMult * (count + buyAmount) / count - 1)  // milestone fires: all units scale
          : totalProd * buyAmount / count)                                   // no milestone: pure linear
        : totalProd;
      // store uid and totalProd for combo building
      units.push({ type: 'unit', el: tent, btn: buyBtn, gain, cost, efficiency: gain / cost, count, buyAmount, uid, totalProd });
    });

    // collect 1-unit costs — mode switch happens every 3rd call (see getSingleUnitCosts)
    const singleUnitCosts = getSingleUnitCosts();

    const unitSingles = [];
    for (const unit of units) {
      const singleCost = singleUnitCosts[unit.uid];
      if (!singleCost || singleCost <= 0) continue;
      if (!unit.count || unit.count <= 0 || unit.buyAmount <= 1) continue; // skip if already in 1X mode
      const ms1 = nextMilestoneMult(unit.uid, unit.count, 1);
      const singleMult = ms1.prod * ms1.speed;
      const singleGain = singleMult > 1
        ? unit.totalProd * (singleMult * (unit.count + 1) / unit.count - 1)
        : unit.totalProd / unit.count;
      if (singleGain <= 0) continue;
      unitSingles.push({
        type: 'unit_single', el: unit.el, btn: unit.btn,
        gain: singleGain, cost: singleCost, efficiency: singleGain / singleCost,
        count: unit.count, buyAmount: 1, uid: unit.uid, totalProd: unit.totalProd,
      });
    }

    // fix 2: build combo items — buying a unit then its upgrade is worth more than
    // the sum of parts because the unit adds production before the upgrade multiplies it
    const combos = buildCombos(units, upgrades);
    return [...units, ...upgrades, ...combos, ...unitSingles];
  }

  // a combo buys a unit and its matching upgrade together; the upgrade gain is calculated
  // on the post-unit production so the synergy between them is fully captured
  function buildCombos(units, upgrades) {
    const combos = [];
    for (const unit of units) {
      if (!unit.uid) continue;
      for (const upg of upgrades) {
        if (upg.unitId !== unit.uid) continue;
        const prodAfterUnit = unit.totalProd + unit.gain;
        const comboUpgGain = prodAfterUnit * (upg.multiplier - 1);
        const comboGain = unit.gain + comboUpgGain;
        const comboCost = unit.cost + upg.cost;
        combos.push({
          type: 'combo',
          unit,
          upgrade: upg,
          gain: comboGain,
          cost: comboCost,
          efficiency: comboGain / comboCost,
          el: unit.el,
          btn: unit.btn,
        });
      }
    }
    return combos;
  }

  // time to buy first then second, given current money and income
  function pairTime(first, second, money, income) {
    const wait1 = Math.max(0, (first.cost - money) / income);
    const moneyAfter = Math.max(0, money + income * wait1 - first.cost);
    const incAfter = income + first.gain;
    const wait2 = Math.max(0, (second.cost - moneyAfter) / incAfter);
    return wait1 + wait2;
  }

  function removeFromPool(pool, item) {
    const idx = pool.indexOf(item);
    if (idx >= 0) pool.splice(idx, 1);
  }

  // simulate optimal purchase sequence; combos are treated as atomic buys —
  // selecting a combo removes its constituent unit and upgrade from the pool,
  // and selecting an individual item removes any combo that contained it
  function simulateOptimal(items, money, income) {
    const pool = items.slice();
    let t = 0;
    let curMoney = money;
    let curIncome = income;
    const order = [];

    while (pool.length > 0) {
      pool.sort((a, b) => {
        const ab = pairTime(a, b, curMoney, curIncome);
        const ba = pairTime(b, a, curMoney, curIncome);
        const diff = ab - ba;
        return Math.abs(diff) < 0.5 ? b.gain - a.gain : diff;
      });

      const target = pool.splice(0, 1)[0];

      if (target.type === 'combo') {
        // buying combo: remove standalone unit and upgrade so they're not bought again
        removeFromPool(pool, target.unit);
        removeFromPool(pool, target.upgrade);
        // remove any other combos that share the same unit or upgrade
        // (can't rebuy the unit or upgrade in another combo)
        for (let j = pool.length - 1; j >= 0; j--) {
          const p = pool[j];
          if (p.type === 'combo' && (p.unit === target.unit || p.upgrade === target.upgrade)) {
            pool.splice(j, 1);
          }
        }
      } else {
        // buying individual: remove any combo that contains this item
        for (let j = pool.length - 1; j >= 0; j--) {
          const p = pool[j];
          if (p.type === 'combo' && (p.unit === target || p.upgrade === target)) {
            pool.splice(j, 1);
          }
        }
      }

      if (target.cost <= curMoney) {
        curMoney -= target.cost;
      } else {
        const wait = (target.cost - curMoney) / curIncome;
        t += wait;
        curMoney = 0;
      }
      curIncome += target.gain;
      target.simEta = t;
      order.push(target);
    }

    return order;
  }

  // delay: how much buying B delays reaching #1
  function computeDelays(items, money, income) {
    if (items.length < 2) { if (items.length === 1) items[0].delay = 0; return; }
    if (income <= 0) return;

    const first = items[0];
    first.delay = 0;
    const directToFirst = Math.max(0, (first.cost - money) / income);

    for (let i = 1; i < items.length; i++) {
      const b = items[i];
      // for combos the label sits on the unit button — clicking only spends unit.cost and gains unit.gain;
      // using the full combo cost would overstate the delay
      const clickCost = b.type === 'combo' ? b.unit.cost : b.cost;
      const clickGain = b.type === 'combo' ? b.unit.gain : b.gain;
      let t = 0, m = money, inc = income;
      if (clickCost > m) { t += (clickCost - m) / inc; m = 0; }
      else { m -= clickCost; }
      inc += clickGain;
      if (first.cost > m) { t += (first.cost - m) / inc; }
      b.delay = t - directToFirst;
    }
  }

  function fmtPct(gain, income) {
    if (income <= 0) return '+?%';
    const pct = gain / income * 100;
    return '+' + (pct >= 10 ? Math.round(pct) : pct.toFixed(1)) + '%';
  }

  function applyVisuals(items, money, income) {
    if (!items.length) return;
    // each button/el only gets the label from the highest-ranked item (first writer wins)
    const labelledBtns = new Set();
    const labelledUpgEls = new Set();

    items.forEach((it, i) => {
      const color = rankColor(i);
      const btn = it.btn;
      const affordable = it.cost <= money;
      const top10 = i < 10;

      // top 3: solid green highlight on the whole container
      if (i < 3) {
        it.el.style.outline = '3px solid rgb(0, 200, 0)';
        it.el.style.outlineOffset = '-3px';
      } else {
        it.el.style.outline = '';
        it.el.style.outlineOffset = '';
      }

      // for combos: also highlight the upgrade element with a dashed outline
      if (it.type === 'combo' && it.upgrade && it.upgrade.el) {
        it.upgrade.el.style.outline = i < 3
          ? '3px dashed rgb(0,200,0)'
          : `2px dashed ${color}`;
        it.upgrade.el.style.outlineOffset = '-2px';
      }

      // color the buy button
      if (btn) {
        if (top10 && affordable) {
          btn.style.setProperty('background', color, 'important');
          btn.style.boxShadow = '';
          btn.classList.remove('wc-top');
        } else if (top10) {
          btn.style.background = '';
          btn.style.boxShadow = `inset 0 0 0 2px ${color}, 0 0 8px ${color}`;
          btn.classList.add('wc-top');
        } else {
          btn.style.background = '';
          btn.style.boxShadow = '';
          btn.classList.remove('wc-top');
        }
      }

      // text label: on the buy button (units/combos/unit_singles) or inline after .value div (upgrades)
      // each button/upgrade el only shows the label of its highest-ranked item
      let label;
      if (it.type === 'unit' || it.type === 'combo' || it.type === 'unit_single') {
        const labelParent = btn;
        if (!labelParent) return;
        if (!labelledBtns.has(labelParent)) {
          labelledBtns.add(labelParent);
          label = labelParent.querySelector('.wc-lbl');
          if (!label) {
            label = document.createElement('div');
            label.className = 'wc-lbl';
            labelParent.appendChild(label);
          }
        }
        // combos: add a small tag on the upgrade element to show it's paired
        if (it.type === 'combo' && it.upgrade && it.upgrade.el) {
          const upgEl = it.upgrade.el;
          if (!labelledUpgEls.has(upgEl)) {
            labelledUpgEls.add(upgEl);
            let comboTag = upgEl.querySelector('.wc-combo-tag');
            if (!comboTag) {
              comboTag = document.createElement('div');
              comboTag.className = 'wc-lbl wc-combo-tag';
              const valueDiv = upgEl.querySelector('.value');
              if (valueDiv && valueDiv.parentNode) {
                valueDiv.parentNode.insertBefore(comboTag, valueDiv.nextSibling);
              } else {
                upgEl.appendChild(comboTag);
              }
            }
            comboTag.style.color = color;
            comboTag.title = 'This upgrade is evaluated together with its unit as combo #' + (i + 1) + '. Buy both for maximum efficiency.';
            comboTag.textContent = 'combo #' + (i + 1);
          }
        }
      } else {
        if (!labelledBtns.has(it.el)) {
          labelledBtns.add(it.el);
          label = it.el.querySelector('.wc-lbl');
          if (!label) {
            label = document.createElement('div');
            label.className = 'wc-lbl';
            const valueDiv = it.el.querySelector('.value');
            if (valueDiv && valueDiv.parentNode) {
              valueDiv.parentNode.insertBefore(label, valueDiv.nextSibling);
            } else {
              it.el.appendChild(label);
            }
          }
        }
      }

      if (!label) return; // already labelled by a higher-ranked item

      // 'c' suffix marks a combo; 's' suffix marks a single-unit buy
      const rankStr = '#' + (i + 1) + (it.type === 'combo' ? 'c' : it.type === 'unit_single' ? 's' : '');
      const pctStr = fmtPct(it.gain, income);
      let extra = '';
      let tooltip = 'Rank ' + (i + 1);
      if (it.type === 'combo') tooltip += ' (combo: unit + upgrade bought together)';
      else if (it.type === 'unit_single') tooltip += ' (single unit: 1 unit instead of MaxOCD batch)';
      tooltip += ': buying this increases income by ' + pctStr;
      if (i === 0) {
        const w = fmtWait(it.simEta || 0);
        if (w) { extra = ' ' + w; tooltip += '. Affordable in ' + w; }
        else tooltip += '. Affordable now!';
      } else {
        if (it.delay != null && Math.abs(it.delay) >= 5) {
          extra = ' ' + fmtPenalty(it.delay);
          tooltip += it.delay > 0
            ? '. Buying this before #1 costs ' + fmtWait(it.delay) + ' of extra waiting'
            : '. Buying this before #1 saves ' + fmtWait(-it.delay);
        }
      }
      label.style.color = color;
      label.title = tooltip;
      label.textContent = rankStr + ' ' + pctStr + extra;
      // for upgrades the label is position:static under absolute children — set tooltip on the card too
      if (it.type === 'upgrade') {
        it.el.title = tooltip;
        it.el.style.cursor = 'help';
      }
    });
  }

  function cleanStale(items) {
    const activeBtns = new Set(items.map(i => i.btn).filter(Boolean));
    const activeEls = new Set(items.map(i => i.el));
    // track upgrade elements that are part of active combos (not in activeEls directly)
    const activeUpgEls = new Set(
      items.filter(i => i.type === 'combo' && i.upgrade).map(i => i.upgrade.el)
    );

    document.querySelectorAll('.upgrade[data-id]').forEach(el => {
      if (!activeEls.has(el) && !activeUpgEls.has(el)) {
        el.querySelectorAll('.wc-lbl').forEach(l => l.remove());
        const ubtn = el.querySelector('.button');
        if (ubtn) { ubtn.style.background = ''; ubtn.style.boxShadow = ''; ubtn.classList.remove('wc-top'); }
        el.style.outline = ''; el.style.outlineOffset = '';
        el.title = ''; el.style.cursor = '';
      }
    });
    document.querySelectorAll('.tents > div .button[unit-id]').forEach(btn => {
      if (!activeBtns.has(btn)) {
        btn.style.background = ''; btn.style.boxShadow = '';
        btn.classList.remove('wc-top');
        btn.querySelectorAll('.wc-lbl').forEach(l => l.remove());
      }
    });
    document.querySelectorAll('.wc-eff, .wc-tent-eff').forEach(l => l.remove());
    document.querySelectorAll('.upgrade[data-id], .tents > div').forEach(el => {
      if (!activeEls.has(el) && !activeUpgEls.has(el)) { el.style.outline = ''; el.style.outlineOffset = ''; }
    });
    // remove combo tags from upgrade elements no longer part of an active combo
    document.querySelectorAll('.wc-combo-tag').forEach(tag => {
      const parentEl = tag.closest('.upgrade[data-id]');
      if (!parentEl || !activeUpgEls.has(parentEl)) tag.remove();
    });
    // upgrade is now in a combo: strip any leftover standalone rank label so only the combo tag remains
    activeUpgEls.forEach(el => {
      el.querySelectorAll('.wc-lbl:not(.wc-combo-tag)').forEach(l => l.remove());
    });
  }

  function isPanelOpen(sel) {
    const el = document.querySelector(sel);
    return !!(el && getComputedStyle(el).display !== 'none');
  }

  setInterval(() => {
    const upgradesOpen = upgradesPanelOpen();
    document.body.classList.toggle('wc-upgrades-open', upgradesOpen);
    // hide top info bar whenever any overlay panel is visible (all have z-index:1, our bar is 5)
    const panelOpen = upgradesOpen || isPanelOpen('#cloudInfo') || isPanelOpen('.menu_managers');
    document.body.classList.toggle('wc-panel-open', panelOpen);
  }, 200);


  // deploy decision: hybrid stall-detection + safety-net cap.
  // tested 28 strategies across 7 scenarios (fast/medium/slow growth × low/medium/high/very-high
  // income caps × 8h/24h sessions). winner: stall-4% OR boost>=1000%, whichever fires first.
  //
  // why this works:
  // - compounding means longer waits almost always pay off (more contractors → higher multiplier
  //   → more income → exponentially more pending next cycle)
  // - stall-4%: deploy when last 5 minutes of boost growth added less than 4% of total boost
  //   (income has plateaued, upgrades exhausted — waiting further gives diminishing returns)
  // - f1000% cap: safety net for extremely sustained growth where stall never triggers in 8h
  // - first prestige: deploy immediately (any boost is pure gain from zero)
  let _lastKnownIncome = 0; // rolling income for manual deploy detection
  let _peakIncome = 0; // highest income seen this cycle (used for recovery tracking)
  let _boostSamples = []; // [{t, boost}] — sampled every 30s for stall detection
  let _boostSampleTime = 0;
  let _stallLogged = false; // true after logging for a sample; reset on next sample
  let _growthWindow = []; // [{t, boost}] — sliding window for growth rate (last 30s)
  let _stallRatios = []; // [{t, ratio}] — for predicting time-to-deploy
  let _deployDecisionLogged = false;

  // read boost % from game engine; returns null if game isn't ready
  function readBoostState() {
    try {
      const g = trainingCamp.game; // eslint-disable-line
      const contractors = g.db.contractors;
      const pending = g.getPendingContractors();
      const rate = g.const.contractorMult + g.db.investorMult;
      const currentMult = 1 + contractors * rate;
      const newMult = 1 + (contractors + pending) * rate;
      const boostPct = (newMult / currentMult - 1) * 100;
      const contractorBonus = g.db.contractorBonus || 0;
      return { contractors, pending, boostPct, contractorBonus };
    } catch (e) { return null; }
  }

  // update boost sample buffer; detect prestige resets
  function updateBoostSamples(now, boostPct) {
    if (now - _boostSampleTime >= 30) {
      _boostSamples.push({ t: now, boost: boostPct });
      if (_boostSamples.length > 20) _boostSamples.shift(); // keep ~10min
      _boostSampleTime = now;
      _stallLogged = false;
    }
    // reset after prestige (boost drops sharply)
    if (_boostSamples.length >= 2 && boostPct < _boostSamples[_boostSamples.length - 2].boost * 0.5) {
      // save pre-deploy income for recovery tracking (catches manual deploys too)
      const rec = getIncomeRecovery();
      if (!rec || rec.recoveredAt) {
        // use peak income from this cycle — _lastKnownIncome is already post-reset by now
        savePreDeployIncome(_peakIncome);
      }
      _peakIncome = 0;
      _boostSamples = [{ t: now, boost: boostPct }];
      _stallRatios = [];
      _growthWindow = [];
    }
  }

  // compute growth in last 5 minutes (for stall detection)
  function computeRecentGrowth(now, boostPct) {
    for (let i = 0; i < _boostSamples.length; i++) {
      const age = now - _boostSamples[i].t;
      if (age >= STALL_LOOKBACK_S - 60 && age <= STALL_LOOKBACK_S + 60) {
        return boostPct - _boostSamples[i].boost;
      }
    }
    return -1; // not enough data yet
  }

  // growth rate in %/min — sliding window average over last 30s to smooth discrete
  // pending steps (pending = floor(sqrt(runPP)) causes boost to jump then stay flat)
  function computeGrowthRate(now, boostPct) {
    _growthWindow.push({ t: now, boost: boostPct });
    while (_growthWindow.length > 1 && now - _growthWindow[0].t > 30) _growthWindow.shift();
    if (_growthWindow.length >= 2) {
      const w0 = _growthWindow[0], wN = _growthWindow[_growthWindow.length - 1];
      const wdt = wN.t - w0.t;
      if (wdt >= 5) return Math.max(0, (wN.boost - w0.boost) / wdt * 60);
    }
    return 0;
  }

  // predict time until stall ratio crosses threshold using linear extrapolation
  function predictTimeToStall(now, boostPct, recentGrowth) {
    let timeToStall = Infinity;
    if (boostPct <= 0) {
      if (!_stallLogged) { _stallLogged = true; log('stall prediction: no pending contractors'); }
      return timeToStall;
    }
    if (recentGrowth < 0) {
      if (!_stallLogged && _boostSamples.length > 0) {
        _stallLogged = true;
        const oldestAge = now - _boostSamples[0].t;
        const dataIn = Math.max(0, STALL_LOOKBACK_S - 60 - oldestAge);
        log('stall prediction: collecting samples, data in ' + fmtWait(dataIn)
          + ' (boostSamples=' + _boostSamples.length + ')');
      }
      return timeToStall;
    }
    const ratio = recentGrowth / boostPct;
    // record once per boost sample (avoid duplicates from rapid 100ms purchase ticks)
    const lastStallT = _stallRatios.length > 0 ? _stallRatios[_stallRatios.length - 1].t : 0;
    if (!_stallLogged && now - lastStallT >= 10) {
      _stallRatios.push({ t: now, ratio });
      if (_stallRatios.length > 15) _stallRatios.shift();
    }
    // extrapolate when ratio crosses stall threshold
    if (_stallRatios.length >= 3) {
      const r0 = _stallRatios[0];
      const rM = _stallRatios[Math.floor(_stallRatios.length / 2)];
      const rN = _stallRatios[_stallRatios.length - 1];
      const dt = rN.t - r0.t;
      if (dt > 60 && rN.ratio > STALL_RATIO) {
        const slope = (rN.ratio - r0.ratio) / dt;
        if (slope < 0) {
          // linear: ratio already declining, extrapolate directly
          timeToStall = Math.max(0, (STALL_RATIO - rN.ratio) / slope * 0.7);
        } else if (_stallRatios.length >= 6) {
          // slope still positive but decelerating — fit linear regression to per-sample slopes
          // to predict when slope crosses zero, then estimate ratio decline to threshold
          const slopes = [];
          for (let i = 1; i < _stallRatios.length; i++) {
            const si = _stallRatios[i], sp = _stallRatios[i - 1];
            slopes.push({ t: (si.t + sp.t) / 2, s: (si.ratio - sp.ratio) / (si.t - sp.t) });
          }
          // linear regression: s(t) = a + b*t on the slopes
          let sumT = 0, sumS = 0, sumTS = 0, sumTT = 0;
          for (const p of slopes) { sumT += p.t; sumS += p.s; sumTS += p.t * p.s; sumTT += p.t * p.t; }
          const n = slopes.length;
          const denom = n * sumTT - sumT * sumT;
          if (denom > 0) {
            const b = (n * sumTS - sumT * sumS) / denom; // slope of the slope (should be negative = decelerating)
            const a = (sumS - b * sumT) / n;
            const currentSlope = a + b * now;
            if (b < 0 && currentSlope > 0) {
              // slope is decelerating: predict when it hits zero
              const timeToZeroSlope = -currentSlope / b;
              // integrate ratio from now to peak: ratio += currentSlope*t + 0.5*b*t²
              const peakRatio = rN.ratio + currentSlope * timeToZeroSlope + 0.5 * b * timeToZeroSlope * timeToZeroSlope;
              if (peakRatio > STALL_RATIO) {
                // after peak, slope continues negative at rate b — ratio declines
                const drop = peakRatio - STALL_RATIO;
                const timeFromPeak = Math.sqrt(2 * drop / Math.abs(b));
                timeToStall = (timeToZeroSlope + timeFromPeak) * 0.7;
              }
            }
          }
        }
      }
      if (!_stallLogged) {
        _stallLogged = true;
        const slope = dt > 0 ? (rN.ratio - r0.ratio) / dt : 0;
        log('stall prediction: ratio=' + ratio.toFixed(4) +
          ' samples=' + _stallRatios.length + ' dt=' + dt.toFixed(0) + 's' +
          ' slope=' + (slope * 60).toFixed(5) + '/m' +
          ' timeToStall=' + (isFinite(timeToStall) ? (timeToStall / 60).toFixed(1) + 'm' : 'n/a') +
          (dt <= 60 ? ' (need dt>60s)' : '') +
          (rN.ratio <= STALL_RATIO ? ' (ratio<=' + STALL_RATIO + ', deploy imminent)' : '') +
          (slope >= 0 && !isFinite(timeToStall) ? ' (slope>=0, decel not detected yet)' : ''));
      }
    }
    return timeToStall;
  }

  function getPrestigeInfo() {
    const state = readBoostState();
    if (!state) return null;
    const { contractors, pending, boostPct, contractorBonus } = state;
    const now = Date.now() / 1000;

    updateBoostSamples(now, boostPct);
    const recentGrowth = computeRecentGrowth(now, boostPct);
    const boostGrowthPerMin = computeGrowthRate(now, boostPct);
    const timeToStall = predictTimeToStall(now, boostPct, recentGrowth);

    // deploy decision:
    // 1. first prestige (contractors=0): deploy as soon as game allows
    // 2. stall: last 5min added < STALL_RATIO of total boost (growth has stalled)
    // 3. cap at BOOST_CAP_PCT: safety net if growth sustains and stall never triggers
    let deployNow = false;
    let deployReason = '';
    if (contractors === 0 && pending > 0 && contractorBonus >= 50) {
      deployNow = true;
      deployReason = 'first prestige (contractors=0)';
    } else if (contractorBonus >= 50 && boostPct >= 50) {
      const stalled = recentGrowth >= 0 && recentGrowth < boostPct * STALL_RATIO
        && _stallRatios.length >= 3; // need persistent trend, not just one low reading
      const cappedOut = boostPct >= BOOST_CAP_PCT;
      if (stalled) { deployNow = true; deployReason = 'stalled: recentGrowth=' + recentGrowth.toFixed(2) + ' < ' + (boostPct * STALL_RATIO).toFixed(2); }
      else if (cappedOut) { deployNow = true; deployReason = 'cap: boost=' + boostPct.toFixed(0) + '% >= ' + BOOST_CAP_PCT + '%'; }
    }
    if (deployNow && !_deployDecisionLogged) {
      _deployDecisionLogged = true;
      log('DEPLOY decision: ' + deployReason
        + ' | contractors=' + contractors + ' pending=' + pending
        + ' boost=' + boostPct.toFixed(1) + '% contractorBonus=' + contractorBonus
        + ' recentGrowth=' + (recentGrowth >= 0 ? recentGrowth.toFixed(2) : 'n/a')
        + ' stallRatios=' + _stallRatios.length
        + ' boostSamples=' + _boostSamples.length);
    }
    if (!deployNow) _deployDecisionLogged = false;

    const stallRatio = _stallRatios.length > 0 ? _stallRatios[_stallRatios.length - 1].ratio : -1;
    return { boostPct, deployNow, boostGrowthPerMin, recentGrowth, timeToStall, stallRatio };
  }

  // inject a one-line summary near the income display:
  //   "Xm +Y% | +8.3% → 2h"   — waiting: current boost + countdown to 100% threshold
  //   "Xm +Y% | DEPLOY +167%"  — ready: bright green call-to-action
  // main span stays green; deploy span colour changes to signal state
  function updateTopInfo(ranked, money, income, prestige) {
    const container = document.querySelector('.total-gps');
    if (!container) return;
    let infoEl = document.querySelector('#wc-top-info');
    if (!infoEl) {
      infoEl = document.createElement('div');
      infoEl.id = 'wc-top-info';
      container.style.overflow = 'visible';
      container.appendChild(infoEl);
    }
    // ensure two child spans exist: [0] main info, [1] deploy signal
    if (infoEl.children.length < 2) {
      infoEl.innerHTML = '<span></span><span></span>';
    }
    const mainSpan = infoEl.children[0];
    const deploySpan = infoEl.children[1];

    if (!ranked.length || income <= 0) { mainSpan.textContent = ''; deploySpan.textContent = ''; return; }

    const top = ranked[0];
    let countdown;
    if (top.type === 'combo') {
      const first = top.unit.cost <= top.upgrade.cost ? top.unit : top.upgrade;
      const second = top.unit.cost <= top.upgrade.cost ? top.upgrade : top.unit;
      const wait1 = Math.max(0, (first.cost - money) / income);
      const moneyAfter = Math.max(0, money + income * wait1 - first.cost);
      const incAfter = income + first.gain;
      const wait2 = Math.max(0, (second.cost - moneyAfter) / incAfter);
      countdown = wait1 + wait2;
    } else {
      countdown = Math.max(0, (top.cost - money) / income);
    }

    const pct = top.gain / income * 100;
    const timeStr = countdown <= 0 ? 'ready!' : fmtWait(countdown);
    const pctStr = '+' + (pct >= 10 ? Math.round(pct) : pct.toFixed(1)) + '%';
    mainSpan.textContent = timeStr + ' ' + pctStr;

    const recovery = updateIncomeRecovery(income);
    const recoveryTip = fmtRecoveryTip(recovery, income);

    if (!prestige) { deploySpan.textContent = ''; infoEl.title = recoveryTip.replace(/^\n/, ''); return; }

    const bpct = prestige.boostPct;
    const bpctStr = bpct >= 10 ? Math.round(bpct) + '%' : bpct.toFixed(1) + '%';
    const mainTip = (countdown <= 0
      ? '#1 ranked purchase is affordable now'
      : 'Time until #1 ranked purchase is affordable') +
      ' — buying it grows income by ' + pctStr + '.';
    const growthStr = prestige.boostGrowthPerMin > 0
      ? prestige.boostGrowthPerMin.toFixed(1) + '%/m'
      : 'measuring…';
    const recentStr = prestige.recentGrowth >= 0
      ? '+' + prestige.recentGrowth.toFixed(1) + '% last 5m'
      : 'waiting for data';
    const stallEta = isFinite(prestige.timeToStall)
      ? 'deploy in ~' + fmtWait(prestige.timeToStall)
      : '';
    if (prestige.deployNow) {
      deploySpan.textContent = ' | DEPLOY +' + bpctStr;
      deploySpan.style.color = '#0f0';
      deploySpan.style.fontWeight = 'bold';
      const reason = prestige.boostPct >= BOOST_CAP_PCT ? 'Boost hit +' + BOOST_CAP_PCT + '% cap.'
        : 'Growth stalled — ' + recentStr + ' (< ' + (prestige.boostPct * STALL_RATIO).toFixed(1) + '% threshold).';
      infoEl.title = mainTip +
        '\n' + reason + ' Deploy now for +' + bpctStr + ' boost.' +
        '\nGrowth rate: ' + growthStr +
        recoveryTip;
    } else {
      // time to cap — prefer 5min average (stable) over 30s window (noisy when extrapolated)
      const growthPerMin = prestige.recentGrowth >= 0
        ? prestige.recentGrowth / (STALL_LOOKBACK_S / 60)
        : prestige.boostGrowthPerMin;
      const capEta = growthPerMin > 0 && prestige.boostPct < BOOST_CAP_PCT
        ? (BOOST_CAP_PCT - prestige.boostPct) / growthPerMin * 60
        : 0;
      const etaStr = stallEta ? ' → ' + fmtWait(prestige.timeToStall)
        : capEta > 0 ? ' → <' + fmtWait(capEta)
        : '';
      const isMeasuring = growthStr === 'measuring…';
      deploySpan.textContent = ' | +' + bpctStr + ' ' + growthStr + (isMeasuring ? '' : etaStr);
      deploySpan.style.color = '#fa0'; // amber — not yet
      deploySpan.style.fontWeight = '';
      const threshStr = (prestige.boostPct * STALL_RATIO).toFixed(1) + '%';
      const ratioLabel = Math.round(STALL_RATIO * 100) + '%';
      const capEtaStr = capEta > 0 ? '\nTime to +' + BOOST_CAP_PCT + '% cap (at current rate): ' + fmtWait(capEta) : '';
      infoEl.title = mainTip +
        '\nPrestige boost: +' + bpctStr + ' (growing ' + growthStr + ')' +
        '\n' + recentStr + ' (deploy when 5m growth < ' + threshStr + ', i.e. ' + ratioLabel + ' of boost)' +
        (stallEta ? '\nEstimated ' + stallEta
          : prestige.stallRatio >= 0 ? '\nStall ratio: ' + (prestige.stallRatio * 100).toFixed(1) + '% (deploy when it falls to ' + ratioLabel + ') — still rising, waiting for it to decline'
          : '\nEstimating deploy time… needs ~6 min of data.') +
        capEtaStr +
        '\nSafety cap: deploy at +' + BOOST_CAP_PCT + '% regardless.' +
        recoveryTip;
    }
  }

  // income recovery tracking — save pre-deploy income to localStorage so it survives reloads;
  // once current income >= stored value, record recovery time and keep showing it
  const LS_RECOVERY_KEY = 'wc_incomeRecovery';
  function savePreDeployIncome(income) {
    try {
      localStorage.setItem(LS_RECOVERY_KEY, JSON.stringify({
        income, incomeFmt: fmtNum(income), deployedAt: Date.now(),
      }));
    } catch (e) { /* storage full or blocked */ }
  }
  function getIncomeRecovery() {
    try {
      const raw = localStorage.getItem(LS_RECOVERY_KEY);
      return raw ? JSON.parse(raw) : null;
    } catch (e) { return null; }
  }
  function updateIncomeRecovery(currentIncome) {
    const rec = getIncomeRecovery();
    if (!rec || rec.recoveredAt) return rec; // already recovered or no data
    if (currentIncome >= rec.income) {
      rec.recoveredAt = Date.now();
      rec.recoverySeconds = (rec.recoveredAt - rec.deployedAt) / 1000;
      try { localStorage.setItem(LS_RECOVERY_KEY, JSON.stringify(rec)); } catch (e) { /* */ }
      log('income recovered to ' + rec.incomeFmt + '/s in ' + fmtWait(rec.recoverySeconds));
    }
    return rec;
  }
  function fmtRecoveryTip(rec, currentIncome) {
    if (!rec) return '';
    if (rec.recoveredAt) {
      const t = rec.recoverySeconds > 0 ? fmtWait(rec.recoverySeconds) : '<1s';
      return '\nIncome recovery: ' + rec.incomeFmt + '/s reached in ' + t;
    }
    const elapsed = (Date.now() - rec.deployedAt) / 1000;
    const pct = rec.income > 0 ? Math.min(100, currentIncome / rec.income * 100).toFixed(0) : 0;
    return '\nIncome recovery: ' + pct + '% of ' + rec.incomeFmt + '/s (' + (fmtWait(elapsed) || '<1s') + ' elapsed)';
  }

  // game uses jQuery mousedown delegation on #game_holder with event.which==1 check;
  // native MouseEvent with button:0 (→ which:1) + bubbles:true is the only reliable way
  function triggerBuy(btn) {
    if (!btn) return;
    btn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0, buttons: 1 }));
  }

  // buy an item, switching to 1X mode first for unit_single buys (then restoring MaxOCD)
  function buyItem(item) {
    if (item.type === 'unit_single') {
      setBuyMode(0);
      triggerBuy(item.btn);
      setBuyMode(5);
    } else {
      triggerBuy(item.btn);
    }
  }

  // auto-deploy when stall-detection or +1000% cap fires;
  // three-phase: tick 1 opens deploy building, tick 2 clicks deploy_troops, tick 3 confirms;
  // panel visibility checked via computed display (getBoundingClientRect non-zero even when hidden)
  let deployPanelOpenedAt = 0;
  let _deployActive = false; // sticky flag: once deploy fires, keep retrying phases 2/3
  let _deployRetryId = 0;
  function isDisplayed(el) {
    let node = el;
    while (node && node !== document.body) {
      if (window.getComputedStyle(node).display === 'none') return false;
      node = node.parentElement;
    }
    return true;
  }
  function deployTick() {
    // phase 3: confirmation modal is open — click yes
    const confirmModal = document.querySelector('#confirmation_modal.deploy-conf');
    if (confirmModal && isDisplayed(confirmModal)) {
      const yesBtn = confirmModal.querySelector('.yes');
      if (yesBtn) {
        savePreDeployIncome(getCurrentIncome());
        triggerBuy(yesBtn);
        _deployActive = false;
        clearInterval(_deployRetryId);
        log('auto-deploy confirmed (yes)');
        return;
      }
    }
    // phase 2: deploy window is open — click the deploy button
    const deployWindow = document.querySelector('#cloudInfo .window.deploy');
    if (deployWindow && isDisplayed(deployWindow)) {
      const deployBtn = deployWindow.querySelector('[data-type="deploy_troops"]');
      if (deployBtn) {
        triggerBuy(deployBtn);
        log('auto-deploy clicked DEPLOY TROOPS, waiting for confirm');
        return;
      }
    }
    // user closed the deploy panel — stop retrying (they may not want to deploy)
    if (_deployActive && Date.now() - deployPanelOpenedAt > 2000) {
      _deployActive = false;
      clearInterval(_deployRetryId);
      log('auto-deploy: user closed deploy panel, aborting');
    }
  }
  function autoDeploy(prestige) {
    if (!prestige || !prestige.deployNow) return;
    if (_deployActive) return; // already retrying via interval
    // phase 1: open the deploy window by clicking the building icon
    if (Date.now() - deployPanelOpenedAt < 8000) return;
    const deployBuilding = document.querySelector('div.building.troops > div:first-child')
      || document.querySelector('#trainingCamp > div.trainingCamp_holder > div:nth-child(3) > div:nth-child(1)');
    if (deployBuilding && isDisplayed(deployBuilding)) {
      triggerBuy(deployBuilding);
      deployPanelOpenedAt = Date.now();
      _deployActive = true;
      _deployRetryId = setInterval(deployTick, 500);
      log('opening deploy window via building icon');
      return;
    }
    log('deploy building icon not found');
  }

  // buy trainers (managers) as soon as affordable — a trainer is required for continuous
  // production; without it the unit only produces during its timed cycle, so hiring the
  // trainer is always the right move once we own at least one of that unit
  function autoBuyManager(money) {
    let bought = false;
    document.querySelectorAll('.manager[data-id]').forEach(mgr => {
      // investors cost Gold (.value_icon.gold) — skip them, only hire PP-based trainers
      if (mgr.querySelector('.value_icon.gold')) return;
      const btn = mgr.querySelector('.button');
      if (!btn || btn.classList.contains('disabled')) return;
      const numEl = mgr.querySelector('.number');
      if (!numEl) return;
      const cost = parseNum(numEl.textContent.trim());
      if (cost <= 0 || cost > money) return;
      // only hire if we actually own units of that type
      const dataId = mgr.getAttribute('data-id');
      const unitBtn = document.querySelector('.tents > div .button[unit-id="' + dataId + '"]');
      if (!unitBtn) return;
      const tentDiv = unitBtn.closest('.tents > div');
      const unitNumEl = tentDiv && tentDiv.querySelector('.unitNumber');
      const unitCount = unitNumEl ? parseInt(unitNumEl.textContent.trim(), 10) : 0;
      if (unitCount <= 0) return;
      triggerBuy(btn);
      bought = true;
      log('auto-hire trainer for unit ' + dataId + ', cost=' + fmtNum(cost));
    });
    return bought;
  }

  // buy first unit of any new type as soon as it's affordable — first buys always unlock
  // a new income stream and the milestone (2x) bonus, so they're always worth doing.
  // new units have .cover.partiallyHidden on their tent; the button inside is always
  // disabled while the cover is present — the game uses the cover's own click handler
  // for the first buy, so we trigger that instead of the inner button
  function autoBuyNew(money) {
    let bought = false;
    document.querySelectorAll('.tents > div').forEach(tent => {
      const cover = tent.querySelector('.cover.partiallyHidden');
      if (!cover) return;
      // cost is displayed in the cover teaser, not the inner button
      const costEl = cover.querySelector('.basic-unit-cost');
      if (!costEl) return;
      const cost = parseNum(costEl.textContent.trim());
      if (cost <= 0 || cost > money) return;
      triggerBuy(cover);
      bought = true;
      log('auto-buy new unit type via cover, cost=' + fmtNum(cost));
    });
    return bought;
  }

  // set buy amount to MaxOCD (index 5) on startup so every buy fills to the milestone tier;
  // the game cycles through [1X,10X,100X,MAX,OCD,MaxOCD] on each .buy-amount mousedown
  function initBuyAmount() {
    setBuyMode(5);
  }

  function setBuyMode(targetIndex) {
    const ba = document.querySelector('.buy-amount');
    if (!ba) return;
    try {
      let attempts = 0;
      while (trainingCamp.game.db.buyAmountIndex !== targetIndex && attempts++ < 10) { // eslint-disable-line
        ba.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0, buttons: 1 }));
      }
    } catch (e) { /* trainingCamp not ready yet */ }
  }

  // click idle unit sprites to restart their production cycle — units without a trainer
  // stop after each cycle and show "0.0s"; the .unit element has a direct jQuery mousedown
  // handler (not delegated) so we dispatch straight to it
  function autoClickCycles() {
    document.querySelectorAll('.tents > div').forEach(tent => {
      const unitNumEl = tent.querySelector('.unitNumber');
      if (!unitNumEl || parseInt(unitNumEl.textContent.trim(), 10) <= 0) return;
      const timeEl = tent.querySelector('.time');
      if (!timeEl || timeEl.textContent.trim() !== '0.0s') return;
      const unitEl = tent.querySelector('.unit[cursortype="pointer"]');
      if (!unitEl) return;
      unitEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0, buttons: 1 }));
    });
  }

  // when no continuous income yet (no trainers), buy the cheapest affordable unit
  // to bootstrap production and eventually unlock trainers
  function autoBuyBootstrap(money) {
    const candidates = [];
    document.querySelectorAll('.tents > div').forEach(tent => {
      const buyBtn = tent.querySelector('.button[unit-id]');
      if (!buyBtn || buyBtn.classList.contains('disabled')) return;
      const costEl = buyBtn.querySelector('.value');
      if (!costEl) return;
      const cost = parseNum(costEl.textContent.trim());
      if (cost > 0 && cost <= money) candidates.push({ btn: buyBtn, cost });
    });
    if (!candidates.length) return false;
    candidates.sort((a, b) => a.cost - b.cost);
    triggerBuy(candidates[0].btn);
    log('bootstrap-buy unit cost=' + fmtNum(candidates[0].cost));
    return true;
  }

  // auto-buy #1 when affordable; for combos buy cheaper part first as soon as affordable,
  // then the second part if we still have enough — don't wait for combined cost;
  // unit_single buys use buyItem() which switches to 1X mode before triggering
  function autoBuy(ranked, money, income, deployEta, allItems) {
    const top = ranked[0];
    if (!top) return false;
    if (top.type === 'combo') {
      const first = top.unit.cost <= top.upgrade.cost ? top.unit : top.upgrade;
      const second = first === top.unit ? top.upgrade : top.unit;
      if (first.cost > money) return autoBuyPreDeploy(ranked, money, income, deployEta) || autoBuySnack(ranked, money, income, allItems);
      buyItem(first);
      if (second.cost <= money - first.cost) {
        buyItem(second);
        log('auto-buy combo #1: both parts, cost=' + fmtNum(top.cost));
      } else {
        log('auto-buy combo #1 (first part): cost=' + fmtNum(first.cost));
      }
      return true;
    } else {
      if (top.cost > money) return autoBuyPreDeploy(ranked, money, income, deployEta) || autoBuySnack(ranked, money, income, allItems);
      buyItem(top);
      return true;
    }
  }

  // when #1 won't be affordable before the deploy point, buy the highest-ranked item
  // that IS affordable now — it improves income during the final stretch before prestige
  const PRE_DEPLOY_LEEWAY_S = 60;
  function autoBuyPreDeploy(ranked, money, income, deployEta) {
    if (!isFinite(deployEta) || deployEta <= PRE_DEPLOY_LEEWAY_S || income <= 0) return false;
    const top = ranked[0];
    if (!top) return false;
    const top1Eta = top.type === 'combo'
      ? Math.max(0, (Math.min(top.unit.cost, top.upgrade.cost) - money) / income)
      : Math.max(0, (top.cost - money) / income);
    // only act when #1 is genuinely unreachable before prestige (with leeway)
    if (top1Eta <= deployEta - PRE_DEPLOY_LEEWAY_S) return false;
    // scan rank 2 onwards for the highest-ranked affordable batch item
    // (skip unit_single — trickle-buy in autoBuySnack handles those)
    for (let i = 1; i < ranked.length; i++) {
      const item = ranked[i];
      if (item.type === 'unit_single') continue;
      const firstCost = item.type === 'combo'
        ? Math.min(item.unit.cost, item.upgrade.cost)
        : item.cost;
      if (firstCost > money) continue;
      if (item.type === 'combo') {
        buyItem(item.unit.cost <= item.upgrade.cost ? item.unit : item.upgrade);
      } else {
        buyItem(item);
      }
      log('pre-deploy buy rank #' + (i + 1) + ': ' + item.type + ', cost=' + fmtNum(firstCost));
      return true;
    }
    return false;
  }

  // when we can't afford #1, buy items that won't meaningfully delay reaching it.
  // 1. trickle-buy: if #1 involves a unit type, buy single units of that SAME type —
  //    you're paying toward the same goal but getting income immediately instead of waiting.
  //    strictly better than saving for the whole batch (same total cost, earlier income).
  // 2. other snacks: batch buys/combos with cost < 15s of income OR delay <= 15s;
  //    single-unit buys of OTHER types only when delay <= 0 (must actually help).
  let _trickleCount = 0;
  const SNACK_SECONDS = 15;
  function autoBuySnack(ranked, money, income, allItems) {
    if (income <= 0) return false;
    // trickle-buy: if #1 involves a unit type, buy 1 unit of that same type.
    // uses allItems (not ranked) because singles may not be in the ranking pool
    // when #1 is < 5min away, but trickle-buying toward #1 is always worthwhile.
    const top = ranked[0];
    const topUid = top ? (top.type === 'unit' || top.type === 'unit_single' ? top.uid
      : top.type === 'combo' ? top.unit?.uid : null) : null;
    if (topUid) {
      for (const item of allItems) {
        if (item.type === 'unit_single' && item.uid === topUid && item.cost <= money) {
          buyItem(item);
          _trickleCount = (_trickleCount || 0) + 1;
          // log every 50th trickle buy to avoid spam (runs every 100ms)
          if (_trickleCount % 50 === 1) log('trickle-buy: unit ' + topUid + ', cost=' + fmtNum(item.cost));
          return true;
        }
      }
      _trickleCount = 0; // reset when no trickle buy was possible
    }
    // other snacks
    const costThreshold = income * SNACK_SECONDS;
    for (let i = 1; i < ranked.length; i++) {
      const item = ranked[i];
      const cost = item.type === 'combo' ? Math.min(item.unit.cost, item.upgrade.cost) : item.cost;
      if (cost > money) continue;
      const eligible = item.type === 'unit_single'
        ? (item.delay != null && item.delay <= 0)
        : (cost < costThreshold || (item.delay != null && item.delay <= SNACK_SECONDS));
      if (!eligible) continue;
      if (item.type === 'combo') {
        buyItem(item.unit.cost <= item.upgrade.cost ? item.unit : item.upgrade);
      } else {
        buyItem(item);
      }
      log('auto-buy snack: rank #' + (i + 1) + ' ' + item.type
        + ', cost=' + fmtNum(cost) + ', delay=' + (item.delay != null ? item.delay.toFixed(0) + 's' : 'n/a'));
      return true;
    }
    return false;
  }

  // reschedule at 100 ms after any purchase so post-prestige cheap items are consumed
  // in rapid succession rather than one per 3 s cycle
  const LOOP_FAST_MS = 100;
  const LOOP_IDLE_MS = 3000;
  function mainLoop() {
    const money = getCurrentMoney();
    const income = getCurrentIncome();
    if (income > 0) {
      _lastKnownIncome = income;
      if (income > _peakIncome) _peakIncome = income;
    }

    // force income display — game hides .total-gps with not-available when setting is off
    const gpsEl = document.querySelector('.total-gps');
    if (gpsEl) gpsEl.classList.remove('not-available');

    // manager and new-unit buys run even at income=0 (early game / after prestige)
    let bought = autoBuyManager(money);
    bought = autoBuyNew(money) || bought;

    const unitProd = getUnitProduction();
    const items = collectAll(unitProd);
    if (!items.length) { setTimeout(mainLoop, LOOP_IDLE_MS); return; }

    // no continuous income yet (no trainer) — buy cheapest affordable unit to bootstrap
    if (income <= 0) {
      bought = autoBuyBootstrap(money) || bought;
      setTimeout(mainLoop, bought ? LOOP_FAST_MS : LOOP_IDLE_MS);
      return;
    }

    // rank without singles first to compute raw ETA to #1
    const nonSingles = items.filter(it => it.type !== 'unit_single');
    const baseRanked = simulateOptimal(nonSingles, money, income);
    computeDelays(baseRanked, money, income);
    const baseTop = baseRanked[0];
    const top1Eta = !baseTop ? Infinity
      : baseTop.type === 'combo'
        ? Math.max(0, (Math.min(baseTop.unit.cost, baseTop.upgrade.cost) - money) / income)
        : Math.max(0, (baseTop.cost - money) / income);

    // include singles in ranking only when #1 is 5+ minutes away — no point buying
    // single units if you'll be able to afford the real #1 buy in a few minutes anyway
    const SINGLE_BUY_THRESHOLD_S = 300;
    const ranked = (top1Eta >= SINGLE_BUY_THRESHOLD_S && nonSingles.length < items.length)
      ? (() => { const r = simulateOptimal(items, money, income); computeDelays(r, money, income); return r; })()
      : baseRanked;
    applyVisuals(ranked, money, income);
    cleanStale(ranked);
    const prestige = getPrestigeInfo();
    updateTopInfo(ranked, money, income, prestige);
    const timeToStall = prestige ? prestige.timeToStall : Infinity;
    bought = autoBuy(ranked, money, income, timeToStall, items) || bought;
    autoDeploy(prestige);

    setTimeout(mainLoop, bought ? LOOP_FAST_MS : LOOP_IDLE_MS);
  }
  mainLoop();

  // auto-dismiss startup dialogs that block the game on every page load:
  // 0. kongregate outer page "Play Now" overlay — uses stimulus click handler so needs .click()
  // 1. loading screen (.progress-percent) — shows "Game loaded - click to continue"
  // 2. offline income modal (.window.pp_gain) — closed via cloudInfo.close() or its .x button
  function autoDismissStartup() {
    // kongregate play-now overlay (outer page, not inside iframe)
    const playBtn = document.querySelector('#gtm-play-now-button');
    if (playBtn && playBtn.getBoundingClientRect().width > 0) {
      playBtn.click();
      return;
    }
    // loading/init screen
    const loadEl = document.querySelector('.progress-percent');
    if (loadEl && loadEl.getBoundingClientRect().width > 0) {
      loadEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0, buttons: 1 }));
      return;
    }
    // offline income modal
    const ppGain = document.querySelector('.window.pp_gain');
    if (ppGain && ppGain.getBoundingClientRect().width > 0) {
      try { cloudInfo.close(); } catch (e) { /* not available yet */ } // eslint-disable-line
      const closeBtn = ppGain.querySelector('.x');
      if (closeBtn) closeBtn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0, buttons: 1 }));
    }
    // progress-revert warning dialog (anti-exploit rollback notice) — click OK to dismiss
    const progressRevert = document.querySelector('.window.progress_reverted');
    if (progressRevert && progressRevert.getBoundingClientRect().width > 0) {
      const okBtn = progressRevert.querySelector('.button[data-type="close"]');
      if (okBtn) okBtn.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, button: 0, buttons: 1 }));
    }
    // post-deploy result window — closes automatically after clicking X
    // detected by presence of .post-deploy inside the deploy window
    const deployPostDeploy = document.querySelector('#cloudInfo .window.deploy .post-deploy');
    if (deployPostDeploy) {
      const closeBtn = document.querySelector('#cloudInfo .window.deploy .x[cursortype="pointer"]');
      if (closeBtn) triggerBuy(closeBtn);
    }
  }

  // fast cycle: restart idle unit production every 100ms (runs independently of main loop);
  // 100ms is well below typical cycle times so we catch cycles as soon as they complete
  setInterval(autoClickCycles, 100);

  // dismiss startup dialogs on a fast interval until game is running
  setInterval(autoDismissStartup, 300);

  // ============ WAR ZONE ============

  function isWarZoneActive() {
    const wz = document.querySelector('#warZone');
    return wz && window.getComputedStyle(wz).display !== 'none';
  }

  function getFuelPct() {
    const el = document.querySelector('#warZone .fuel-info .button.fuel');
    if (!el) return 100;
    const v = parseFloat(el.textContent);
    return isNaN(v) ? 100 : v;
  }

  // war zone main loop: attack clicks + time challenge spam + tc dismiss
  setInterval(() => {
    if (!isWarZoneActive()) return;

    // dismiss war zone intro tutorial if it appears
    const wzIntro = document.querySelector('[data-screen="warZone"][data-type="close"]');
    if (wzIntro && isDisplayed(wzIntro)) { triggerBuy(wzIntro); return; }

    // time challenge active — spam click, but cap refuel TCs to avoid overshoot
    const tcClick = document.querySelector('#tcHolder .tc-1-click .bar-text');
    if (tcClick && isDisplayed(tcClick)) {
      if (_isRefuelTc && getFuelPct() >= 35) return; // one refuel's worth is enough
      triggerBuy(tcClick);
      return;
    }
    _isRefuelTc = false; // TC closed, reset

    // time challenge ended — dismiss result screen
    const tcBack = document.querySelector('#tcHolder .tc-1-end .bar-text');
    if (tcBack && isDisplayed(tcBack)) { triggerBuy(tcBack); return; }

    // pvp event: button cycles fight → skip simulation → continue; disabled between transitions
    const pvpBtn = document.querySelector('.pvp-cover .button:not(.disabled)');
    if (pvpBtn && isDisplayed(pvpBtn)) {
      triggerBuy(pvpBtn);
      log('pvp click: ' + pvpBtn.textContent.trim());
      return;
    }

    // boss sacrifice prompt — click SACRIFICE to finish off the boss
    const sacrificeBtn = document.querySelector('#sacrifice .sacrifice');
    if (sacrificeBtn && isDisplayed(sacrificeBtn)) {
      triggerBuy(sacrificeBtn);
      log('boss sacrifice triggered');
      return;
    }

    // main attack click — skip if fuel too low for a single click (costs 0.05% per click)
    if (getFuelPct() < 0.05) return;
    const attack = document.querySelector('#unit .unit-attack');
    if (attack) triggerBuy(attack);
  }, 100);

  // fuel monitor: trigger refuel once when below 0.5% and TC is available;
  // cooldown of 40s (> TC duration ~30s) prevents re-clicking before TC ends;
  // also check timer text is empty — non-empty means button is still on cooldown
  let lastRefuelAt = 0;
  let _isRefuelTc = false;

  // detect refuel button clicks (auto or manual) to flag the TC as refuel-triggered
  function initRefuelDetector() {
    const refuel = document.querySelector('#warZone .refuel .icon.tc');
    if (!refuel || refuel._wcRefuelHooked) return;
    refuel.addEventListener('mousedown', () => { _isRefuelTc = true; }, true);
    refuel._wcRefuelHooked = true;
  }
  setInterval(() => {
    if (!isWarZoneActive()) return;
    initRefuelDetector();
    if (getFuelPct() >= 0.5) return;
    if (document.querySelector('#tcHolder')?.children?.length > 0) return; // TC already open
    if (Date.now() - lastRefuelAt < 40000) return; // cooldown > TC duration (30s)
    const refuel = document.querySelector('#warZone .refuel .icon.tc');
    const refuelTimer = refuel?.querySelector('.timer')?.textContent.trim();
    if (refuel && isDisplayed(refuel)
        && refuel.querySelector('span')?.textContent.trim() === 'REFUEL'
        && !refuelTimer) { // timer empty = truly available, not on cooldown
      triggerBuy(refuel);
      lastRefuelAt = Date.now();
      log('war zone refuel triggered, fuel=' + getFuelPct().toFixed(1) + '%');
    }
  }, 2000);

  // set buy amount to MaxOCD after game is loaded
  setTimeout(initBuyAmount, 1500);
  setTimeout(initBuyAmount, 4000); // retry in case game wasn't ready on first attempt

  // upgrades load lazily (only appear in DOM after panel is opened once);
  // open and immediately close the panel on startup so they're always in ranked/snack
  function initUpgradesPanel() {
    if (document.querySelectorAll('.upgrade[data-id]').length > 0) return; // already loaded
    const btn = document.querySelector('.building.upgrades');
    if (!btn) return;
    triggerBuy(btn); // open panel → populates .upgrade[data-id] elements in DOM
    setTimeout(() => triggerBuy(btn), 400); // close it again
  }
  setTimeout(initUpgradesPanel, 3500);
})();