War Clicks Helper

Optimales Kaufranking, Auto-Kauf, Kriegszone-Automatisierung, Prestige-Berater und Werbeentfernung

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

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

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

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

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

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

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

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

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

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

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

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

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

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