Optimal purchase ranking, auto-buy, war zone automation, prestige advisor, and ad removal for War Clicks
// ==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); })();