Greasy Fork is available in English.
Gym gains calculator, happy jump planner, and logger for Torn City
// ==UserScript==
// @name Torn Gym Planner
// @namespace iSatomi
// @version 3.34
// @description Gym gains calculator, happy jump planner, and logger for Torn City
// @author iSatomi [3580191]
// @license MIT
// @match https://www.torn.com/gym.php*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// =========================================================================
// SHARED CORE — API key, gmFetch, cross-page stat/perk cache
// =========================================================================
// ── Shared persistence (key = 'apiKey' for all modules) ──────────────────
const _load = (k, d) => { const v = GM_getValue(k, null); return v !== null ? v : d; };
const _save = (k, v) => GM_setValue(k, v);
// Cross-page cache helpers — data written on one page, read on another
const cache = {
get: (k) => { try { const v = GM_getValue('aio_'+k, null); return v ? JSON.parse(v) : null; } catch(_){ return null; }},
set: (k, v) => GM_setValue('aio_'+k, JSON.stringify(v)),
};
// Keys: apiKey, battlestats {str,spd,def,dex}, workstats {man,int,end},
// perks_raw (flat array of all perk strings), edu_reduction (number)
// ── Shared gmFetch ────────────────────────────────────────────────────────
function gmFetch(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method:"GET", url, timeout:10000,
onload: r => { try { resolve(JSON.parse(r.responseText)); } catch(e) { reject(e); }},
onerror: () => reject(new Error("Network error")),
ontimeout: () => reject(new Error("Timeout")),
});
});
}
// ── Shared CSS base — all three modules use .t-wrap, .t-sec, .t-row etc ──
// Module-specific styles are injected separately per module.
document.head.insertAdjacentHTML('beforeend', `<style>
.t-wrap{margin:8px 0 12px;background:#181818;border:1px solid #333;border-radius:6px;font-family:Arial,sans-serif;font-size:14px;color:#ccc;overflow:hidden}
.t-hdr{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:linear-gradient(135deg,#222,#1a1a1a);border-bottom:1px solid #2a2a2a;cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent}
.t-hdr:active{background:#2a2a2a}
.t-title{font-size:15px;font-weight:bold;color:#e0e0e0}
.t-tog{font-size:16px;color:#555;transition:transform .2s}
.t-wrap.open .t-tog{transform:rotate(180deg)}
.t-body{display:none;padding:12px}
.t-wrap.open .t-body{display:block}
.t-sec{font-size:10px;font-weight:bold;color:#555;letter-spacing:.08em;text-transform:uppercase;margin:14px 0 6px;padding-bottom:4px;border-bottom:1px solid #252525}
.t-sec:first-child{margin-top:0}
.t-row{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:6px 10px;margin-top:3px;border-radius:4px;background:#1e1e1e}
.t-rl{color:#778;font-size:12px;flex-shrink:0}
.t-rv{color:#dde;font-size:12px;text-align:right}
.t-row.g .t-rv{color:#7abf7a}.t-row.b .t-rv{color:#7a9acc}.t-row.r .t-rv{color:#bf7a7a}.t-row.a .t-rv{color:#bf9f5a}
.t-field{margin-bottom:8px}
.t-field label{display:block;font-size:12px;color:#888;margin-bottom:3px}
.t-field select,.t-field input[type=number],.t-field input[type=password],.t-field input[type=text]{width:100%;padding:8px 10px;background:#222;border:1px solid #383838;border-radius:4px;color:#e0e0e0;font-size:14px;box-sizing:border-box;-webkit-appearance:none;appearance:none}
.t-field select:focus,.t-field input:focus{outline:none;border-color:#555;background:#282828}
.t-sg{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}
.t-sg .t-field{margin-bottom:0}
.t-sg .t-field label{font-size:11px}
.t-btns{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
.t-btn{flex:1;padding:10px 8px;border-radius:4px;border:1px solid #383838;background:#222;color:#ddd;font-size:13px;font-weight:bold;cursor:pointer;text-align:center;-webkit-tap-highlight-color:transparent;transition:background .15s;min-width:60px}
.t-btn:active{background:#2a2a3a}
.t-btn-g{background:#1a2518;border-color:#3a5030;color:#7abf7a}
.t-btn-b{background:#18182a;border-color:#3a3a60;color:#8a8aee}
.t-btn-c{background:#182028;border-color:#304060;color:#6aaade}
.t-status{display:none;margin-top:8px;padding:8px 10px;border-radius:4px;font-size:12px;line-height:1.5;word-break:break-word}
.t-status.ok{display:block;background:#182018;border:1px solid #2a4a2a;color:#7abf7a}
.t-status.err{display:block;background:#201818;border:1px solid #4a2828;color:#bf7a7a}
.t-status.warn{display:block;background:#1e1a10;border:1px solid #4a3a18;color:#bf9f5a}
.t-coll-hdr{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;cursor:pointer;-webkit-tap-highlight-color:transparent;font-size:11px;font-weight:bold;color:#555;letter-spacing:.06em;text-transform:uppercase}
.t-coll-tog{font-size:12px;color:#444;transition:transform .2s}
.t-coll.open .t-coll-tog{transform:rotate(180deg)}
.t-coll-body{display:none;padding:8px 10px 10px}
.t-coll.open .t-coll-body{display:block}
.t-bar{height:6px;background:#1e1e2a;border-radius:3px;margin-top:8px;overflow:hidden}
.t-bar-fill{height:100%;border-radius:3px;background:linear-gradient(90deg,#3a5aa0,#6a8acc)}
</style>`);
// ─────────────────────────────────────────────────────────────────────────
// GYM DATA — hardcoded fallback only. No separate API call needed.
// Gym IDs match the Torn API user?selections=gym response (plain integer).
// Dot values are the actual display values (API stores ×10, we store /10 already).
// ─────────────────────────────────────────────────────────────────────────
// Fallback gym data (API: gain/10, energy from energy_cost)
// Format: { id, name, tier, energy, str, spd, def, dex }
const GYMS_FALLBACK = [
{ id:1, name:"Premier Fitness", tier:"L", energy:5, str:2.0, spd:2.0, def:2.0, dex:2.0 },
{ id:2, name:"Average Joes", tier:"L", energy:5, str:2.4, spd:2.4, def:2.8, dex:2.4 },
{ id:3, name:"Woody's Workout", tier:"L", energy:5, str:2.8, spd:3.2, def:3.0, dex:2.8 },
{ id:4, name:"Beach Bods", tier:"L", energy:5, str:3.2, spd:3.2, def:3.2, dex:0 },
{ id:5, name:"Silver Gym", tier:"L", energy:5, str:3.4, spd:3.6, def:3.4, dex:3.2 },
{ id:6, name:"Pour Femme", tier:"L", energy:5, str:3.4, spd:3.6, def:3.6, dex:3.8 },
{ id:7, name:"Davies Den", tier:"L", energy:5, str:3.7, spd:0, def:3.7, dex:3.7 },
{ id:8, name:"Global Gym", tier:"L", energy:5, str:4.0, spd:4.0, def:4.0, dex:4.0 },
{ id:9, name:"Knuckle Heads", tier:"M", energy:10, str:4.8, spd:4.4, def:4.0, dex:4.2 },
{ id:10, name:"Pioneer Fitness", tier:"M", energy:10, str:4.4, spd:4.6, def:4.8, dex:4.4 },
{ id:11, name:"Anabolic Anomalies", tier:"M", energy:10, str:5.0, spd:4.6, def:5.2, dex:4.6 },
{ id:12, name:"Core", tier:"M", energy:10, str:5.0, spd:5.2, def:5.0, dex:5.0 },
{ id:13, name:"Racing Fitness", tier:"M", energy:10, str:5.0, spd:5.4, def:4.8, dex:5.2 },
{ id:14, name:"Complete Cardio", tier:"M", energy:10, str:5.5, spd:5.8, def:5.5, dex:5.2 },
{ id:15, name:"Legs, Bums and Tums",tier:"M", energy:10, str:0, spd:5.6, def:5.6, dex:5.8 },
{ id:16, name:"Deep Burn", tier:"M", energy:10, str:6.0, spd:6.0, def:6.0, dex:6.0 },
{ id:17, name:"Apollo Gym", tier:"H", energy:10, str:6.0, spd:6.2, def:6.4, dex:6.2 },
{ id:18, name:"Gun Shop", tier:"H", energy:10, str:6.6, spd:6.4, def:6.2, dex:6.2 },
{ id:19, name:"Force Training", tier:"H", energy:10, str:6.4, spd:6.6, def:6.4, dex:6.8 },
{ id:20, name:"Cha Cha's", tier:"H", energy:10, str:6.4, spd:6.4, def:6.8, dex:7.0 },
{ id:21, name:"Atlas", tier:"H", energy:10, str:7.0, spd:6.4, def:6.4, dex:6.6 },
{ id:22, name:"Last Round", tier:"H", energy:10, str:6.8, spd:6.6, def:7.0, dex:6.6 },
{ id:23, name:"The Edge", tier:"H", energy:10, str:6.8, spd:7.0, def:7.0, dex:6.8 },
{ id:24, name:"George's", tier:"H", energy:10, str:7.3, spd:7.3, def:7.3, dex:7.3 },
{ id:25, name:"Balboas Gym", tier:"S", energy:25, str:0, spd:0, def:7.5, dex:7.5 },
{ id:26, name:"Frontline Fitness", tier:"S", energy:25, str:7.5, spd:7.5, def:0, dex:0 },
{ id:27, name:"Gym 3000", tier:"S", energy:50, str:8.0, spd:0, def:0, dex:0 },
{ id:28, name:"Mr. Isoyamas", tier:"S", energy:50, str:0, spd:0, def:8.0, dex:0 },
{ id:29, name:"Total Rebound", tier:"S", energy:50, str:0, spd:8.0, def:0, dex:0 },
{ id:30, name:"Elites", tier:"S", energy:50, str:0, spd:0, def:0, dex:8.0 },
{ id:31, name:"Sports Science Lab", tier:"S", energy:25, str:9.0, spd:9.0, def:9.0, dex:9.0 },
{ id:32, name:"The Jail Gym", tier:"J", energy:5, str:3.4, spd:3.4, def:4.6, dex:0 },
];
// Runtime gym list — starts as fallback, gets replaced by API data
// One-time cache migration: clear old key if present
if (GM_getValue("gymDataCache", null)) { GM_deleteValue("gymDataCache"); GM_deleteValue("gymDataCacheTs"); }
let GYMS = GYMS_FALLBACK.slice();
// Maps: id → gym, name.lower → gym
let GYM_BY_ID = {};
let GYM_BY_NAME = {};
function buildGymMaps() {
GYM_BY_ID = {};
GYM_BY_NAME = {};
for (const g of GYMS) {
GYM_BY_ID[g.id] = g;
GYM_BY_NAME[g.name.toLowerCase()] = g;
}
// Debug: log any gym with very high dots (> Sports Science Lab 9.0)
const suspicious = GYMS.filter(g => Math.max(g.str||0,g.spd||0,g.def||0,g.dex||0) > 9.5);
if (suspicious.length) {
console.warn('[AIO Debug] Gyms with >9.5 dots:', JSON.stringify(suspicious));
}
}
buildGymMaps();
// ─────────────────────────────────────────────────────────────────────────
// ALL-STATS STORE — populated by fetchFromAPI, used for specialist eligibility
// ─────────────────────────────────────────────────────────────────────────
let allStats = { strength:0, speed:0, defense:0, dexterity:0 };
// Restore battle stats from cross-page cache if available
(function() {
const bs = cache.get('battlestats');
if (bs) { allStats = bs; }
})();
// Set of gym IDs the user has actually unlocked (purchased membership).
// Populated by scraping the gym page DOM — zero API calls needed.
// Falls back to null (unknown) if DOM scrape fails.
// Standard gyms unlock sequentially — if user is at gym N, they've unlocked all standard
// gyms up to and including N. Specialist gyms require separate purchase.
// STANDARD_UNLOCK_ORDER maps gym id → sequential position (1=first gym, 24=George's)
const STANDARD_UNLOCK_ORDER = {
1:1, 2:2, 3:3, 4:4, 5:5, 6:6, 7:7, 8:8, // L tier
9:9, 10:10, 11:11, 12:12, 13:13, 14:14, 15:15, 16:16, // M tier
17:17, 18:18, 19:19, 20:20, 21:21, 22:22, 23:23, 24:24, // H tier
32:0, // Jail gym — always accessible in jail
};
// Specialist gym IDs (purchased separately, not in the sequential chain)
const SPECIALIST_IDS = new Set([25, 26, 27, 28, 29, 30, 31]);
// Specialist unlock requirements — stat ratio conditions
const SPECIALIST_REQS = {
25: { // Balboas Gym — DEF+DEX ≥25% > STR+SPD, needs Cha Cha's (id 20) unlocked
prereqId: 20,
check: s => (s.defense + s.dexterity) >= (s.strength + s.speed) * 1.25,
desc: "(DEF+DEX) must be ≥25% more than (STR+SPD) · Needs Cha Cha's",
},
26: { // Frontline Fitness — STR+SPD ≥25% > DEF+DEX, needs Cha Cha's
prereqId: 20,
check: s => (s.strength + s.speed) >= (s.defense + s.dexterity) * 1.25,
desc: "(STR+SPD) must be ≥25% more than (DEF+DEX) · Needs Cha Cha's",
},
27: { // Gym 3000 — STR ≥25% > 2nd highest, needs George's (id 24)
prereqId: 24,
check: s => { const rest = [s.speed,s.defense,s.dexterity].sort((a,b)=>b-a); return s.strength >= rest[0]*1.25; },
desc: "STR must be ≥25% above 2nd highest stat · Needs George's",
},
28: { // Mr. Isoyamas — DEF ≥25% > 2nd highest, needs George's
prereqId: 24,
check: s => { const rest = [s.strength,s.speed,s.dexterity].sort((a,b)=>b-a); return s.defense >= rest[0]*1.25; },
desc: "DEF must be ≥25% above 2nd highest stat · Needs George's",
},
29: { // Total Rebound — SPD ≥25% > 2nd highest, needs George's
prereqId: 24,
check: s => { const rest = [s.strength,s.defense,s.dexterity].sort((a,b)=>b-a); return s.speed >= rest[0]*1.25; },
desc: "SPD must be ≥25% above 2nd highest stat · Needs George's",
},
30: { // Elites — DEX ≥25% > 2nd highest, needs George's
prereqId: 24,
check: s => { const rest = [s.strength,s.speed,s.defense].sort((a,b)=>b-a); return s.dexterity >= rest[0]*1.25; },
desc: "DEX must be ≥25% above 2nd highest stat · Needs George's",
},
31: { // Sports Science Lab — needs Last Round (id 22), drug-limited
prereqId: 22,
check: () => true,
desc: "Needs Last Round unlocked · ≤50 Xanax and ≤50 Ecstasy lifetime",
drugLimited: true,
},
};
// Check if a gym is accessible given what we know about unlocks + stats
// Returns { state: 'unlocked'|'ratio'|'prereq', reason, purchased }
// Rank all gyms for a given stat — split into unlocked, purchasable (ratio met), and locked
// ─────────────────────────────────────────────────────────────────────────
// GYM DATA — fetched from Torn API (torn/?selections=gyms) on autofill,
// cached 24h. Fallback table used only if API hasn't been called yet.
// The fallback DEX column is approximate — API values are authoritative.
// ─────────────────────────────────────────────────────────────────────────
async function loadGymsFromAPI(key) {
if (!key || key.length !== 16) return false;
// Use cache if fresh (24h)
const cacheTs = _load("gymDataCacheTs_v2", 0);
const cacheData = _load("gymDataCache_v2", null);
if (cacheData && (Date.now() - cacheTs) < 86400000) {
try {
const cached = JSON.parse(cacheData);
if (cached?.length > 20) {
GYMS = cached;
buildGymMaps();
rebuildGymDropdown();
return true;
}
} catch(_) { /* stale/corrupt cache */ }
}
try {
const data = await gmFetch(`https://api.torn.com/torn/?selections=gyms&key=${key}&comment=GymGains`);
if (data?.error) return false;
if (!data?.gyms) return false;
// API returns: { "13": { id:13, name:"Racing Fitness", energy_cost:10,
// strength:50, speed:54, defense:48, dexterity:52 }, ... }
// Gains stored as *10 in API, divide by 10 to get actual dots
// Build from API data directly — don't filter to fallback only,
// so new gyms added to Torn are included automatically
const fallbackById = {};
GYMS_FALLBACK.forEach(g => { fallbackById[g.id] = g; });
const updated = Object.entries(data.gyms).map(([idStr, apiGym]) => {
const id = parseInt(idStr);
const fallback = fallbackById[id];
// Determine tier from fallback if known, else guess from energy cost
const energy = apiGym.energy_cost ?? fallback?.energy ?? 10;
const tier = fallback?.tier ?? (energy >= 50 ? 'S' : energy >= 25 ? 'S' : energy >= 10 ? 'H' : 'L');
return {
id,
name: apiGym.name || fallback?.name || `Gym ${id}`,
tier,
energy,
str: apiGym.strength != null ? apiGym.strength / 10 : (fallback?.str ?? 0),
spd: apiGym.speed != null ? apiGym.speed / 10 : (fallback?.spd ?? 0),
def: apiGym.defense != null ? apiGym.defense / 10 : (fallback?.def ?? 0),
dex: apiGym.dexterity != null ? apiGym.dexterity / 10 : (fallback?.dex ?? 0),
};
}).filter(g => {
if (!(g.str || g.spd || g.def || g.dex)) return false; // trains nothing
// Skip gyms with suspiciously high dots that we can't identify
// (likely API data artifacts or test gyms) — cap at 10 dots
const maxDots = Math.max(g.str||0, g.spd||0, g.def||0, g.dex||0);
if (maxDots > 10) { console.log('[AIO] Skipping implausible gym:', g); return false; }
return true;
});
// Jail gym (id:32) reports inflated stats (10.0 dots) from API — always use fallback
const jailFallback = GYMS_FALLBACK.find(g => g.id === 32);
const jailIdx = updated.findIndex(g => g.id === 32);
if (jailIdx >= 0) updated[jailIdx] = jailFallback; // replace API version with fallback
else if (jailFallback) updated.push(jailFallback); // add if missing
updated.sort((a, b) => a.id - b.id);
GYMS = updated;
buildGymMaps();
rebuildGymDropdown();
_save("gymDataCache_v2", JSON.stringify(GYMS));
_save("gymDataCacheTs_v2", Date.now());
return true;
} catch(_) {
return false;
}
}
// ─────────────────────────────────────────────────────────────────────────
// CONSTANTS
// ─────────────────────────────────────────────────────────────────────────
const STAT_KEYS = { strength:"str", speed:"spd", defense:"def", dexterity:"dex" };
const STAT_LABELS = { strength:"Strength", speed:"Speed", defense:"Defense", dexterity:"Dexterity" };
const STAT_ABBREV = { strength:"STR", speed:"SPD", defense:"DEF", dexterity:"DEX" };
// Vladar's formula constants per stat (post stat-cap-removal, still uses S in formula uncapped)
// dS = (S*ROUND(1+0.07*ROUND(LN(1+H/250),4),4) + 8*H^1.05 + (1-(H/99999)^2)*A + B)
// * (1/200000) * G * E * perks
// Note: The 50M cap in the formula term was removed Aug 2022 — use actual stat value
const STAT_CONSTS = {
strength: { A:1600, B:1700, C:700 },
speed: { A:1600, B:2000, C:1350 },
dexterity: { A:1800, B:1500, C:1000 },
defense: { A:2100, B:-600, C:1500 },
};
const NATURAL_E = { no:20, yes:30 };
const E_CAP = { no:100, yes:150 };
const MAX_ITER = 10000;
const XAN_CD_AVG = 7, XAN_CD_MIN = 6, XAN_CD_MAX = 8;
const EDVD_CD = 6, EDVD_HAPPY = 2500;
const FHC_HAPPY = 500; // Feathery Hotel Coupon: refills energy + +500 happy
const FHC_CD = 6; // hours of booster CD per FHC
const FHC_MAX = 5; // max FHCs in 30h CD window
const ITEM_IDS = { xanax:206, edvd:540, ecstasy:203, fhc:367, lsd:415 };
// ── Tuning constants ─────────────────────────────────────────────────────
const FORMULA_CORRECTION = 1.027; // empirical correction from 34 logged sessions
const LOG_CLICK_GATE_MS = 8000; // window after TRAIN click during which we accept a log
const LOG_DEDUPE_MS = 30000; // suppress duplicate logs within this window
const OUTLIER_THRESHOLD = 0.50; // entries with >50% prediction error excluded from calibration
const JUMP_HAPPY_RATIO = 1.5; // happy / propHappy ratio that classifies a session as a jump
// Energy can types — IDs resolved at runtime via Torn API items lookup
// Name must exactly match the Torn item name (after "Can of " prefix)
const CAN_TYPES = [
{ label:"Goose Juice", name:"Can of Goose Juice", e:5, id:1, cd:2 },
{ label:"Damp Valley", name:"Can of Damp Valley", e:10, id:68, cd:2 },
{ label:"Crocozade", name:"Can of Crocozade", e:15, id:69, cd:2 },
{ label:"Munster", name:"Can of Munster", e:20, id:372, cd:2 },
{ label:"Santa Shooters", name:"Can of Santa Shooters", e:20, id:1020, cd:2 },
{ label:"Red Cow", name:"Can of Red Cow", e:25, id:607, cd:2 },
{ label:"Rockstar Rudolph",name:"Can of Rockstar Rudolph",e:25, id:1021, cd:2 },
{ label:"Taurine Elite", name:"Can of Taurine Elite", e:30, id:967, cd:2 },
];
// Candy types — all give 30min booster CD each, max 48/jump
// Grouped: 25-happy (cheapest/shoplift), 35-happy (big choc), 50-happy (Crimes 2.0)
const CANDY_TYPES = [
{ label:"Bag of Candy Kisses", happy:50, name:"Bag of Candy Kisses", id:616 },
{ label:"Jawbreaker", happy:50, name:"Jawbreaker", id:617 },
{ label:"Pixie Sticks", happy:50, name:"Pixie Sticks", id:618 },
{ label:"Big Box of Chocolates", happy:35, name:"Big Box of Chocolate Bars",id:207 },
{ label:"Box of Chocolates", happy:25, name:"Box of Chocolate Bars", id:208 },
{ label:"Lollipop", happy:25, name:"Lollipop", id:621 },
{ label:"Bag of Bon Bons", happy:25, name:"Bag of Bon Bons", id:619 },
{ label:"Box of Mints", happy:25, name:"Box of Extra Strong Mints",id:620 },
];
const CANDY_CD_HRS = 0.5; // 30min = 0.5h per candy
// Live prices fetched for all cans, stored here
let canPrices = {}; // { canLabel: price }
let candyPrices = {}; // { candyLabel: price }
let allItemPrices = {}; // { xanax, edvd, ecstasy, fhc, lsd, + canLabel, candyLabel: price }
const PRICE_CACHE_KEY = 'aio_priceCache';
const PRICE_CACHE_TTL = 60 * 60 * 1000; // 1 hour
// Resolve can item IDs from Torn API (cached in GM storage for 7 days)
async function resolveCanIDs(apiKey) {
const CACHE_KEY = "canItemIds", CACHE_TS = "canItemIdsTs";
const cached = _load(CACHE_KEY, null), ts = _load(CACHE_TS, 0);
if (cached && (Date.now()-ts) < 7*86400000) {
try {
const ids = JSON.parse(cached);
CAN_TYPES.forEach(c => { if (ids[c.name]) c.id = ids[c.name]; });
CANDY_TYPES.forEach(c => { if (ids[c.name]) c.id = ids[c.name]; });
return;
} catch(_) {}
}
const key = (_load("apiKey","") || "").trim();
if (!key || key.length !== 16) return;
try {
const data = await gmFetch("https://api.torn.com/torn/?selections=items&key="+key+"&comment=AIOIDs");
if (data?.error || !data?.items) return;
const idMap = {};
for (const [id, item] of Object.entries(data.items)) {
// Match by name only — Torn API types vary (cans="Energy Drink", candy="Drug")
if (CAN_TYPES.some(c=>c.name===item.name) || CANDY_TYPES.some(c=>c.name===item.name))
idMap[item.name] = parseInt(id);
}
CAN_TYPES.forEach(c => { if (idMap[c.name]) c.id = idMap[c.name]; });
CANDY_TYPES.forEach(c => { if (idMap[c.name]) c.id = idMap[c.name]; });
_save(CACHE_KEY, JSON.stringify(idMap)); _save(CACHE_TS, Date.now());
} catch(_) {}
}
const GG_DEFAULTS = {
gymId:"18", stat:"strength", subscriber:"no",
happy:"4525", statTotal:"0", statGoal:"300000", calcMode:"daily",
factionPerk:"0", propertyPerk:"0", eduStatPerk:"0", eduGenPerk:"0",
jobPerk:"0", bookPerk:"0", sportsSneakers:"0", steroids:"0",
energy:"10", dailyRefill:"no", dailyRefillCost:"1725000",
hjXanaxCount:"4", hjEDVDs:"5", hjANJob:"no", hjEcstasy:"yes", hjRefill:"yes",
hjBaseHappy:"0", hjVoracity:"0",
hjXanaxCost:"880000", hjEDVDCost:"2500000", hjEcstasyCost:"100000",
hjXanaxOD:"3", hjEcstasyOD:"5", hjToleration:"0", hjNightclub:"no",
hjFHC:"0", hjFHCCost:"12000000", hjFHCHappy:"500",
hjLSD:"0", hjLSDCost:"500000",
hjCans:"0", hjCanType:"0", hjCanCost:"1650000", hjCanFactionPerk:"0",
hjCandies:"0", hjCandyType:"0", hjCandyCost:"500000",
hjCandyVoracity:"0", hjCandyAbsorption:"no",
xgXanaxCount:"1", xgXanaxCost:"880000",
xgRefill:"no", xgRefillCost:"1725000",
};
const gg_load = k => _load(k, GG_DEFAULTS[k]);
const gg_save = (k, v) => _save(k, v);
const STYLES = `
.gg-wrap{margin:8px 0 12px;background:#181818;border:1px solid #333;border-radius:6px;font-family:Arial,sans-serif;font-size:14px;color:#ccc;overflow:hidden}
.gg-header{display:flex;align-items:center;justify-content:space-between;padding:10px 14px;background:linear-gradient(135deg,#242424,#1c1c1c);border-bottom:1px solid #2a2a2a;cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-tap-highlight-color:transparent}
.gg-header:hover{background:linear-gradient(135deg,#2c2c2c,#222)}
.gg-title{font-size:15px;font-weight:bold;color:#e0e0e0}
.gg-toggle{font-size:16px;color:#555;transition:transform .2s}
.gg-wrap.open .gg-toggle{transform:rotate(180deg)}
.gg-body{display:none;padding:12px}
.gg-wrap.open .gg-body{display:block}
.gg-sec{font-size:10px;font-weight:bold;color:#556;letter-spacing:.08em;text-transform:uppercase;margin:14px 0 6px;padding-bottom:4px;border-bottom:1px solid #252525}
.gg-sec:first-child{margin-top:0}
.gg-field{margin-bottom:8px}
.gg-field label{display:block;font-size:12px;color:#888;margin-bottom:3px}
.gg-field select,.gg-field input[type=number],.gg-field input[type=password]{width:100%;padding:8px 10px;background:#222;border:1px solid #383838;border-radius:4px;color:#e0e0e0;font-size:14px;box-sizing:border-box;-webkit-appearance:none;appearance:none}
.gg-field select:focus,.gg-field input:focus{outline:none;border-color:#555;background:#282828}
.gg-sg{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.gg-sg .gg-field{margin-bottom:0}
.gg-sg .gg-field label{font-size:11px}
.gg-api-row{display:flex;gap:6px;align-items:stretch}
.gg-api-row input{flex:1}
.gg-api-btn{flex-shrink:0;padding:8px 12px;border-radius:4px;border:1px solid #3a5030;background:#1a2518;color:#7abf7a;font-size:12px;font-weight:bold;cursor:pointer;-webkit-tap-highlight-color:transparent}
.gg-api-btn:hover{background:#20301e}
.gg-tab-row{display:flex;gap:4px}
.gg-tab{flex:1;padding:9px 6px;border-radius:4px;border:1px solid #383838;background:#222;color:#888;font-size:13px;font-weight:bold;cursor:pointer;text-align:center;-webkit-tap-highlight-color:transparent;transition:all .15s}
.gg-tab.active{background:#18182a;border-color:#303058;color:#7a7acc}
.gg-tab:hover{background:#2a2a2a}
.gg-cfg-btn{padding:9px 11px;border-radius:4px;border:1px solid #383838;background:#222;color:#556;font-size:13px;cursor:pointer;-webkit-tap-highlight-color:transparent;transition:all .15s;flex-shrink:0}
.gg-cfg-btn.active{border-color:#303058;color:#7a7acc;background:#181828}
.gg-cfg-btn:hover{background:#2a2a2a}
.gg-section{display:none}
.gg-section.active{display:block}
.gg-btn-row{display:flex;gap:6px;margin-top:10px;flex-wrap:wrap}
.gg-btn{flex:1;padding:10px 8px;border-radius:4px;border:1px solid #383838;background:#222;color:#ddd;font-size:13px;font-weight:bold;cursor:pointer;text-align:center;-webkit-tap-highlight-color:transparent;transition:background .15s;min-width:60px}
.gg-btn:hover,.gg-btn:active{background:#2a2a2a}
.gg-btn-fill{background:#1a2518;border-color:#3a5030;color:#7abf7a}
.gg-btn-fill:hover,.gg-btn-fill:active{background:#20301e}
.gg-btn-calc{background:#18182a;border-color:#303058;color:#7a7acc}
.gg-btn-calc:hover,.gg-btn-calc:active{background:#20203a}
.gg-btn-compare{background:#1a1a18;border-color:#3a3818;color:#aaaa5a;flex:0 0 auto;padding:10px 12px;font-size:12px}
.gg-btn-compare:hover,.gg-btn-compare:active{background:#26261a}
.gg-btn-copy{background:#1a1a2a;border-color:#2a2a48;color:#5a5a88;font-size:12px;flex:0 0 auto;padding:10px 12px}
.gg-status{display:none;margin-top:8px;padding:8px 10px;border-radius:4px;font-size:12px;line-height:1.5;word-break:break-word}
.gg-status.ok{display:block;background:#182018;border:1px solid #2a4a2a;color:#7abf7a}
.gg-status.warn{display:block;background:#201e10;border:1px solid #4a3a18;color:#bf9f5a}
.gg-row{display:flex;justify-content:space-between;align-items:baseline;gap:8px;padding:6px 10px;margin-top:3px;border-radius:4px;background:#1e1e1e}
.gg-row .gg-rl{color:#888;font-size:12px;flex-shrink:0}
.gg-row .gg-rv{color:#ddd;font-size:13px;text-align:right;word-break:break-word}
.gg-row.hi .gg-rv{color:#7abf7a}
.gg-row.g .gg-rv{color:#7abf7a}
.gg-row.a .gg-rv{color:#bf9f5a}
.gg-row.b .gg-rv{color:#7a7acc}
.gg-collapsible{margin-top:8px;background:#1a1a1a;border:1px solid #2a2a2a;border-radius:4px}
.gg-collapsible-header{display:flex;align-items:center;justify-content:space-between;padding:8px 10px;cursor:pointer;-webkit-tap-highlight-color:transparent;font-size:11px;font-weight:bold;color:#556;letter-spacing:.06em;text-transform:uppercase}
.gg-collapsible-header:hover{color:#888}
.gg-collapsible-toggle{font-size:12px;color:#444;transition:transform .2s}
.gg-collapsible.open .gg-collapsible-toggle{transform:rotate(180deg)}
.gg-collapsible-body{display:none;padding:8px 10px 10px}
.gg-collapsible.open .gg-collapsible-body{display:block}
.gg-results{margin-top:10px}
.gg-tldr{background:#0e1a1e;border:1px solid #1a3a4a;border-radius:5px;padding:12px 14px;margin-bottom:10px}
.gg-tldr-title{font-size:11px;font-weight:bold;color:#4a8aaa;letter-spacing:.06em;text-transform:uppercase;margin-bottom:10px}
.gg-tldr-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.gg-tldr-cell{background:#141e24;border:1px solid #1e3040;border-radius:3px;padding:8px 6px;text-align:center}
.gg-tldr-val{font-size:16px;font-weight:bold;color:#7abf7a;line-height:1.2}
.gg-tldr-lbl{font-size:10px;color:#446;margin-top:3px;text-transform:uppercase;letter-spacing:.04em}
.gg-rsec{font-size:10px;font-weight:bold;color:#445;letter-spacing:.08em;text-transform:uppercase;margin:12px 0 4px;padding-bottom:3px;border-bottom:1px solid #222}
.gg-rsec:first-child{margin-top:4px}
.gg-price-badge{font-size:10px;font-weight:normal;color:#4a7a4a;margin-left:4px}
.gg-price-badge.loading{color:#555}
.gg-price-badge.err{color:#7a4a4a}
.gg-cmp{margin-top:10px}
.gg-cmp-title{font-size:12px;font-weight:bold;color:#aaaa5a;letter-spacing:.05em;text-transform:uppercase;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid #2a2a1a}
.gg-cmp-grid{display:grid;grid-template-columns:minmax(0,1.1fr) repeat(3,minmax(0,1fr));gap:0;width:100%;table-layout:fixed}
.gg-cmp-hdr{font-size:10px;font-weight:bold;color:#555;padding:5px 4px;background:#1a1a1a;border-bottom:1px solid #252525;text-align:center;word-break:break-word;line-height:1.3}
.gg-cmp-hdr:first-child{text-align:left;color:#444}
.gg-cmp-hdr.daily{color:#7a7acc}
.gg-cmp-hdr.jump{color:#aaaa5a}
.gg-cmp-label{font-size:11px;color:#666;padding:5px 4px;background:#1c1c1c;border-bottom:1px solid #222;word-break:break-word;line-height:1.3}
.gg-cmp-val{font-size:11px;color:#bbb;padding:5px 4px;background:#1e1e1e;border-bottom:1px solid #222;text-align:right;word-break:break-all;line-height:1.3}
.gg-cmp-val.win{color:#7abf7a;font-weight:bold}
.gg-cmp-val.lose{color:#555}
.gg-cmp-verdict{margin-top:12px;padding:10px 12px;border-radius:4px;font-size:12px;line-height:1.7;background:#141a10;border:1px solid #2a3a20;color:#8abf7a}
.gg-bestgym{margin-top:8px;background:#141414;border:1px solid #252525;border-radius:5px;overflow:hidden}
.gg-bestgym-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:#1a1a1a;border-bottom:1px solid #252525;cursor:pointer;-webkit-tap-highlight-color:transparent;user-select:none}
.gg-bestgym-title{font-size:11px;font-weight:bold;color:#aaaa5a;letter-spacing:.06em;text-transform:uppercase}
.gg-bestgym-toggle{font-size:12px;color:#555;transition:transform .2s}
.gg-bestgym.open .gg-bestgym-toggle{transform:rotate(180deg)}
.gg-bestgym-body{display:none;padding:8px 10px 10px}
.gg-bestgym.open .gg-bestgym-body{display:block}
.gg-gym-row{display:flex;align-items:center;gap:8px;padding:5px 8px;margin-top:3px;border-radius:3px;background:#1c1c1c;cursor:pointer;-webkit-tap-highlight-color:transparent}
.gg-gym-row:hover{background:#222}
.gg-gym-row.current{background:#18222a;border:1px solid #2a3a4a}
.gg-gym-row.best{background:#181e14;border:1px solid #2a3a20}
.gg-gym-rank{font-size:11px;color:#555;width:16px;flex-shrink:0;text-align:center}
.gg-gym-name{font-size:12px;color:#ccc;flex:1}
.gg-gym-name.current{color:#7aafcc}
.gg-gym-name.best{color:#7abf7a}
.gg-gym-dots-val{font-size:12px;font-weight:bold;color:#7abf7a;flex-shrink:0}
.gg-gym-dots-val.dim{color:#666}
.gg-gym-e{font-size:10px;color:#555;flex-shrink:0}
.gg-gym-use-btn{font-size:10px;padding:2px 6px;border-radius:3px;border:1px solid #2a4a2a;background:#182018;color:#6a9f6a;cursor:pointer;flex-shrink:0;-webkit-tap-highlight-color:transparent}
.gg-gym-use-btn:hover{background:#1e2a1e}
.gg-bestgym-sec{font-size:10px;color:#446;margin:8px 0 3px;letter-spacing:.05em;text-transform:uppercase}
.gg-dots{display:flex;gap:3px;align-items:center;margin-top:4px;font-size:11px;color:#556}
.gg-dot{width:10px;height:10px;border-radius:50%;background:#2a2a2a;border:1px solid #333;display:inline-block}
.gg-dot.on{background:#7abf7a;border-color:#5a9f5a}
.gg-dot.half{background:#bf9f5a;border-color:#9f7f3a}
.gg-dot.off{background:#1e1e1e;border-color:#2a2a2a}
.gg-dot-val{margin-left:4px;color:#7abf7a;font-weight:bold}
.gg-dot-na{color:#555;font-style:italic;margin-left:4px}
.gg-field input.invalid,.gg-field select.invalid{border-color:#7a3030!important;background:#201818!important}
.gg-field input.warn-input,.gg-field select.warn-input{border-color:#6a5020!important}
.gg-val-msg{font-size:10px;color:#bf5a5a;margin-top:2px;display:none}
.gg-val-msg.show{display:block}
.gg-infobar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:8px;padding:6px 8px;background:#141414;border:1px solid #252525;border-radius:4px;font-size:11px;color:#556}
.gg-infobar-item{display:flex;align-items:center;gap:4px}
.gg-infobar-val{color:#888;font-weight:bold}
.gg-infobar-val.hi{color:#7abf7a}
.gg-last-updated{font-size:10px;color:#444;margin-left:auto;white-space:nowrap}
.gg-cfg-panel{margin-top:8px;padding:10px 12px;background:#141414;border:1px solid #252525;border-radius:5px}
`;
function buildGymOptions() {
const placeholder = `<option value="" disabled>— select gym —</option>`;
return placeholder + GYMS.map(g => {
const label = g.tier === "J" ? g.name : `[${g.tier}] ${g.name}`;
return `<option value="${g.id}">${label}</option>`;
}).join('');
}
function buildHTML() {
const statOpts = Object.keys(STAT_KEYS).map(s =>
`<option value="${s}">${STAT_LABELS[s]}</option>`).join('');
const perkSel = id =>
`<select id="gg-${id}">${Array.from({length:101},(_,i)=>
`<option value="${i}">${i}%</option>`).join('')}</select>`;
return `
<div class="gg-header">
<span class="gg-title">🏋️ Gym Gains Calculator</span>
<span class="gg-toggle">▼</span>
</div>
<div class="gg-body">
<!-- info bar shown after autofill -->
<div id="gg-infobar" class="gg-infobar" style="display:none">
<div class="gg-infobar-item">⚡ <span class="gg-infobar-val" id="gg-info-energy">—</span></div>
<div class="gg-infobar-item">😊 <span class="gg-infobar-val" id="gg-info-happy">—</span></div>
<div class="gg-infobar-item" id="gg-info-sub-wrap" style="display:none">
🎫 <span class="gg-infobar-val hi" id="gg-info-sub">Subscriber</span>
</div>
<div class="gg-last-updated" id="gg-last-updated"></div>
</div>
<!-- ── ONBOARDING — visible until first successful autofill ────────── -->
<div id="gg-onboard" style="margin:8px 0 12px;padding:10px 12px;background:#16201f;border:1px solid #2a4040;border-radius:5px;display:none">
<div style="font-size:13px;color:#9aaaaa;font-weight:bold;margin-bottom:4px">👋 Welcome — quick setup</div>
<div style="font-size:11px;color:#778;line-height:1.6">
1. Add your Torn API key in <strong>⚙ Settings</strong> below<br>
2. Tap <strong>⟳ Auto-fill</strong> to pull your stats, perks, and prices<br>
3. Tell the script your current energy → tap <strong>Calculate</strong>
</div>
</div>
<!-- ── STEP 1: WHAT TO TRAIN ─────────────────────────── -->
<div class="gg-sec">1. What to train</div>
<div class="gg-sg">
<div class="gg-field">
<label>Gym <span id="gg-gym-badge" style="font-size:10px;color:#4a7a4a"></span></label>
<select id="gg-gym">${buildGymOptions()}</select>
<div class="gg-dots" id="gg-dots-display"></div>
</div>
<div class="gg-field">
<label style="font-weight:bold;color:#9a9acc">Stat</label>
<select id="gg-stat" style="font-size:15px;font-weight:bold">${statOpts}</select>
</div>
</div>
<div class="gg-bestgym" id="gg-bestgym-panel">
<div class="gg-bestgym-header">
<span class="gg-bestgym-title">⭐ Best Gym for Stat</span>
<span class="gg-bestgym-toggle">▼</span>
</div>
<div class="gg-bestgym-body" id="gg-bestgym-body">
<div style="font-size:11px;color:#446;padding:4px 0 2px">Auto-fill to check specialist gym eligibility.</div>
</div>
</div>
<!-- ── STEP 2: YOUR PROFILE (one-time config) ─────────── -->
<div class="gg-collapsible" id="gg-profile-panel" style="margin-top:12px">
<div class="gg-collapsible-header">
<span style="color:#9a9aaa">2. Your profile</span>
<span style="font-size:10px;color:#556;margin-left:6px" id="gg-profile-summary">— set once, applies to all sessions</span>
<span class="gg-collapsible-toggle">▼</span>
</div>
<div class="gg-collapsible-body">
<div style="font-size:11px;color:#556;margin-bottom:8px;line-height:1.5">
Auto-fill pulls these from the API. Only edit if needed.
</div>
<div class="gg-sg">
<div class="gg-field">
<label>Current stat <span id="gg-autofill-badge" style="font-size:10px;color:#4a7a4a;display:none">✓ auto</span></label>
<input type="number" id="gg-statTotal" min="0" placeholder="e.g. 120,000">
<div class="gg-val-msg" id="gg-val-stat">Enter your current stat</div>
</div>
<div class="gg-field">
<label>Your goal <span id="gg-goal-progress" style="font-size:10px;color:#556"></span></label>
<input type="number" id="gg-statGoal" min="0" placeholder="e.g. 600,000">
<div class="gg-val-msg" id="gg-val-goal">Goal must be higher than current</div>
</div>
</div>
<div class="gg-sg">
<div class="gg-field">
<label>Your max happy</label>
<input type="number" id="gg-happy" min="0" placeholder="e.g. 5025">
<div class="gg-val-msg" id="gg-val-happy">From your property — auto-filled</div>
</div>
<div class="gg-field">
<label>Subscriber?</label>
<select id="gg-subscriber">
<option value="no">No (100E cap)</option>
<option value="yes">Yes (150E cap)</option>
</select>
</div>
</div>
</div>
</div>
<!-- ── STEP 3: THIS SESSION ─────────────────────────── -->
<div class="gg-sec" style="margin-top:14px">3. What you have right now</div>
<div style="font-size:11px;color:#556;margin-bottom:8px;line-height:1.5">
Tell the script what energy and items are available, then tap Calculate.
</div>
<div class="gg-sg">
<div class="gg-field">
<label>Current energy ⚡ <span id="gg-energy-badge" style="font-size:10px;color:#4a7a4a"></span></label>
<input type="number" id="gg-energy" min="0" placeholder="e.g. 150">
</div>
<div class="gg-field">
<label>Drug ready?</label>
<select id="gg-session-drug">
<option value="none">None — no drug CD</option>
<option value="xanax">Xanax (+250E)</option>
<option value="lsd">LSD (+50E)</option>
</select>
</div>
</div>
<div class="gg-sg">
<div class="gg-field">
<label>Use Points Refill?</label>
<select id="gg-session-refill">
<option value="no">No</option>
<option value="yes">Yes (+150E, ~$1.7m)</option>
</select>
</div>
<div class="gg-field">
<label>Happy right now 😊</label>
<input type="number" id="gg-session-happy" min="0" placeholder="blank = your max">
</div>
</div>
<!-- ── PRIMARY ACTION — big, obvious, hard to miss ─── -->
<button class="gg-btn gg-btn-calc" id="gg-calc" style="width:100%;font-size:15px;padding:13px;margin-top:10px;font-weight:bold">
Calculate this session →
</button>
<!-- ── SECONDARY ACTIONS — smaller, less prominent ─── -->
<div class="gg-btn-row" style="margin-top:8px;gap:6px">
<button class="gg-btn gg-btn-fill" id="gg-autofill" style="flex:1;font-size:12px">⟳ Auto-fill</button>
<button class="gg-btn gg-btn-compare" id="gg-compare" style="flex:1;font-size:12px">⚖ Compare</button>
</div>
<div class="gg-btn-row" style="margin-top:6px;gap:6px">
<button class="gg-btn" id="gg-tab-plan" style="flex:1;font-size:11px;color:#bf9f5a;border-color:#3a3018;background:#1a1808">📋 Long-term plan</button>
<button class="gg-btn" id="gg-tab-bonuses-btn" style="flex:1;font-size:11px;color:#7a6a9a;border-color:#2a2038;background:#181620">🎖 Perks & bonuses</button>
</div>
<button class="gg-btn gg-btn-copy" id="gg-copy" style="width:100%;margin-top:6px;font-size:11px;display:none">📋 Copy result</button>
<!-- ── BONUSES PANEL ── -->
<div class="gg-section" id="gg-bonuses-inputs">
<div class="gg-cfg-panel">
<div class="gg-sec" style="margin-top:0">🎖 Perks & Training Bonuses</div>
<div style="font-size:11px;color:#556;margin-bottom:10px;line-height:1.5">
These boost gym gains. Auto-filled from your perks. <span id="gg-perks-badge" style="color:#4a7a4a"></span>
</div>
<div class="gg-sg">
<div class="gg-field"><label>Faction Gym</label>${perkSel('factionPerk')}</div>
<div class="gg-field"><label>Property Gym</label>${perkSel('propertyPerk')}</div>
<div class="gg-field"><label>Education (this stat)</label>${perkSel('eduStatPerk')}</div>
<div class="gg-field"><label>Education (all stats)</label>${perkSel('eduGenPerk')}</div>
<div class="gg-field"><label>Job Bonus</label>${perkSel('jobPerk')}</div>
<div class="gg-field"><label>Book Bonus</label>${perkSel('bookPerk')}</div>
<div class="gg-field">
<label>Steroids</label>
<select id="gg-steroids"><option value="0">None</option><option value="20">+20%</option></select>
</div>
<div class="gg-field">
<label>Sports Sneakers (SPD)</label>
<select id="gg-sportsSneakers"><option value="0">None</option><option value="5">+5%</option></select>
</div>
</div>
<div id="gg-bonus-summary" style="margin-top:10px;padding:8px 10px;background:#141e14;border:1px solid #253025;border-radius:4px;font-size:11px;color:#6a9a6a;display:none"></div>
</div>
</div>
<!-- ── PLAN AHEAD PANEL ── -->
<div class="gg-section" id="gg-plan-inputs">
<div class="gg-cfg-panel">
<div class="gg-sec" style="margin-top:0">📋 Long-term Plan — How long to reach your goal?</div>
<div style="font-size:11px;color:#556;margin-bottom:10px;line-height:1.5">
Pick how you'll be training day-to-day. The script will project days until you hit your goal.
</div>
<div class="gg-field">
<label>I'll be training with…</label>
<select id="gg-plan-method">
<option value="daily">📅 Just natural energy regen</option>
<option value="xanax">💊 Regen + Xanax every CD</option>
<option value="jump">⚡ Happy Jumps</option>
</select>
</div>
<div id="gg-plan-xanax-opts" style="display:none;margin-top:8px">
<div class="gg-sg">
<div class="gg-field">
<label>Xanax per day</label>
<select id="gg-xgXanaxCount">
<option value="1">1/day (~24h CD)</option>
<option value="2">2/day (~12h CD)</option>
<option value="3" selected>3/day (~8h CD) ← typical</option>
<option value="4">4/day (6h CD)</option>
</select>
</div>
<div class="gg-field">
<label>Xanax cost <span id="gg-xg-xanax-price" class="gg-price-badge"></span></label>
<input type="number" id="gg-xgXanaxCost" min="0" placeholder="e.g. 860000">
</div>
</div>
<div class="gg-sg">
<div class="gg-field">
<label>Points Refill/day</label>
<select id="gg-xgRefill"><option value="no">No</option><option value="yes">Yes</option></select>
</div>
<div class="gg-field">
<label>Refill cost ($)</label>
<input type="number" id="gg-xgRefillCost" min="0" placeholder="1725000">
</div>
</div>
</div>
<div id="gg-plan-daily-opts" style="margin-top:8px">
<div class="gg-sg">
<div class="gg-field">
<label>Points Refill/day</label>
<select id="gg-dailyRefill"><option value="no">No</option><option value="yes">Yes</option></select>
</div>
<div class="gg-field">
<label>Refill cost ($)</label>
<input type="number" id="gg-dailyRefillCost" min="0" placeholder="1725000">
</div>
</div>
</div>
<div id="gg-plan-jump-opts" style="display:none;margin-top:8px">
<div class="gg-sec" style="margin-top:0;font-size:10px">Energy Sources</div>
<div class="gg-sg">
<div class="gg-field">
<label>Xanax</label>
<select id="gg-hjXanaxCount">
<option value="0">None</option>
<option value="1">1 (~400E)</option>
<option value="2">2 (~650E)</option>
<option value="3">3 (~900E)</option>
<option value="4">4 (1000E)</option>
</select>
</div>
<div class="gg-field">
<label>LSD (50E, no OD)</label>
<select id="gg-hjLSD">
${Array.from({length:6},(_,i)=>`<option value="${i}">${i===0?'None':i+' (+'+i*50+'E)'}</option>`).join('')}
</select>
</div>
</div>
<div class="gg-sg">
<div class="gg-field">
<label>FHC</label>
<select id="gg-hjFHC">${Array.from({length:6},(_,i)=>`<option value="${i}">${i===0?'None':i+' FHC (+'+i*FHC_HAPPY+' happy)'}</option>`).join('')}</select>
</div>
<div class="gg-field">
<label>Points Refill after</label>
<select id="gg-hjRefill"><option value="yes">Yes</option><option value="no">No</option></select>
</div>
</div>
<div class="gg-sg">
<div class="gg-field">
<label>Energy Cans</label>
<select id="gg-hjCanType">
<option value="0">None</option>
${CAN_TYPES.map((c,i)=>`<option value="${i+1}">${c.e}E — ${c.label}</option>`).join('')}
</select>
</div>
<div class="gg-field">
<label>Number of Cans</label>
<input type="number" id="gg-hjCans" min="0" max="50" placeholder="0">
</div>
</div>
<div class="gg-sg">
<div class="gg-field">
<label>Faction Can Bonus</label>
<select id="gg-hjCanFactionPerk">
${[0,10,20,30,40,50].map(v=>`<option value="${v}">${v===0?'None':'+'+v+'%'}</option>`).join('')}
</select>
</div>
<div class="gg-field">
<label>Voracity hrs</label>
<input type="number" id="gg-hjVoracity" min="0" max="24" placeholder="0">
</div>
</div>
<div class="gg-sec" style="font-size:10px">Happy Boosters</div>
<div class="gg-sg">
<div class="gg-field">
<label>eDVDs per jump</label>
<input type="number" id="gg-hjEDVDs" min="0" max="12" placeholder="0">
</div>
<div class="gg-field">
<label>Ecstasy</label>
<select id="gg-hjEcstasy"><option value="yes">Yes (x2)</option><option value="no">No</option></select>
</div>
</div>
<div class="gg-sg">
<div class="gg-field">
<label>10star Adult Novelties</label>
<select id="gg-hjANJob"><option value="no">No</option><option value="yes">Yes (x2 eDVD)</option></select>
</div>
<div class="gg-field">
<label>Candy type</label>
<select id="gg-hjCandyType">
<option value="0">None</option>
${CANDY_TYPES.map((c,i)=>`<option value="${i+1}">${c.happy}★ — ${c.label}</option>`).join('')}
</select>
</div>
</div>
<div class="gg-sg">
<div class="gg-field">
<label>Candies</label>
<input type="number" id="gg-hjCandies" min="0" max="48" placeholder="0">
</div>
<div class="gg-field">
<label>Candy Voracity</label>
<select id="gg-hjCandyVoracity">${[0,5,10,15,20,25,30,35,40,45,50].map(v=>`<option value="${v}">${v===0?'None':'+'+v+'%'}</option>`).join('')}</select>
</div>
</div>
<div class="gg-sg">
<div class="gg-field">
<label>7star Grocery Absorption</label>
<select id="gg-hjCandyAbsorption"><option value="no">No</option><option value="yes">Yes (+10%)</option></select>
</div>
<div class="gg-field">
<label>Happy before boosters</label>
<input type="number" id="gg-hjBaseHappy" min="0" placeholder="0 = prop max">
</div>
</div>
<div class="gg-collapsible" id="gg-od-panel">
<div class="gg-collapsible-header">OD Risk Settings <span class="gg-collapsible-toggle">v</span></div>
<div class="gg-collapsible-body">
<div style="font-size:11px;color:#665533;margin-bottom:8px">Community estimates only.</div>
<div class="gg-sg">
<div class="gg-field"><label>Xanax OD %</label><input type="number" id="gg-hjXanaxOD" min="0" max="100" step="0.1"></div>
<div class="gg-field"><label>Ecstasy OD %</label><input type="number" id="gg-hjEcstasyOD" min="0" max="100" step="0.1"></div>
</div>
<div class="gg-sg">
<div class="gg-field">
<label>Faction Toleration</label>
<select id="gg-hjToleration">${Array.from({length:11},(_,i)=>`<option value="${i*3}">${i*3}%</option>`).join('')}</select>
</div>
<div class="gg-field">
<label>7star Nightclub (-50% OD)</label>
<select id="gg-hjNightclub"><option value="no">No</option><option value="yes">Yes</option></select>
</div>
</div>
</div>
</div>
</div>
<div class="gg-btn-row" style="margin-top:12px">
<button class="gg-btn gg-btn-calc" id="gg-plan-calc" style="flex:1">Calculate Plan</button>
</div>
</div>
</div>
<!-- ── STATUS + RESULTS ────────────────────────────── -->
<div class="gg-status" id="gg-status"></div>
<div class="gg-results" id="gg-daily-results" style="display:none"></div>
<div class="gg-results" id="gg-jump-results" style="display:none"></div>
<div class="gg-results" id="gg-compare-results" style="display:none"></div>
<!-- ── ITEM COSTS & PRICES ────────────────────────── -->
<div class="gg-collapsible" id="gg-item-info-panel" style="margin-top:10px">
<div class="gg-collapsible-header">💰 Item Costs & Prices <span class="gg-collapsible-toggle">▼</span></div>
<div class="gg-collapsible-body">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
<span style="font-size:11px;color:#556">Enter costs manually or fetch live.</span>
<button class="gg-btn gg-btn-fill" id="gg-fetch-prices" style="font-size:11px;padding:5px 12px;flex:none">⟳ Live Prices</button>
</div>
<div class="gg-sg">
<div class="gg-field"><label>Xanax <span id="gg-xanax-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjXanaxCost" min="0" placeholder="$"></div>
<div class="gg-field"><label>FHC <span id="gg-fhc-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjFHCCost" min="0" placeholder="$"></div>
</div>
<div class="gg-sg">
<div class="gg-field"><label>eDVD <span id="gg-edvd-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjEDVDCost" min="0" placeholder="$"></div>
<div class="gg-field"><label>Ecstasy <span id="gg-ecstasy-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjEcstasyCost" min="0" placeholder="$"></div>
</div>
<div class="gg-sg">
<div class="gg-field"><label>LSD <span id="gg-lsd-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjLSDCost" min="0" placeholder="$"></div>
<div class="gg-field"><label>Energy Can <span id="gg-can-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjCanCost" min="0" placeholder="$"></div>
</div>
<div class="gg-sg">
<div class="gg-field"><label>Candy <span id="gg-candy-price" class="gg-price-badge"></span></label><input type="number" id="gg-hjCandyCost" min="0" placeholder="$"></div>
</div>
<div id="gg-price-breakdown" style="margin-top:8px"></div>
</div>
</div>
<!-- ── SETTINGS & API KEY ─────────────────────────── -->
<div class="gg-collapsible" id="gg-settings-panel" style="margin-top:8px">
<div class="gg-collapsible-header">⚙ Settings & API Key <span class="gg-collapsible-toggle">▼</span></div>
<div class="gg-collapsible-body">
<button class="gg-btn gg-btn-fill" id="gg-autofill-settings" style="width:100%;margin-bottom:10px">⟳ Auto-fill from API</button>
<div class="gg-field">
<label>Torn API Key <span style="font-size:10px;color:#556">— stored locally only</span></label>
<div class="gg-api-row">
<input type="password" id="gg-apikey" placeholder="Paste 16-character key…" autocomplete="off">
<button class="gg-api-btn" id="gg-apikey-save">Save</button>
</div>
<div style="margin-top:6px;padding:6px 10px;background:#0e0e0e;border:1px solid #252525;border-radius:4px;font-size:11px;line-height:1.7;color:#556">
Needs <strong style="color:#668">Normal Access</strong> (gym, perks, stats).<br>
<a href="https://www.torn.com/preferences.php#tab=api?step=addNewKey&title=AIO+Planner&type=3" target="_blank" style="color:#4a7aaa;text-decoration:none">🔑 Auto-create key →</a>
<span style="margin-left:8px;color:#444">Prices via weav3r.dev (no key needed)</span>
</div>
</div>
<div class="gg-sec">Cache</div>
<div class="gg-sg">
<button class="gg-btn" id="gg-copy-logs" style="background:#1a1a2a;border-color:#2a2a48;color:#7a7acc;font-size:12px">Copy Logs</button>
<button class="gg-btn" id="gg-clear-cache" style="background:#2a1a1a;border-color:#4a2a2a;color:#bf7a7a;font-size:12px">Clear Cache</button>
</div>
<div id="gg-debug-status" style="font-size:10px;color:#445;margin-top:4px;display:none"></div>
</div>
</div>
<!-- ── GAINS LOGGER ───────────────────────────────── -->
<div class="gg-collapsible" id="gg-logger-panel" style="margin-top:8px">
<div class="gg-collapsible-header">
📓 Gains Logger <span id="gg-logger-badge" style="font-size:10px;color:#4a7a4a;margin-left:4px"></span>
<span class="gg-collapsible-toggle">▼</span>
</div>
<div class="gg-collapsible-body" id="gg-logger-body">
<!-- Auto-log toggle -->
<div id="gg-autolog-row" style="display:flex;align-items:center;gap:8px;padding:8px 10px;background:#141414;border:1px solid #252525;border-radius:4px;margin-bottom:10px;cursor:pointer">
<span id="gg-autolog-dot" style="display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0"></span>
<div style="flex:1">
<div id="gg-autolog-lbl" style="font-size:12px;font-weight:bold"></div>
<div style="font-size:10px;color:#445;margin-top:1px">Watches for train results — logs every gym session automatically</div>
</div>
<span style="font-size:10px;color:#333;flex-shrink:0">tap</span>
</div>
<!-- Manual entry -->
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<div style="font-size:10px;font-weight:bold;color:#445;text-transform:uppercase;letter-spacing:.06em">Manual Entry</div>
<button id="gg-log-autofill" style="background:#182028;border:1px solid #2a3a50;color:#6a9acc;border-radius:4px;padding:4px 10px;font-size:11px;cursor:pointer;-webkit-tap-highlight-color:transparent">⚡ Fill from page</button>
</div>
<div class="gg-sg">
<div class="gg-field"><label>Actual Gain</label><input type="number" id="gg-log-gain" placeholder="e.g. 31355" min="0"></div>
<div class="gg-field"><label>Energy Used</label><input type="number" id="gg-log-energy" placeholder="e.g. 1000" min="0"></div>
</div>
<div class="gg-sg">
<div class="gg-field"><label>Pre-train Stat</label><input type="number" id="gg-log-stat" placeholder="stat before" min="0"></div>
<div class="gg-field"><label>Happy at Train</label><input type="number" id="gg-log-happy" placeholder="e.g. 35050" min="0"></div>
</div>
<button class="gg-btn gg-btn-fill" id="gg-log-add" style="width:100%;margin-bottom:12px">+ Log Entry</button>
<!-- Log table -->
<div id="gg-log-entries"></div>
<!-- Calibration -->
<div id="gg-log-calibration"></div>
<!-- Actions -->
<div class="gg-sg" style="margin-top:8px">
<button class="gg-btn" id="gg-log-copy" style="background:#182028;border-color:#2a3a50;color:#6a9acc;font-size:12px">📋 Copy Log</button>
<button class="gg-btn" id="gg-log-clear" style="background:#1a1010;border-color:#301818;color:#aa5a5a;font-size:12px">🗑 Clear Log</button>
</div>
<div id="gg-log-copy-status" style="display:none;margin-top:6px;font-size:11px;color:#7abf7a;padding:6px 8px;background:#141a14;border:1px solid #253025;border-radius:4px"></div>
</div>
</div>
</div>`;
}
// ─────────────────────────────────────────────────────────────────────────
// MOUNT
// ─────────────────────────────────────────────────────────────────────────
const styleEl = document.createElement("style");
styleEl.textContent = STYLES;
document.head.appendChild(styleEl);
const wrap = document.createElement("div");
wrap.className = "gg-wrap";
try {
wrap.innerHTML = buildHTML();
} catch (e) {
// If HTML render fails, show a visible error so the user knows something's wrong
wrap.innerHTML = `<div style="background:#2a1010;border:2px solid #802020;color:#f88;padding:12px;font-family:monospace;font-size:12px;border-radius:4px;margin:8px;line-height:1.5">
<strong style="color:#f44">⚠ Gym Planner: HTML render failed</strong><br>
${e.message?.replace(/[<>]/g, '') || 'Unknown error'}<br>
<small style="color:#a66">Please report this with a screenshot.</small>
</div>`;
console.error('[GymPlanner] buildHTML failed:', e);
}
const mt = document.querySelector('.content-wrapper') || document.body;
mt.insertBefore(wrap, mt.firstChild);
const q = sel => wrap.querySelector(sel);
let gymConfirmed = false;
function rebuildGymDropdown() {
const sel = q("#gg-gym"); if (!sel) return;
const cur = sel.value;
sel.innerHTML = buildGymOptions();
if (cur && GYMS.some(g => String(g.id) === cur)) { setGymSelect(sel, cur); }
else { const rd = detectGymFromDOM(); if (rd) { setGymSelect(sel, String(rd)); gymConfirmed = true; if (el.gymBadge) el.gymBadge.textContent = "✓ page"; } }
if (typeof updateDotsDisplay === 'function') updateDotsDisplay();
}
function setGymSelect(sel, idStr) {
sel.value = idStr;
for (const opt of sel.options) { if (opt.value === idStr) { opt.selected = true; break; } }
}
const el = {
gym: q("#gg-gym"), gymBadge: q("#gg-gym-badge"),
stat: q("#gg-stat"), subscriber: q("#gg-subscriber"),
statTotal: q("#gg-statTotal"), statGoal: q("#gg-statGoal"),
happy: q("#gg-happy"),
factionPerk: q("#gg-factionPerk"), propertyPerk: q("#gg-propertyPerk"),
eduStatPerk: q("#gg-eduStatPerk"), eduGenPerk: q("#gg-eduGenPerk"),
jobPerk: q("#gg-jobPerk"), bookPerk: q("#gg-bookPerk"),
steroids: q("#gg-steroids"), sportsSneakers: q("#gg-sportsSneakers"),
energy: q("#gg-energy"), dailyRefill: q("#gg-dailyRefill"),
dailyRefillCost: q("#gg-dailyRefillCost"),
hjBaseHappy: q("#gg-hjBaseHappy"),
hjXanaxCount: q("#gg-hjXanaxCount"), hjEDVDs: q("#gg-hjEDVDs"),
hjANJob: q("#gg-hjANJob"), hjEcstasy: q("#gg-hjEcstasy"),
hjRefill: q("#gg-hjRefill"), hjVoracity: q("#gg-hjVoracity"),
hjFHC: q("#gg-hjFHC"), hjFHCCost: q("#gg-hjFHCCost"),
hjLSD: q("#gg-hjLSD"), hjLSDCost: q("#gg-hjLSDCost"),
hjCans: q("#gg-hjCans"), hjCanType: q("#gg-hjCanType"),
hjCanCost: q("#gg-hjCanCost"), hjCanFactionPerk: q("#gg-hjCanFactionPerk"),
hjCandies: q("#gg-hjCandies"), hjCandyType: q("#gg-hjCandyType"),
hjCandyCost: q("#gg-hjCandyCost"), hjCandyVoracity:q("#gg-hjCandyVoracity"),
hjCandyAbsorption:q("#gg-hjCandyAbsorption"),
candyBadge: q("#gg-candy-price"),
hjXanaxCost: q("#gg-hjXanaxCost"), hjEDVDCost: q("#gg-hjEDVDCost"),
hjEcstasyCost: q("#gg-hjEcstasyCost"), fhcBadge: q("#gg-fhc-price"),
lsdBadge: q("#gg-lsd-price"), canBadge: q("#gg-can-price"),
hjXanaxOD: q("#gg-hjXanaxOD"), hjEcstasyOD: q("#gg-hjEcstasyOD"),
hjToleration: q("#gg-hjToleration"), hjNightclub: q("#gg-hjNightclub"),
xanaxBadge: q("#gg-xanax-price"), edvdBadge: q("#gg-edvd-price"),
ecstasyBadge: q("#gg-ecstasy-price"), fetchPricesBtn: q("#gg-fetch-prices"),
autofill: q("#gg-autofill"), calc: q("#gg-calc"),
sessionDrug: q("#gg-session-drug"), sessionRefill: q("#gg-session-refill"),
sessionHappy: q("#gg-session-happy"),
compare: q("#gg-compare"), copy: q("#gg-copy"),
perksBadge: q("#gg-perks-badge"), status: q("#gg-status"),
dailyResults: q("#gg-daily-results"), jumpResults: q("#gg-jump-results"),
compareResults: q("#gg-compare-results"), autofillBadge: q("#gg-autofill-badge"),
energyBadge: q("#gg-energy-badge"),
apikey: q("#gg-apikey"), apikeyBtn: q("#gg-apikey-save"),
dotsDisplay: q("#gg-dots-display"), bestGymPanel: q("#gg-bestgym-panel"),
bestGymBody: q("#gg-bestgym-body"), infobar: q("#gg-infobar"),
infoEnergy: q("#gg-info-energy"), infoHappy: q("#gg-info-happy"),
infoSubWrap: q("#gg-info-sub-wrap"), infoSub: q("#gg-info-sub"),
lastUpdated: q("#gg-last-updated"), valStat: q("#gg-val-stat"),
valGoal: q("#gg-val-goal"), valHappy: q("#gg-val-happy"),
xgXanaxCount: q("#gg-xgXanaxCount"), xgXanaxCost: q("#gg-xgXanaxCost"),
xgRefill: q("#gg-xgRefill"), xgRefillCost: q("#gg-xgRefillCost"),
};
// ── Restore all saved inputs ──────────────────────────────────────────────
const EL_MAP = {
stat:"stat", subscriber:"subscriber", happy:"happy",
statTotal:"statTotal", statGoal:"statGoal",
factionPerk:"factionPerk", propertyPerk:"propertyPerk",
eduStatPerk:"eduStatPerk", eduGenPerk:"eduGenPerk",
jobPerk:"jobPerk", bookPerk:"bookPerk",
steroids:"steroids", sportsSneakers:"sportsSneakers",
energy:"energy", dailyRefill:"dailyRefill", dailyRefillCost:"dailyRefillCost",
hjXanaxCount:"hjXanaxCount", hjEDVDs:"hjEDVDs", hjANJob:"hjANJob",
hjEcstasy:"hjEcstasy", hjRefill:"hjRefill", hjVoracity:"hjVoracity",
hjFHC:"hjFHC", hjFHCCost:"hjFHCCost",
hjLSD:"hjLSD", hjLSDCost:"hjLSDCost",
hjCans:"hjCans", hjCanType:"hjCanType", hjCanCost:"hjCanCost",
hjCanFactionPerk:"hjCanFactionPerk",
hjCandies:"hjCandies", hjCandyType:"hjCandyType", hjCandyCost:"hjCandyCost",
hjCandyVoracity:"hjCandyVoracity", hjCandyAbsorption:"hjCandyAbsorption",
hjXanaxCost:"hjXanaxCost", hjEDVDCost:"hjEDVDCost", hjEcstasyCost:"hjEcstasyCost",
hjXanaxOD:"hjXanaxOD", hjEcstasyOD:"hjEcstasyOD",
hjToleration:"hjToleration", hjNightclub:"hjNightclub",
xgXanaxCount:"xgXanaxCount", xgXanaxCost:"xgXanaxCost",
xgRefill:"xgRefill", xgRefillCost:"xgRefillCost",
};
Object.entries(EL_MAP).forEach(([k, ek]) => {
if (!el[ek]) return;
el[ek].value = gg_load(k);
el[ek].addEventListener("change", () => {
gg_save(k, el[ek].value);
if (k === "statTotal" || k === "statGoal") updateGoalProgress();
});
});
updateGoalProgress();
// ── Header collapse ───────────────────────────────────────────────────────
wrap.querySelector(".gg-header").addEventListener("click", () => {
gg_save("ggCollapsed", wrap.classList.toggle("open") ? "no" : "yes");
});
if (gg_load("ggCollapsed") !== "yes") wrap.classList.add("open");
// Show onboarding banner if user has never auto-filled successfully
if (!_load("hasAutofilled", "")) {
const ob = q("#gg-onboard");
if (ob) ob.style.display = "block";
} else {
// Returning user — also collapse the profile panel by default
// (they've already set it up). Show it if they want to edit.
// Profile collapsible starts closed by default, that's fine.
}
// ── API key (in settings panel collapsible) ───────────────────────────────
const savedKey = (_load("apiKey","") || "").trim();
el.apikey.value = savedKey.length === 16 ? savedKey : "";
el.apikey.placeholder = savedKey.length === 16 ? "Key saved ✓" : "Paste 16-character key…";
el.apikeyBtn.addEventListener("click", () => {
const k = el.apikey.value.trim();
if (k.length !== 16) { showStatus("warn", `⚠ Key must be 16 characters (got ${k.length}).`); return; }
_save("apiKey", k);
el.apikey.placeholder = "Key saved ✓";
el.apikey.value = "";
showStatus("ok", "✓ Key saved. Click Auto-fill to load your data.");
loadGymsFromAPI(k);
});
q("#gg-autofill-settings")?.addEventListener("click", autofill);
// ── Panel visibility ──────────────────────────────────────────────────────
// Single primary screen (gym/stat/session inputs) with two overlay panels:
// 🎖 Bonuses & Perks, and 📋 Plan ahead. Only one shows at a time.
// New UX: no tabs, just overlay panels toggled by buttons
let activePanel = null; // "bonuses" | "plan" | null
function showPanel(name) {
activePanel = activePanel === name ? null : name;
q("#gg-bonuses-inputs")?.classList.toggle("active", activePanel === "bonuses");
q("#gg-plan-inputs")?.classList.toggle("active", activePanel === "plan");
// Highlight active button
q("#gg-tab-bonuses-btn")?.classList.toggle("active", activePanel === "bonuses");
q("#gg-tab-plan")?.classList.toggle("active", activePanel === "plan");
if (activePanel === "bonuses") updateBonusSummary();
if (activePanel === "plan") updatePlanMethodOpts();
}
q("#gg-tab-bonuses-btn")?.addEventListener("click", () => showPanel("bonuses"));
q("#gg-tab-plan")?.addEventListener("click", () => showPanel("plan"));
// Plan method sub-options toggle
function updatePlanMethodOpts() {
const m = q("#gg-plan-method")?.value || "daily";
q("#gg-plan-xanax-opts") && (q("#gg-plan-xanax-opts").style.display = m==="xanax" ? "" : "none");
q("#gg-plan-daily-opts") && (q("#gg-plan-daily-opts").style.display = m==="daily" ? "" : "none");
q("#gg-plan-jump-opts") && (q("#gg-plan-jump-opts").style.display = m==="jump" ? "" : "none");
}
q("#gg-plan-method")?.addEventListener("change", updatePlanMethodOpts);
updatePlanMethodOpts();
// Plan-ahead calculate button → routes to the right calculator
q("#gg-plan-calc")?.addEventListener("click", () => {
const m = q("#gg-plan-method")?.value || "daily";
showPanel(null); // close plan panel
if (m === "xanax") calculateXanaxGrind();
else if (m === "jump") calculateHappyJump();
else calculateDaily();
});
// ── Bonus summary — live combined multiplier shown on Bonuses tab ────────
function updateBonusSummary() {
const el_ = q("#gg-bonus-summary");
if (!el_) return;
const stat = el.stat.value;
const bonus = calcBonus(stat);
const pct = ((bonus - 1) * 100).toFixed(1);
const parts = [];
if (getPerk(el.factionPerk) > 0) parts.push(`Faction +${el.factionPerk.value}%`);
if (getPerk(el.propertyPerk) > 0) parts.push(`Property +${el.propertyPerk.value}%`);
if (getPerk(el.eduStatPerk) > 0) parts.push(`Edu(stat) +${el.eduStatPerk.value}%`);
if (getPerk(el.eduGenPerk) > 0) parts.push(`Edu(all) +${el.eduGenPerk.value}%`);
if (getPerk(el.jobPerk) > 0) parts.push(`Job +${el.jobPerk.value}%`);
if (getPerk(el.bookPerk) > 0) parts.push(`Book +${el.bookPerk.value}%`);
if (getPerk(el.steroids) > 0) parts.push(`Steroids +${el.steroids.value}%`);
if (stat === "speed" && getPerk(el.sportsSneakers) > 0) parts.push(`Sneakers +${el.sportsSneakers.value}%`);
if (parts.length === 0) {
el_.style.display = "none";
return;
}
el_.style.display = "block";
el_.innerHTML = `<span style="color:#9aaa9a">Combined: ×${bonus.toFixed(4)} (+${pct}%)</span><br><span style="color:#556;font-size:10px">${parts.join(" · ")}</span>`;
}
// Wire all perk inputs to refresh summary
[el.factionPerk, el.propertyPerk, el.eduStatPerk, el.eduGenPerk,
el.jobPerk, el.bookPerk, el.steroids, el.sportsSneakers].forEach(e => {
e?.addEventListener("change", updateBonusSummary);
});
el.stat?.addEventListener("change", updateBonusSummary);
const wireCollapsible = id => {
q("#"+id)?.querySelector(".gg-collapsible-header")
?.addEventListener("click", e => { e.stopPropagation(); q("#"+id)?.classList.toggle("open"); });
};
["gg-od-panel","gg-item-info-panel","gg-settings-panel","gg-profile-panel"].forEach(wireCollapsible);
// Profile panel: open for new users (so they see what to fill), collapsed for returning
if (!_load("hasAutofilled", "")) {
q("#gg-profile-panel")?.classList.add("open");
} else {
// Returning user — populate the profile summary line with what's set
const ps = q("#gg-profile-summary");
const stat = parseInt(_load("statTotal","0"))||0;
const goal = parseInt(_load("statGoal","0"))||0;
// Note: cannot use `fmt` here — it's defined later (TDZ).
const _fmt = n => Math.round(n).toLocaleString();
if (ps && stat && goal) ps.textContent = `— ${_fmt(stat)} → ${_fmt(goal)}`;
}
q("#gg-logger-panel")?.querySelector(".gg-collapsible-header")?.addEventListener("click", e => {
e.stopPropagation();
q("#gg-logger-panel")?.classList.toggle("open");
renderLogger();
});
// ── Load cached prices on startup ─────────────────────────────────────────
(function loadCachedPrices() {
const cached = _load(PRICE_CACHE_KEY, null); if (!cached) return;
try {
const { ts, data } = JSON.parse(cached);
if (Date.now() - ts < PRICE_CACHE_TTL && data?.cans && Object.keys(data.cans).length > 0)
applyPriceData(data, gg_load("subscriber") || "no", true);
} catch(_) {}
})();
// ── Auto-switcher toggle (injected into Settings panel) ───────────────────
let switcherEnabled = _load('autoSwitchEnabled', true);
const _swEl = document.createElement('label');
_swEl.style.cssText = 'display:flex;align-items:center;gap:6px;cursor:pointer;margin-top:12px;font-size:12px;user-select:none;-webkit-tap-highlight-color:transparent';
const _swDot = document.createElement('span');
_swDot.style.cssText = 'display:inline-block;width:8px;height:8px;border-radius:50%;flex-shrink:0';
const _swLbl = document.createElement('span');
const _updateSw = () => {
_swDot.style.background = switcherEnabled ? '#5a9f5a' : '#555';
_swLbl.style.color = switcherEnabled ? '#7abf7a' : '#556';
_swLbl.textContent = 'Auto gym switcher: ' + (switcherEnabled ? 'ON' : 'OFF');
};
_updateSw();
_swEl.append(_swDot, _swLbl);
_swEl.addEventListener('click', () => {
switcherEnabled = !switcherEnabled;
_save('autoSwitchEnabled', switcherEnabled);
_updateSw();
if (!switcherEnabled) hideSwitchBanner();
});
q("#gg-settings-panel .gg-collapsible-body")?.appendChild(_swEl);
// ── Candy/can type → auto-fill cost ──────────────────────────────────────
q("#gg-hjCandyType")?.addEventListener("change", () => {
const idx = parseInt(q("#gg-hjCandyType").value);
if (idx > 0) {
const candy = CANDY_TYPES[idx-1], p = candyPrices[candy.label];
if (p && el.hjCandyCost) {
el.hjCandyCost.value = p; gg_save("hjCandyCost", String(p));
if (el.candyBadge) el.candyBadge.textContent = p>=1e6?`$${(p/1e6).toFixed(1)}m`:`$${(p/1000).toFixed(0)}k`;
}
}
maybeRecalc();
});
q("#gg-hjCanType")?.addEventListener("change", () => {
const idx = parseInt(q("#gg-hjCanType").value);
if (idx > 0) {
const can = CAN_TYPES[idx-1], p = canPrices[can.label];
if (p && el.hjCanCost) {
el.hjCanCost.value = p; gg_save("hjCanCost", String(p));
if (el.canBadge) el.canBadge.textContent = `$${(p/1000).toFixed(0)}k`;
}
}
renderPriceBreakdown(el.subscriber?.value); maybeRecalc();
});
q("#gg-subscriber")?.addEventListener("change", () => renderPriceBreakdown(el.subscriber?.value));
q("#gg-hjCanFactionPerk")?.addEventListener("change", () => renderPriceBreakdown(el.subscriber?.value));
q("#gg-hjCandyVoracity")?.addEventListener("change", () => renderPriceBreakdown(el.subscriber?.value));
q("#gg-hjCandyAbsorption")?.addEventListener("change", () => renderPriceBreakdown(el.subscriber?.value));
el.stat.addEventListener("change", () => {
gg_save("stat", el.stat.value);
tryAutoFillStat(); updateDotsDisplay(); updateBestGymPanel(); updateGoalProgress(); maybeRecalc();
});
// ── Goal progress badge ───────────────────────────────────────────────────
function updateGoalProgress() {
const badge = q("#gg-goal-progress"); if (!badge) return;
const cur = parseFloat(el.statTotal?.value) || 0;
const goal = parseFloat(el.statGoal?.value) || 0;
if (!cur || !goal || goal <= cur) { badge.textContent = ""; return; }
const pct = Math.min(100, cur/goal*100).toFixed(1);
const left = goal - cur;
const fmt = n => n>=1e6?(n/1e6).toFixed(2)+"m":n>=1000?Math.round(n/1000)+"k":String(Math.round(n));
badge.textContent = `${pct}% · ${fmt(left)} to go`;
badge.style.color = pct >= 90 ? "#66cc88" : pct >= 50 ? "#aaaa5a" : "#556";
}
// ── Button wiring ─────────────────────────────────────────────────────────
el.autofill.addEventListener("click", () => {
try { autofill(); }
catch(e) { showStatus("warn", "✗ Auto-fill error: " + e.message); }
});
el.fetchPricesBtn.addEventListener("click", () => fetchPrices(!!_load(PRICE_CACHE_KEY, null)));
el.calc.addEventListener("click", calculateSession);
el.compare.addEventListener("click", () => { try { calculateCompare(); } catch(e) { showStatus("warn", "✗ "+e.message); } });
el.copy.addEventListener("click", copyResults);
wrap.addEventListener("keydown", e => { if (e.key === "Enter" && document.activeElement?.tagName !== "BUTTON") calculateSession(); });
// ── Auto-recalc on input change ───────────────────────────────────────────
const RECALC_INPUTS = ["gym","stat","subscriber","statTotal","statGoal","happy",
"factionPerk","propertyPerk","eduStatPerk","eduGenPerk","jobPerk","bookPerk",
"steroids","sportsSneakers","energy","dailyRefill","hjXanaxCount","hjEDVDs",
"hjANJob","hjEcstasy","hjRefill","hjVoracity","hjBaseHappy",
"hjFHC","hjLSD","hjCans","hjCanType","hjCanFactionPerk",
"hjCandies","hjCandyType","hjCandyVoracity","hjCandyAbsorption"];
RECALC_INPUTS.forEach(k => {
const input = el[k]; if (!input) return;
input.addEventListener("change", () => {
if (k === "gym" && el.gym.value) gymConfirmed = true;
updateDotsDisplay(); updateBestGymPanel(); validateInputs(); maybeRecalc();
});
});
q("#gg-bestgym-panel")?.querySelector(".gg-bestgym-header")
?.addEventListener("click", () => { q("#gg-bestgym-panel").classList.toggle("open"); updateBestGymPanel(); });
// ── Startup: detect gym, restore stats, load gym API data ─────────────────
(function startup() {
el.gym.value = "";
const domGymId = detectGymFromDOM();
if (domGymId) { setGymSelect(el.gym, String(domGymId)); el.gymBadge.textContent = "✓ page"; gymConfirmed = true; }
})();
setTimeout(() => {
const d = detectGymFromDOM();
if (d) { setGymSelect(el.gym, String(d)); el.gymBadge.textContent = "✓ page"; gymConfirmed = true; }
tryAutoFillStat(); updateDotsDisplay(); validateInputs(); updateBestGymPanel();
}, 800);
setTimeout(() => {
if (!gymConfirmed) {
const d = detectGymFromDOM();
if (d) { setGymSelect(el.gym, String(d)); el.gymBadge.textContent = "✓ page"; gymConfirmed = true; updateDotsDisplay(); updateBestGymPanel(); }
}
}, 2500);
setTimeout(() => {
const k = (_load("apiKey","") || "").trim();
if (k.length === 16) loadGymsFromAPI(k).then(() => { updateDotsDisplay(); updateBestGymPanel(); });
}, 100);
const _storedTs = _load("lastAutofillTs", 0);
if (_storedTs && el.lastUpdated) {
const _ago = Math.round((Date.now()-_storedTs)/60000);
el.lastUpdated.textContent = _ago < 2 ? "updated recently" : `updated ${_ago}m ago`;
}
setInterval(() => {
const ts = _load("lastAutofillTs", 0);
if (ts && el.lastUpdated) {
const ago = Math.round((Date.now()-ts)/60000);
el.lastUpdated.textContent = ago < 1 ? "updated just now" : `updated ${ago}m ago`;
}
}, 60000);
function updateDotsDisplay() {
const gym = getGymData();
const stat = el.stat.value;
const disp = el.dotsDisplay;
if (!disp) return;
if (!gym) { disp.innerHTML = ""; return; }
const key = STAT_KEYS[stat];
const dots = gym[key];
if (!dots) {
disp.innerHTML = `<span class="gg-dot-na">✗ ${gym.name} doesn't train ${STAT_LABELS[stat]}</span>`;
return;
}
// Render dot pips (max 9 dots, each dot = 1.0)
const MAX_DOTS = 9;
const fullDots = Math.floor(dots);
const halfDot = (dots - fullDots) >= 0.3;
let pips = "";
for (let i = 0; i < MAX_DOTS; i++) {
const cls = i < fullDots ? "on" : (i === fullDots && halfDot) ? "half" : "off";
pips += `<span class="gg-dot ${cls}" title="${dots} dots"></span>`;
}
// Best gym for stat — use epsilon comparison to avoid floating point issues
const allDots = GYMS.map(g => g[key] || 0);
const bestDots = Math.max(...allDots);
const bestGym = GYMS.find(g => Math.abs((g[key] || 0) - bestDots) < 0.01);
const isBest = bestGym && gym.id === bestGym.id;
const bestLabel = bestGym ? (bestGym.name || `Gym ${bestGym.id}`) : null;
disp.innerHTML = `${pips}<span class="gg-dot-val">${dots} dots</span>`
+ (isBest ? `<span style="margin-left:6px;font-size:10px;color:#aaaa5a">★ best for ${STAT_LABELS[stat]}</span>`
: bestLabel ? `<span style="margin-left:6px;font-size:10px;color:#446">best: ${bestLabel} (${bestDots})</span>` : "");
}
// ─────────────────────────────────────────────────────────────────────────
// BEST GYM PANEL
// ─────────────────────────────────────────────────────────────────────────
// BEST GYM PANEL — shows best unlocked gym for the selected stat,
// updates whenever stat or gym changes
// ─────────────────────────────────────────────────────────────────────────
function updateBestGymPanel() {
const body = el.bestGymBody;
if (!body || !q("#gg-bestgym-panel").classList.contains("open")) return;
// If gym hasn't been confirmed from page or API, show a prompt
if (!gymConfirmed && !el.gym.value) {
body.innerHTML = `<div style="font-size:11px;color:#bf9f5a;padding:6px 2px;line-height:1.6">
⚠ Gym not detected yet.<br>
Click <strong>⟳ Auto-fill</strong> to detect your current gym and see the best gym recommendation.
</div>`;
return;
}
const stat = el.stat.value;
const statKey = STAT_KEYS[stat];
const statLabel = STAT_LABELS[stat];
const currentGym = getGymData();
const currentId = currentGym?.id || 0;
const hasStats = allStats.strength > 0 || allStats.speed > 0 || allStats.defense > 0 || allStats.dexterity > 0;
const manualOwned = JSON.parse(_load('manualGymOwned','[]'));
const currentPos = STANDARD_UNLOCK_ORDER[currentId] ?? 0;
// A gym is "owned" if:
// - Standard: sequential position ≤ current position (unlocks automatically)
// - Specialist: manually confirmed via + button
// - Unknown/new gym: treat as owned if it appears in API data (user clearly has access)
function isOwned(g) {
if (SPECIALIST_IDS.has(g.id)) return manualOwned.includes(g.id);
const knownPos = STANDARD_UNLOCK_ORDER[g.id];
if (knownPos !== undefined) return knownPos <= currentPos;
// Unknown gym ID (new gym added to Torn after this script was written)
// If it came from the API, the user likely has access — treat as owned
return true;
}
// Check stat ratio for specialist gyms
function ratioMet(gymId) {
const req = SPECIALIST_REQS[gymId];
if (!req) return true;
if (!hasStats) return false; // can't check without stats
return req.check(allStats);
}
// Check if specialist prereq gym is owned
function prereqOwned(gymId) {
const req = SPECIALIST_REQS[gymId];
if (!req) return true;
const prereqPos = STANDARD_UNLOCK_ORDER[req.prereqId] ?? 99;
return prereqPos <= currentPos;
}
// Build categorised gym list for this stat
const ownedGyms = []; // owned + trains this stat
const purchasable = []; // ratio met, prereq owned, but not purchased yet
const ratioNeeded = []; // specialist, prereq owned, but ratio not met
const prereqNeeded = []; // specialist, prereq not unlocked yet
for (const g of GYMS) {
const dots = g[statKey] || 0;
if (!dots) continue; // doesn't train this stat
if (isOwned(g)) {
ownedGyms.push({ gym:g, dots });
} else if (SPECIALIST_IDS.has(g.id)) {
if (!prereqOwned(g.id)) {
prereqNeeded.push({ gym:g, dots, req: SPECIALIST_REQS[g.id] });
} else if (!ratioMet(g.id)) {
ratioNeeded.push({ gym:g, dots, req: SPECIALIST_REQS[g.id] });
} else {
purchasable.push({ gym:g, dots, req: SPECIALIST_REQS[g.id] });
}
}
// Standard gyms above current position are just skipped (not unlocked yet)
}
// Sort all by dots descending
[ownedGyms, purchasable, ratioNeeded, prereqNeeded].forEach(a => a.sort((x,y) => y.dots - x.dots));
const bestOwned = ownedGyms[0];
const currentDots = currentGym?.[statKey] || 0;
let html = "";
// ── Best available gym ────────────────────────────────────────────────
if (bestOwned) {
const isCurrent = bestOwned.gym.id === currentId;
const gymName_ = bestOwned.gym.name || `Gym ${bestOwned.gym.id}`;
const tierLabel = bestOwned.gym.tier === "J" ? gymName_ : `[${bestOwned.gym.tier}] ${gymName_}`;
const pct = currentDots > 0 && bestOwned.dots > currentDots
? `+${(((bestOwned.dots/currentDots)-1)*100).toFixed(0)}% vs current`
: "";
const drugNote = bestOwned.gym.id === 31 ? " · ≤50 Xanax+Ecstasy" : "";
const useBtn = !isCurrent
? `<button class="gg-gym-use-btn" data-gymid="${bestOwned.gym.id}" style="margin-left:auto;font-size:11px;padding:3px 10px;border-radius:3px;border:1px solid #2a4a2a;background:#182018;color:#7abf7a;cursor:pointer">Use</button>` : "";
html += `<div style="padding:4px 0 8px">
<div style="font-size:10px;color:#4a8a4a;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px">★ Best for ${statLabel}</div>
<div class="gg-gym-row best" style="background:#181e14;border:1px solid #2a3a20">
<span class="gg-gym-rank">★</span>
<span class="gg-gym-name best">${tierLabel}${isCurrent?" ← you're here":""}</span>
<span class="gg-gym-e">${bestOwned.gym.energy}E</span>
<span class="gg-gym-dots-val">${bestOwned.dots}</span>
${useBtn}
</div>
${pct ? `<div style="font-size:11px;color:#5a9f5a;margin-top:4px;padding-left:8px">Switch for <strong>${pct}</strong> more ${statLabel} per energy${drugNote}</div>` : `<div style="font-size:11px;color:#446;margin-top:4px;padding-left:8px">You're already in the best gym${drugNote}</div>`}
</div>`;
} else {
html += `<div style="font-size:11px;color:#556;padding:4px 0 8px">No owned gyms found that train ${statLabel}.</div>`;
}
// ── Your other unlocked gyms (compact) ───────────────────────────────
const others = ownedGyms.slice(1);
if (others.length) {
html += `<div class="gg-bestgym-sec">Your other unlocked gyms for ${statLabel}</div>`;
others.forEach((e, i) => {
const isCurrent = e.gym.id === currentId;
const tierLabel = e.gym.tier === "J" ? e.gym.name : `[${e.gym.tier}] ${e.gym.name}`;
const useBtn = !isCurrent
? `<button class="gg-gym-use-btn" data-gymid="${e.gym.id}">Use</button>` : "";
html += `<div class="gg-gym-row ${isCurrent?"current":""}">
<span class="gg-gym-rank">#${i+2}</span>
<span class="gg-gym-name ${isCurrent?"current":""}">${tierLabel}${isCurrent?" ←":""}</span>
<span class="gg-gym-e">${e.gym.energy}E</span>
<span class="gg-gym-dots-val dim">${e.dots}</span>
${useBtn}
</div>`;
});
}
// ── Purchasable specialists ───────────────────────────────────────────
if (purchasable.length) {
html += `<div class="gg-bestgym-sec" style="margin-top:8px">💰 Your stats qualify — purchase to unlock</div>`;
purchasable.forEach(e => {
const tierLabel = `[${e.gym.tier}] ${e.gym.name}`;
const drugNote = e.gym.id === 31 ? " (drug-limited)" : "";
const ownBtn = `<button class="gg-gym-own-btn" data-gymid="${e.gym.id}" style="font-size:10px;padding:2px 7px;border-radius:3px;border:1px solid #2a5a2a;background:#182518;color:#6a9f6a;cursor:pointer">I own it</button>`;
html += `<div class="gg-gym-row">
<span class="gg-gym-rank">💰</span>
<span class="gg-gym-name">${tierLabel}${drugNote}</span>
<span class="gg-gym-e">${e.gym.energy}E</span>
<span class="gg-gym-dots-val">${e.dots}</span>
${ownBtn}
</div>`;
});
}
// ── Ratio not met ─────────────────────────────────────────────────────
if (ratioNeeded.length) {
html += `<div class="gg-bestgym-sec" style="margin-top:8px">📊 Better gyms — stat ratio not yet met</div>`;
ratioNeeded.forEach(e => {
const tierLabel = `[${e.gym.tier}] ${e.gym.name}`;
html += `<div class="gg-gym-row" title="${e.req?.desc||''}">
<span class="gg-gym-rank">📊</span>
<span class="gg-gym-name">${tierLabel}</span>
<span class="gg-gym-e">${e.gym.energy}E</span>
<span class="gg-gym-dots-val dim">${e.dots}</span>
<span class="gg-gym-locked" title="${e.req?.desc||''}" style="font-size:10px;color:#5a4a2a;max-width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${e.req?.desc||''}</span>
</div>`;
});
}
// ── Prereq not unlocked ───────────────────────────────────────────────
if (prereqNeeded.length) {
html += `<div class="gg-bestgym-sec" style="margin-top:8px">🔒 Unlock ${GYM_BY_ID[prereqNeeded[0]?.req?.prereqId]?.name || "required gym"} first</div>`;
prereqNeeded.forEach(e => {
const tierLabel = `[${e.gym.tier}] ${e.gym.name}`;
html += `<div class="gg-gym-row">
<span class="gg-gym-rank">🔒</span>
<span class="gg-gym-name" style="color:#444">${tierLabel}</span>
<span class="gg-gym-e">${e.gym.energy}E</span>
<span class="gg-gym-dots-val dim">${e.dots}</span>
</div>`;
});
}
if (!hasStats) html += `<div class="gg-bestgym-note" style="margin-top:8px">⚠ Auto-fill with API key to check specialist gym eligibility based on your actual stat ratios.</div>`;
body.innerHTML = html;
// Wire "I own it" buttons — mark specialist gym as purchased
body.querySelectorAll(".gg-gym-own-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
const gymId = parseInt(btn.dataset.gymid);
const owned = JSON.parse(_load('manualGymOwned','[]'));
if (!owned.includes(gymId)) owned.push(gymId);
_save('manualGymOwned', JSON.stringify(owned));
updateBestGymPanel();
});
});
// Wire "Use" buttons — switch calculator to that gym
body.querySelectorAll(".gg-gym-use-btn").forEach(btn => {
btn.addEventListener("click", e => {
e.stopPropagation();
const gymId = btn.dataset.gymid;
setGymSelect(el.gym, gymId);
gg_save("gymId", gymId);
updateDotsDisplay();
updateBestGymPanel();
maybeRecalc();
showStatus("ok", `✓ Calculator switched to ${GYM_BY_ID[parseInt(gymId)]?.name || "selected gym"}.`);
});
});
}
// ─────────────────────────────────────────────────────────────────────────
// INPUT VALIDATION
// ─────────────────────────────────────────────────────────────────────────
function validateInputs() {
let valid = true;
const statNow = parseFloat(el.statTotal.value) || 0;
const statGoal = parseFloat(el.statGoal.value) || 0;
const happy = parseFloat(el.happy.value) || 0;
// Current stat
const statBad = statNow < 100 && el.statTotal.value !== "";
el.statTotal.classList.toggle("invalid", statBad);
el.valStat.classList.toggle("show", statBad);
if (statBad) valid = false;
// Goal
const goalBad = statGoal > 0 && statGoal <= statNow;
el.statGoal?.classList.toggle("warn-input", goalBad);
el.valGoal?.classList.toggle("show", goalBad);
if (goalBad) valid = false;
// Happy
const happyBad = happy < 100 && el.happy.value !== "";
el.happy.classList.toggle("invalid", happyBad);
el.valHappy.classList.toggle("show", happyBad);
if (happyBad) valid = false;
// Gym + stat compatibility
const gym = getGymData();
const dots = gym?.[STAT_KEYS[el.stat.value]] || 0;
el.gym.classList.toggle("warn-input", gym && !dots);
return valid;
}
// Auto-recalc only if results panel is visible
let _recalcTimer = null;
function maybeRecalc() {
const dailyVisible = el.dailyResults.style.display !== "none";
const jumpVisible = el.jumpResults.style.display !== "none";
const compareVisible = el.compareResults.style.display !== "none";
if (!dailyVisible && !jumpVisible && !compareVisible) return;
clearTimeout(_recalcTimer);
_recalcTimer = setTimeout(() => {
try {
if (compareVisible) calculateCompare();
else if (jumpVisible) calculateHappyJump();
else calculateSession();
} catch(e) { /* silently ignore during auto-recalc */ }
}, 400);
}
// ─────────────────────────────────────────────────────────────────────────
// INFOBAR — shows live page energy/happy and last autofill time
// ─────────────────────────────────────────────────────────────────────────
function updateInfobar(currentE, maxE, currentH, isSubscriber, timestamp) {
if (!el.infobar) return;
el.infobar.style.display = "flex";
if (currentE != null) {
el.infoEnergy.textContent = currentE > maxE
? `${currentE}/${maxE} (+${currentE-maxE} over)`
: `${currentE}/${maxE}`;
el.infoEnergy.className = "gg-infobar-val" + (currentE >= maxE ? " hi" : "");
}
if (currentH != null) el.infoHappy.textContent = currentH.toLocaleString();
if (isSubscriber != null) {
el.infoSubWrap.style.display = isSubscriber ? "" : "none";
}
if (timestamp) {
const ago = Math.round((Date.now() - timestamp) / 60000);
el.lastUpdated.textContent = ago < 1 ? "updated just now" : `updated ${ago}m ago`;
}
}
// Update "X min ago" every minute
setInterval(() => {
const ts = _load("lastAutofillTs", 0);
if (ts && el.lastUpdated) {
const ago = Math.round((Date.now() - ts) / 60000);
el.lastUpdated.textContent = ago < 1 ? "updated just now" : `updated ${ago}m ago`;
}
}, 60000);
// ─────────────────────────────────────────────────────────────────────────
// STATUS
// ─────────────────────────────────────────────────────────────────────────
function showStatus(type, msg) {
el.status.className = `gg-status ${type}`;
el.status.textContent = msg;
}
// ─────────────────────────────────────────────────────────────────────────
// API FETCH — user data (perks, stats, gym, bars, properties)
// ─────────────────────────────────────────────────────────────────────────
async function fetchFromAPI(stat) {
const key = (_load("apiKey","") || "").trim();
if (!key || key.length !== 16) return null;
let data;
try {
data = await gmFetch(`https://api.torn.com/user/?selections=perks,battlestats,gym,bars,basic,properties&key=${key}&comment=GymGains`);
} catch(e) {
return { error: "Network error." };
}
if (data.error) return { error: `API ${data.error.code}: ${data.error.error}` };
const statName = STAT_LABELS[stat].toLowerCase();
const out = {
factionPerk:0, propertyPerk:0, eduStatPerk:0, eduGenPerk:0, jobPerk:0, bookPerk:0,
statValue:null, gymId:null, energyCap:null, subscriber:null, currentEnergy:null,
propertyHappy:null, currentHappy:null, maxHappy:null, filled:[],
};
// Battle stats — capture all 4 for specialist gym eligibility checks
if (data.battlestats) {
const bs = data.battlestats;
allStats = {
strength: Math.round(bs.strength || 0),
speed: Math.round(bs.speed || 0),
defense: Math.round(bs.defense || 0),
dexterity: Math.round(bs.dexterity || 0),
};
cache.set('battlestats', allStats);
if (allStats[stat]) {
out.statValue = allStats[stat];
out.filled.push(`${STAT_LABELS[stat]} ${out.statValue.toLocaleString()}`);
}
}
// Bars — energy cap (subscriber) + current energy + current/max happy
if (data.energy?.maximum) {
out.energyCap = data.energy.maximum;
out.subscriber = data.energy.maximum >= 150 ? "yes" : "no";
out.currentEnergy = data.energy.current ?? null;
out.filled.push(`Subscriber: ${out.subscriber === "yes" ? "Yes" : "No"}`);
}
if (data.happy?.current != null) {
out.currentHappy = data.happy.current;
out.maxHappy = data.happy.maximum;
}
// Gym — user?selections=gym returns the gym_id as a plain integer: { "gym": 13 }
// Some API versions may return { gym: { gym_id: 13 } } — handle both
const rawGym = data.gym;
const gymId = typeof rawGym === "number" ? rawGym
: typeof rawGym === "object" && rawGym !== null
? (rawGym.gym_id ?? rawGym.id ?? null)
: null;
if (gymId) {
out.gymId = gymId;
const g = GYM_BY_ID[gymId];
if (g) out.filled.push(g.name);
}
// Property max happy — prefer bars.happy.maximum (reflects upgrades+staff)
if (out.maxHappy && out.maxHappy > 0) {
out.propertyHappy = out.maxHappy;
out.filled.push(`Max Happy ${out.maxHappy.toLocaleString()}`);
} else if (data.properties) {
let max = 0;
for (const p of Object.values(data.properties)) {
if (p.happy && p.happy > max) max = p.happy;
}
if (max > 0) { out.propertyHappy = max; out.filled.push(`Max Happy ${max.toLocaleString()}`); }
}
if (out.currentHappy != null) out.filled.push(`Happy ${out.currentHappy.toLocaleString()}`);
// Perks — API returns SEPARATE arrays per category, NOT a single flat array.
// Each string is the perk text alone, e.g. "Increases dexterity gym gains by 8%"
// Categories: faction_perks, property_perks, education_perks, company_perks, book_perks
// Also handle legacy flat data.perks array (some API versions) with "Category: text" prefix
function parsePerkList(arr, category) {
if (!Array.isArray(arr)) return;
for (const p of arr) {
const lower = p.toLowerCase();
if (!lower.includes('gym gain')) continue;
const isGeneral = lower.includes('all gym gain');
const isThisStat = lower.includes(statName + ' gym gain');
if (!isGeneral && !isThisStat) continue;
const m = p.match(/(\d+(?:\.\d+)?)\s*%/);
if (!m) continue;
const pct = parseFloat(m[1]);
switch (category) {
case 'faction': out.factionPerk += pct; break;
case 'property': out.propertyPerk += pct; break;
case 'education': isThisStat ? (out.eduStatPerk += pct) : (out.eduGenPerk += pct); break;
case 'company': out.jobPerk += pct; break;
case 'book': out.bookPerk += pct; break;
}
perkCount++;
}
}
let perkCount = 0;
// Primary: separate named arrays (standard API v1 response)
parsePerkList(data.faction_perks, 'faction');
parsePerkList(data.property_perks, 'property');
parsePerkList(data.education_perks, 'education');
parsePerkList(data.company_perks, 'company');
parsePerkList(data.book_perks, 'book');
// Also parse job_perks if present
parsePerkList(data.job_perks, 'company');
// Fallback: legacy flat data.perks array with "Category: text" prefix format
if (perkCount === 0 && Array.isArray(data.perks)) {
for (const p of data.perks) {
const lower = p.toLowerCase();
if (!lower.includes('gym gain')) continue;
const isGeneral = lower.includes('all gym gain');
const isThisStat = lower.includes(statName + ' gym gain');
if (!isGeneral && !isThisStat) continue;
const m = p.match(/(\d+(?:\.\d+)?)\s*%/);
if (!m) continue;
const pct = parseFloat(m[1]);
if (lower.startsWith('faction')) out.factionPerk += pct;
else if (lower.startsWith('property')) out.propertyPerk += pct;
else if (lower.startsWith('education') && isThisStat) out.eduStatPerk += pct;
else if (lower.startsWith('education') && isGeneral) out.eduGenPerk += pct;
else if (lower.startsWith('job')||lower.startsWith('company')) out.jobPerk += pct;
else if (lower.startsWith('book')) out.bookPerk += pct;
perkCount++;
}
}
if (perkCount > 0) out.filled.push(`${perkCount} gym perk${perkCount!==1?"s":""}`);
// Store player identity from basic selection for use in logs
if (data.player_id) { _save('playerId', String(data.player_id)); }
if (data.name) { _save('playerName', data.name); }
return out;
}
// ─────────────────────────────────────────────────────────────────────────
// PRICE FETCH — weav3r.dev bazaar (live) + torn/items market_value (fallback)
// ─────────────────────────────────────────────────────────────────────────
async function fetchItemPrice(itemId) {
if (!itemId) return null;
try {
const d = await gmFetch("https://weav3r.dev/api/marketplace/"+itemId);
let price = null;
if (d?.listings?.length > 0) {
const s = d.listings.map(l=>l.price).filter(p=>p>100).sort((a,b)=>a-b);
if (s.length) price = s[0];
}
if (!price && d?.market_price > 100) price = d.market_price;
return price || null;
} catch(_) { return null; }
}
async function fetchPrices(forceRefresh = false) {
const apiKey = (_load("apiKey","") || "").trim();
const subscriber = el.subscriber?.value || _load("subscriber","no");
if (forceRefresh) {
_save(PRICE_CACHE_KEY, null);
_save("canItemIds", null); _save("canItemIdsTs", 0);
CAN_TYPES.forEach(c => c.id = null); CANDY_TYPES.forEach(c => c.id = null);
}
if (!forceRefresh) {
const cached = _load(PRICE_CACHE_KEY, null);
if (cached) {
try {
const { ts, data } = JSON.parse(cached);
if (Date.now()-ts < PRICE_CACHE_TTL) {
applyPriceData(data, subscriber, true);
showStatus("ok", "✓ Prices cached (" + Math.round((Date.now()-ts)/60000) + "m ago) — click ⟳ to refresh");
return;
}
} catch(_) {}
}
}
showStatus("ok", "⟳ Fetching live prices…");
await resolveCanIDs(apiKey);
const MAIN = [
{key:"xanax", id:ITEM_IDS.xanax, label:"Xanax", badge:el.xanaxBadge, save:"hjXanaxCost", input:el.hjXanaxCost},
{key:"edvd", id:ITEM_IDS.edvd, label:"eDVD", badge:el.edvdBadge, save:"hjEDVDCost", input:el.hjEDVDCost},
{key:"ecstasy", id:ITEM_IDS.ecstasy, label:"Ecstasy", badge:el.ecstasyBadge, save:"hjEcstasyCost", input:el.hjEcstasyCost},
{key:"fhc", id:ITEM_IDS.fhc, label:"FHC", badge:el.fhcBadge, save:"hjFHCCost", input:el.hjFHCCost},
{key:"lsd", id:ITEM_IDS.lsd, label:"LSD", badge:el.lsdBadge, save:"hjLSDCost", input:el.hjLSDCost},
];
MAIN.forEach(m => { if (m.badge) { m.badge.className = "gg-price-badge loading"; m.badge.textContent = "⟳"; } });
const rC = CAN_TYPES.filter(c => c.id), rD = CANDY_TYPES.filter(c => c.id);
const [mR,cR,dR] = await Promise.all([
Promise.all(MAIN.map(m => fetchItemPrice(m.id))),
Promise.all(rC.map(c => fetchItemPrice(c.id))),
Promise.all(rD.map(c => fetchItemPrice(c.id))),
]);
const snap = { main:{}, cans:{}, candies:{} };
const fmt = p => p >= 1e6 ? "$" + (p/1e6).toFixed(1) + "m" : "$" + Math.round(p/1000) + "k";
MAIN.forEach((m,i) => {
const p = mR[i];
if (p) { snap.main[m.key]=p; gg_save(m.save,p); if(m.input) m.input.value=p; if(m.badge){m.badge.className="gg-price-badge";m.badge.textContent=fmt(p);} }
else if (m.badge) { m.badge.className="gg-price-badge err"; m.badge.textContent="—"; }
});
rC.forEach((c,i) => { if (cR[i]) snap.cans[c.label] = cR[i]; });
rD.forEach((c,i) => { if (dR[i]) snap.candies[c.label] = dR[i]; });
_save(PRICE_CACHE_KEY, JSON.stringify({ ts: Date.now(), data: snap }));
applyPriceData(snap, subscriber, false);
const mc=Object.keys(snap.main).length, cc=Object.keys(snap.cans).length, dc=Object.keys(snap.candies).length;
showStatus("ok", (!apiKey && (cc===0||dc===0))
? "✓ " + mc + " main prices · Add API key in ⚙ for can/candy prices"
: "✓ " + mc + " items · " + cc + "/" + CAN_TYPES.length + " cans · " + dc + "/" + CANDY_TYPES.length + " candy · 1h cache");
}
// Apply a price snapshot to inputs, badges, and breakdown panel
function applyPriceData(snapshot, subscriber, fromCache) {
const mainDefs = {
xanax: { e: 250, label: "Xanax", saveKey: "hjXanaxCost", inputEl: el.hjXanaxCost, badge: el.xanaxBadge },
edvd: { e: 0, label: "eDVD", saveKey: "hjEDVDCost", inputEl: el.hjEDVDCost, badge: el.edvdBadge },
ecstasy: { e: 0, label: "Ecstasy", saveKey: "hjEcstasyCost", inputEl: el.hjEcstasyCost, badge: el.ecstasyBadge },
fhc: { e: subscriber === "yes" ? 150 : 100, label: "FHC", saveKey: "hjFHCCost", inputEl: el.hjFHCCost, badge: el.fhcBadge },
lsd: { e: 50, label: "LSD", saveKey: "hjLSDCost", inputEl: el.hjLSDCost, badge: el.lsdBadge },
};
// Apply main item prices
for (const [key, def] of Object.entries(mainDefs)) {
const p = snapshot.main?.[key];
if (p) {
allItemPrices[key] = p;
if (def.inputEl) { def.inputEl.value = p; gg_save(def.saveKey, String(p)); }
if (def.badge) {
def.badge.className = "gg-price-badge";
const inK = p >= 1e6 ? `$${(p/1e6).toFixed(1)}m` : `$${(p/1000).toFixed(0)}k`;
def.badge.textContent = inK;
}
// Also sync Xanax price to Xanax Grind cost field
if (key === 'xanax') {
if (el.xgXanaxCost) { el.xgXanaxCost.value = p; gg_save("xgXanaxCost", String(p)); }
const xgBadge = wrap.querySelector('#gg-xg-xanax-price');
if (xgBadge) {
xgBadge.className = "gg-price-badge";
xgBadge.textContent = p >= 1e6 ? `$${(p/1e6).toFixed(1)}m` : `$${(p/1000).toFixed(0)}k`;
}
}
}
}
// Apply can prices
canPrices = {};
CAN_TYPES.forEach(c => {
const p = snapshot.cans?.[c.label];
if (p) { canPrices[c.label] = p; allItemPrices['can_' + c.label] = p; }
});
// Apply candy prices
candyPrices = {};
CANDY_TYPES.forEach(c => {
const p = snapshot.candies?.[c.label];
if (p) { candyPrices[c.label] = p; allItemPrices['candy_' + c.label] = p; }
});
// Update selected candy cost input
const selCandyIdx = gi(el.hjCandyType);
if (selCandyIdx > 0) {
const selCandy = CANDY_TYPES[selCandyIdx - 1];
const p = candyPrices[selCandy.label];
if (p && el.hjCandyCost) {
el.hjCandyCost.value = p;
gg_save("hjCandyCost", String(p));
if (el.candyBadge) {
el.candyBadge.textContent = p >= 1e6 ? `$${(p/1e6).toFixed(1)}m` : `$${(p/1000).toFixed(0)}k`;
}
}
}
// Update selected can cost input
const selCanIdx = gi(el.hjCanType);
if (selCanIdx > 0) {
const selCan = CAN_TYPES[selCanIdx - 1];
const p = canPrices[selCan.label];
if (p && el.hjCanCost) {
el.hjCanCost.value = p;
gg_save("hjCanCost", String(p));
if (el.canBadge) {
const inK = p >= 1e6 ? `$${(p/1e6).toFixed(1)}m` : `$${(p/1000).toFixed(0)}k`;
el.canBadge.textContent = inK;
}
}
}
renderPriceBreakdown(subscriber);
}
// ── Unified price breakdown ───────────────────────────────────────────────
function renderPriceBreakdown(subscriber) {
const panel = q("#gg-price-breakdown");
if (!panel) return;
const sub = subscriber || el.subscriber?.value || gg_load("subscriber") || "no";
const cap = sub === "yes" ? 150 : 100;
const canFacPct = gi(el.hjCanFactionPerk) / 100;
const candyVorPct = gi(el.hjCandyVoracity) / 100;
const candyAbsPct = el.hjCandyAbsorption?.value === "yes" ? 0.10 : 0;
const candyMult = 1 + candyVorPct + candyAbsPct;
const xanPrice = parseFloat(el.hjXanaxCost?.value) || 0;
const fhcPrice = parseFloat(el.hjFHCCost?.value) || 0;
const lsdPrice = parseFloat(el.hjLSDCost?.value) || 0;
const fmtM = p => p >= 1e6 ? `$${(p/1e6).toFixed(2)}m` : p >= 1000 ? `$${Math.round(p/1000)}k` : `$${p}`;
// Energy sources
const eRows = [];
if (xanPrice > 0) eRows.push({ name:"Xanax", e:250, price:xanPrice, cd:"6–8h", note:"drug" });
if (lsdPrice > 0) eRows.push({ name:"LSD", e:50, price:lsdPrice, cd:"~3h", note:"no OD" });
if (fhcPrice > 0) eRows.push({ name:"FHC", e:cap, price:fhcPrice, cd:"6h", note:`+500 happy` });
eRows.push( { name:"Points Refill", e:cap, price:1725000, cd:"daily" });
CAN_TYPES.forEach(c => {
const p = canPrices[c.label]; if (!p) return;
eRows.push({ name:c.label, e:Math.round(c.e*(1+canFacPct)), price:p, cd:"2h" });
});
// Candy
const cRows = [];
CANDY_TYPES.forEach(c => {
const p = candyPrices[c.label]; if (!p) return;
cRows.push({ name:c.label, happy:Math.round(c.happy*candyMult), price:p });
});
const hasE = eRows.length > 1;
const hasC = cRows.length > 0;
if (!hasE && !hasC) {
panel.innerHTML = `<div style="font-size:11px;color:#445;padding:6px 0">Click ⟳ Live Prices to load rankings.</div>`;
return;
}
let cacheAge = "";
try {
const { ts } = JSON.parse(_load(PRICE_CACHE_KEY, "{}"));
if (ts) { const m=Math.round((Date.now()-ts)/60000); cacheAge=m<1?"updated just now":`updated ${m}m ago`; }
} catch(_) {}
const secHdr = (icon, title, note="") => `
<div style="display:flex;align-items:baseline;gap:6px;margin:12px 0 5px;padding-bottom:4px;border-bottom:1px solid #252535">
<span style="font-size:11px;font-weight:bold;color:#8a9aaa">${icon} ${title}</span>
${note?`<span style="font-size:10px;color:#445">${note}</span>`:""}
</div>`;
const hdrRow = (cols) => `<div style="display:grid;grid-template-columns:${cols};gap:0 8px;margin-bottom:2px">`;
const hdrCell = (t,a="right") => `<div style="font-size:9px;color:#445;text-transform:uppercase;letter-spacing:.04em;text-align:${a};padding-bottom:3px;border-bottom:1px solid #1e1e2a">${t}</div>`;
let html = "";
if (hasE) {
eRows.forEach(r => r.perE = r.price / r.e);
eRows.sort((a,b) => a.perE - b.perE);
const bestPerE = eRows[0].perE;
const noteStr = canFacPct > 0 ? `+${Math.round(canFacPct*100)}% faction applied` : "";
html += secHdr("⚡","Energy Sources — ranked cheapest $/E", noteStr);
html += hdrRow("1fr 44px 62px 40px");
html += hdrCell("Item","left")+hdrCell("Energy")+hdrCell("$/E")+hdrCell("CD");
html += `</div>`;
eRows.forEach((r,i) => {
const ratio=r.perE/bestPerE, isBest=i===0;
const col = isBest?"#7abf7a":ratio<1.3?"#9abf6a":ratio<2?"#bf9f5a":"#667";
const badge = isBest?` <span style="font-size:9px;color:#3a7a3a;background:#182018;border:1px solid #2a4a2a;border-radius:2px;padding:0 3px">BEST</span>`:"";
const note = r.note?` <span style="font-size:9px;color:#4a6a5a">${r.note}</span>`:"";
html += `<div style="display:grid;grid-template-columns:1fr 44px 62px 40px;gap:0 8px;padding:4px 0;border-bottom:1px solid #1a1a22;align-items:baseline">
<div style="font-size:12px;color:${col};font-weight:${isBest?'bold':'normal'}">${r.name}${badge}${note}</div>
<div style="font-size:11px;color:#667;text-align:right">+${r.e}E</div>
<div style="font-size:12px;color:${col};text-align:right;font-weight:${isBest?'bold':'normal'}">${fmt(Math.round(r.perE))}</div>
<div style="font-size:10px;color:#556;text-align:right">${r.cd}</div>
</div>`;
});
}
if (hasC) {
cRows.forEach(r => r.perH = r.price / r.happy);
cRows.sort((a,b) => a.perH - b.perH);
const bestPerH = cRows[0].perH;
const modParts=[];
if (candyVorPct>0) modParts.push(`+${Math.round(candyVorPct*100)}% Voracity`);
if (candyAbsPct>0) modParts.push(`+10% Absorption`);
const modStr = modParts.length ? modParts.join(", ")+" applied" : "30min CD · max 48";
html += secHdr("🍬","Candy — ranked cheapest $/happy", modStr);
html += hdrRow("1fr 50px 64px");
html += hdrCell("Item","left")+hdrCell("Happy")+hdrCell("$/happy");
html += `</div>`;
cRows.forEach((r,i) => {
const ratio=r.perH/bestPerH, isBest=i===0;
const col = isBest?"#7abf7a":ratio<1.3?"#9abf6a":ratio<2.5?"#bf9f5a":"#667";
const badge = isBest?` <span style="font-size:9px;color:#3a7a3a;background:#182018;border:1px solid #2a4a2a;border-radius:2px;padding:0 3px">BEST</span>`:"";
html += `<div style="display:grid;grid-template-columns:1fr 50px 64px;gap:0 8px;padding:4px 0;border-bottom:1px solid #1a1a22;align-items:baseline">
<div style="font-size:12px;color:${col};font-weight:${isBest?'bold':'normal'}">${r.name}${badge}</div>
<div style="font-size:11px;color:#667;text-align:right">+${r.happy}</div>
<div style="font-size:12px;color:${col};text-align:right;font-weight:${isBest?'bold':'normal'}">${fmt(Math.round(r.perH))}</div>
</div>`;
});
if (cRows[0]) {
const b=cRows[0];
html += `<div style="font-size:10px;color:#4a6a5a;margin-top:5px">Best for a jump: ${b.name} ×49 = ${fmtM(49*b.price)} → +${fmt(49*b.happy)} happy</div>`;
}
}
if (hasE) {
html += secHdr("📊",`Cost to get ${cap}E`);
eRows.forEach(r => {
const n=Math.ceil(cap/r.e), total=fmtM(n*r.price);
const cdStr = r.cd==="daily"?"":r.cd==="6–8h"?` · ${r.cd} CD`:` · ${r.name==="FHC"?n*6:n*2}h CD`;
html += `<div style="display:flex;justify-content:space-between;align-items:baseline;padding:3px 0;border-bottom:1px solid #1a1a22">
<span style="font-size:12px;color:#8a9aaa">${r.name}${n>1?` ×${n}`:""}</span>
<span style="font-size:12px;color:#aab">${total}<span style="font-size:10px;color:#445">${cdStr}</span></span>
</div>`;
});
}
if (cacheAge) html += `<div style="font-size:9px;color:#334;text-align:right;margin-top:8px;padding-top:4px;border-top:1px solid #1a1a22">${cacheAge} · 1h cache · ⟳ to refresh</div>`;
panel.innerHTML = html;
}
// ─────────────────────────────────────────────────────────────────────────
// DOM STAT DETECTION (fallback / supplement when no API key)
// ─────────────────────────────────────────────────────────────────────────
function tryAutoFillStat() {
const stat = el.stat.value;
const parseN = t => { const n = parseInt((t||"").replace(/[^0-9]/g,""), 10); return isNaN(n)||n<100 ? null : n; };
// Method 1: Torn's canonical stat element IDs (confirmed from gym page DOM)
// e.g. <span id="strength-val">528,412</span>
const idEl = document.getElementById(stat + '-val');
if (idEl) { const v = parseN(idEl.textContent); if (v) { setStatValue(v); return v; } }
// Method 2: abbreviated label + value pattern e.g. "DEX 528,412"
const abbrev = STAT_ABBREV[stat];
const allEls = document.querySelectorAll('h3,h4,span,div,[class*="stat"],[class*="Stat"]');
for (const node of allEls) {
if (wrap.contains(node)) continue;
const txt = node.textContent.trim();
const m = txt.match(new RegExp('^' + abbrev + '\\s+([\\d,]+)$'));
if (m) { const v = parseN(m[1]); if (v) { setStatValue(v); return v; } }
if (txt === abbrev) {
const sib = node.nextElementSibling;
if (sib) { const v = parseN(sib.textContent); if (v) { setStatValue(v); return v; } }
}
}
// Method 3: look for full stat name with value — TornPDA sometimes renders
// "Dexterity\n528,412" or "Dexterity: 528,412" in gym cards
const fullName = stat.charAt(0).toUpperCase() + stat.slice(1);
for (const node of allEls) {
if (wrap.contains(node)) continue;
const txt = node.textContent.trim();
const m2 = txt.match(new RegExp(fullName + '[:\\s]+([\\d,]+)', 'i'));
if (m2) { const v = parseN(m2[1]); if (v) { setStatValue(v); return v; } }
}
// Method 4: innerText scan of gym root — last resort
const root = document.querySelector('#gymroot,[class*="gymRoot"],[class*="gym-root"],.content-wrapper');
if (root) {
for (const pat of [
new RegExp('\\b' + abbrev + '\\s+([\\d,]+)'),
new RegExp(fullName + '[:\\s]+([\\d,]+)', 'i'),
new RegExp(stat + '-val[^>]*>([\\d,]+)', 'i'),
]) {
const m = root.innerText.match(pat);
if (m) { const v = parseN(m[1]); if (v) { setStatValue(v); return v; } }
}
}
el.autofillBadge.style.display = "none";
return null;
}
function setStatValue(v) {
el.statTotal.value = v;
gg_save("statTotal", String(v));
el.autofillBadge.style.display = "inline";
}
// ─────────────────────────────────────────────────────────────────────────
// DOM GYM DETECTION
// The gym name appears on the page as standalone text (e.g. "Racing Fitness")
// followed by "You have X/Y energy". We find it by scanning all text nodes.
// ─────────────────────────────────────────────────────────────────────────
function detectGymFromDOM() {
const gymNames = GYMS.map(g => ({ id: g.id, lower: g.name.toLowerCase(), name: g.name }));
// Method 1: Torn's gym-name element ID (desktop web — confirmed stable)
// The gym card has a heading with the gym name; sometimes in #gym-root h1/h2/h3
const headingEls = document.querySelectorAll(
'h1,h2,h3,[class*="gymName"],[class*="gym-name"],[class*="title"],[id*="gym"]'
);
for (const el_ of headingEls) {
if (wrap.contains(el_)) continue;
const t = el_.textContent.trim().toLowerCase();
const g = gymNames.find(g => g.lower === t);
if (g) return g.id;
}
// Method 2: Walk all text nodes (robust but slow — handles any rendering)
const walker = document.createTreeWalker(
document.body, NodeFilter.SHOW_TEXT,
{ acceptNode: n => {
if (wrap.contains(n.parentElement)) return NodeFilter.FILTER_REJECT;
const t = n.textContent.trim();
return (t.length >= 4 && t.length <= 60) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
}}
);
const candidates = [];
let node;
while ((node = walker.nextNode())) {
const t = node.textContent.trim().toLowerCase();
const g = gymNames.find(g => g.lower === t);
if (g) candidates.push({ id: g.id, node });
}
if (!candidates.length) return null;
// Prefer candidate near energy text
for (const c of candidates) {
const container = c.node.parentElement?.closest('div,section,article,li') || c.node.parentElement;
if (container && /you have\s+[\d,]+\s*\/\s*[\d,]+\s*energy/i.test(container.textContent))
return c.id;
}
// Prefer candidate near "train" button
for (const c of candidates) {
const container = c.node.parentElement?.closest('div,section,li') || c.node.parentElement;
if (container) {
const hasTrain = [...container.querySelectorAll('button,a')].some(
b => b.textContent.trim().toUpperCase() === 'TRAIN'
);
if (hasTrain) return c.id;
}
}
return candidates[0].id;
}
// ─────────────────────────────────────────────────────────────────────────
// AUTO-FILL
// ─────────────────────────────────────────────────────────────────────────
function autofill() {
_save("lastAutofillTs", Date.now()); // throttle silent page-load autofill
const stat = el.stat.value;
const apiKey = (_load("apiKey","")||"").trim();
// Visible loading state on the Auto-fill button itself
const btn = el.autofill;
const origText = btn?.textContent;
const origDisabled = btn?.disabled;
if (btn) {
btn.disabled = true;
btn.textContent = "⟳ Loading…";
btn.style.opacity = "0.7";
}
showStatus("ok", "⟳ Fetching data…");
const gymLoadPromise = loadGymsFromAPI(apiKey);
const apiPromise = apiKey.length===16 ? fetchFromAPI(stat) : Promise.resolve(null);
const domStat = tryAutoFillStat();
const domGymId = detectGymFromDOM();
const restoreBtn = (success) => {
if (!btn) return;
btn.disabled = origDisabled;
btn.textContent = origText;
btn.style.opacity = "1";
// Brief green/red flash so user sees the click registered
if (success) {
btn.style.transition = "background-color 0.5s ease-out, border-color 0.5s ease-out";
btn.style.backgroundColor = "#1a3a1a";
btn.style.borderColor = "#5a9f5a";
setTimeout(() => {
btn.style.backgroundColor = "";
btn.style.borderColor = "";
setTimeout(() => { btn.style.transition = ""; }, 600);
}, 50);
}
};
Promise.all([gymLoadPromise, apiPromise]).then(([_, api]) => {
if (api?.error) { showStatus("warn", `⚠ ${api.error}`); restoreBtn(false); return; }
const filled=[], missed=[];
if (api) {
// Gym from API gym_id — most reliable
if (api.gymId) {
const g = GYM_BY_ID[api.gymId];
setGymSelect(el.gym, String(api.gymId));
gymConfirmed = true;
if (g) {
el.gymBadge.textContent = "✓ API";
filled.push(g.name);
} else {
filled.push(`Gym #${api.gymId}`);
}
} else if (domGymId) {
setGymSelect(el.gym, String(domGymId));
gymConfirmed = true;
el.gymBadge.textContent = "✓ DOM";
}
// Stat
if (api.statValue) setStatValue(api.statValue);
else if (!domStat) missed.push(STAT_LABELS[stat]);
// Subscriber + happy + current energy
if (api.subscriber) { el.subscriber.value=api.subscriber; gg_save("subscriber",api.subscriber); }
if (api.propertyHappy) { el.happy.value=api.propertyHappy; gg_save("happy",String(api.propertyHappy)); }
if (api.currentEnergy != null && el.energy) {
el.energy.value = api.currentEnergy;
if (el.energyBadge) el.energyBadge.textContent = "✓ live";
filled.push(`Energy ${api.currentEnergy}`);
}
if (api.currentHappy != null) {
// Set hjBaseHappy to current happy for this session only — don't persist
el.hjBaseHappy.value = api.currentHappy;
}
// Perks
const PERK_FIELDS = {factionPerk:"factionPerk",propertyPerk:"propertyPerk",
eduStatPerk:"eduStatPerk",eduGenPerk:"eduGenPerk",jobPerk:"jobPerk",bookPerk:"bookPerk"};
let anyPerk = false;
for (const [k,ek] of Object.entries(PERK_FIELDS)) {
const v = Math.min(Math.round(api[k]||0), 100);
if (el[ek]) { el[ek].value=String(v); gg_save(k,String(v)); }
if (v>0) anyPerk=true;
}
if (anyPerk) { el.perksBadge.textContent="✓ API"; updateBonusSummary(); }
else el.perksBadge.textContent = "";
filled.push(...api.filled.filter(f => !filled.includes(f)));
} else {
// No API — DOM fallback
if (domGymId) {
const g = GYM_BY_ID[domGymId];
setGymSelect(el.gym, String(domGymId));
gymConfirmed = true;
el.gymBadge.textContent = "✓ DOM";
filled.push(g?.name || "Gym detected");
} else missed.push("gym");
if (domStat) filled.push(`${STAT_LABELS[stat]} ${domStat.toLocaleString()}`);
else missed.push(STAT_LABELS[stat]);
// Subscriber from page
const em = document.body.innerText.match(/\d+\s*\/\s*(\d+)\s*energy/i);
if (em) { const sub=parseInt(em[1])>=150?"yes":"no"; el.subscriber.value=sub; gg_save("subscriber",sub); }
missed.push("perks (add API key)");
}
// Scrape current energy from page for infobar (works with or without API)
const eMatch = document.body.innerText.match(/you have\s+(\d+)\s*\/\s*(\d+)\s*energy/i);
if (eMatch) {
updateInfobar(parseInt(eMatch[1]), parseInt(eMatch[2]), api?.currentHappy ?? null,
api?.subscriber === "yes", Date.now());
} else if (api?.currentHappy != null) {
updateInfobar(null, null, api.currentHappy, api.subscriber === "yes", Date.now());
}
_save("lastAutofillTs", Date.now());
updateDotsDisplay();
validateInputs();
updateBestGymPanel();
const msg = filled.length
? "✓ " + filled.join(" · ") + (missed.length?` — missing: ${missed.join(", ")}` : "")
: "⚠ Could not detect data. Check your API key or enter manually.";
showStatus(filled.length?"ok":"warn", msg);
// Onboarding: hide welcome banner, collapse profile panel after first successful autofill
if (filled.length) {
_save("hasAutofilled", "1");
const ob = q("#gg-onboard");
if (ob) ob.style.display = "none";
// Update profile summary to show what was filled
const ps = q("#gg-profile-summary");
if (ps) {
const stat = parseInt(el.statTotal?.value)||0;
const goal = parseInt(el.statGoal?.value)||0;
if (stat && goal) ps.textContent = `— ${fmt(stat)} → ${fmt(goal)}`;
}
}
if (apiKey.length===16) fetchPrices();
// Visible success feedback on the button
restoreBtn(filled.length > 0);
}).catch(e => {
showStatus("warn", `✗ Auto-fill error: ${e.message}`);
restoreBtn(false);
});
}
// ─────────────────────────────────────────────────────────────────────────
let switchBanner = null;
function showSwitchBanner(fromGym, toGym, statLabel) {
hideSwitchBanner();
switchBanner = document.createElement('div');
switchBanner.style.cssText = [
'position:fixed;top:60px;left:50%;transform:translateX(-50%)',
'background:#1a2a1a;border:1px solid #3a6a3a;border-radius:6px',
'padding:10px 16px;font-size:13px;color:#7abf7a',
'z-index:99999;max-width:90vw;text-align:center',
'box-shadow:0 4px 20px rgba(0,0,0,.6);font-family:Arial,sans-serif',
].join(';');
switchBanner.innerHTML = `
🏋️ <strong>Switched to ${toGym}</strong> for better ${statLabel} gains<br>
<span style="font-size:11px;color:#4a8a4a">(was: ${fromGym}) — tap Train again to train</span>`;
document.body.appendChild(switchBanner);
setTimeout(hideSwitchBanner, 5000);
}
function hideSwitchBanner() {
if (switchBanner) { switchBanner.remove(); switchBanner = null; }
}
// Map stat label text from the Torn UI to our stat keys
// Torn shows "Strength", "Speed", "Defense", "Dexterity" as headings above Train buttons
const TORN_STAT_LABELS = {
'strength':'str', 'speed':'spd', 'defense':'def', 'dexterity':'dex',
'str':'str', 'spd':'spd', 'def':'def', 'dex':'dex',
};
// Find which stat a Train button is for by looking at nearby headings
function getStatForTrainButton(btn) {
// TornPDA / Torn desktop: aria-label="Train speed", "Train strength" etc.
const ariaLabel = btn.getAttribute('aria-label') || '';
const ariaMatch = ariaLabel.match(/^Train\s+(strength|speed|defense|dexterity)/i);
if (ariaMatch) return STAT_KEYS[ariaMatch[1].toLowerCase()];
// Fallback: walk up the DOM looking for a stat label heading near the button
let node = btn;
for (let i = 0; i < 8; i++) {
node = node.parentElement;
if (!node) break;
const headings = node.querySelectorAll('h3, h4, strong, span, div');
for (const h of headings) {
if (wrap.contains(h)) continue;
const t = h.textContent.trim().toLowerCase().replace(/\s+gains?$/,'').trim();
if (TORN_STAT_LABELS[t]) return TORN_STAT_LABELS[t];
}
}
return null;
}
function findSwitchButtonForGym(gymName) {
const allBtns = document.querySelectorAll('button, a, [role="button"]');
for (const btn of allBtns) {
if (wrap.contains(btn)) continue;
const t = btn.textContent.trim().toLowerCase();
if (t !== 'switch' && t !== 'enter' && t !== 'use') continue;
const container = btn.closest('li, div, tr, [class*="gym"]') || btn.parentElement;
if (!container) continue;
if (container.textContent.toLowerCase().includes(gymName.toLowerCase())) return btn;
}
return null;
}
// ─────────────────────────────────────────────────────────────────────────
// PAGE READERS — extract live values (stat / energy / happy) from Torn DOM.
// All readers skip our own widget (`wrap`) so we don't read our own values.
// ─────────────────────────────────────────────────────────────────────────
// Shared text-walker. `predicate(text, parentEl)` returns a value (truthy →
// returned to caller) or null/undefined to keep walking.
function walkPageText(predicate) {
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode: n => wrap.contains(n.parentElement) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT
});
let node;
while ((node = walker.nextNode())) {
const result = predicate(node.textContent.trim(), node.parentElement);
if (result != null) return result;
}
return null;
}
// Read current stat value (e.g. SPD: 431,802) from the page
function readStatFromPage(statKey) {
const labelMap = { str:'STR', spd:'SPD', def:'DEF', dex:'DEX',
strength:'STR', speed:'SPD', defense:'DEF', dexterity:'DEX' };
const abbrev = labelMap[statKey] || statKey.toUpperCase().slice(0,3);
return walkPageText((t, parent) => {
if (t === abbrev) {
// Value usually in next sibling element
const sib = parent?.nextElementSibling;
if (sib) {
const v = parseFloat(sib.textContent.replace(/,/g,''));
if (v > 0) return v;
}
// Or as a number elsewhere in the same parent block
const grandparent = parent?.parentElement;
if (grandparent) {
const nums = grandparent.textContent.match(/[\d,]{3,}/g);
if (nums) {
const v = parseFloat(nums[0].replace(/,/g,''));
if (v > 0) return v;
}
}
}
// "STR 33,401" combined-text form
const m = t.match(new RegExp(`^${abbrev}\\s+([\\d,]+)$`));
if (m) return parseFloat(m[1].replace(/,/g,''));
return null;
});
}
// Read current energy from the page (e.g. "125 / 150")
function readEnergyFromPage() {
// TornPDA: <p class="bar-value___NTdce">125 / 150</p> in .energy___hsTnO
for (const node of document.querySelectorAll('[class*="energy"] [class*="bar-value"], [class*="bar-value"]')) {
if (wrap.contains(node)) continue;
const m = node.textContent.trim().match(/^(\d{1,4})\s*\/\s*\d{2,4}$/);
if (m) {
const v = parseInt(m[1]);
if (v >= 0 && v <= 1500) return v;
}
}
// Fallback: any "X / 150" or "X / 100" with energy-related ancestor
return walkPageText((t, parent) => {
const m = t.match(/^(\d{1,4})\s*\/\s*(150|100)$/);
if (m && parent?.closest('[class*="energy"], [id*="energy"]')) {
const v = parseInt(m[1]);
if (v >= 0 && v <= 1500) return v;
}
return null;
});
}
// Read current happy from the page (sidebar or top bar)
function readHappyFromPage() {
return walkPageText((t, parent) => {
if (!/^\d{2,6}$/.test(t.replace(/,/g,''))) return null;
const ctx = parent?.closest('[class*="happy"], [id*="happy"]') ||
parent?.previousElementSibling?.textContent?.toLowerCase().includes('happy') ||
parent?.parentElement?.textContent?.toLowerCase().includes('happiness');
if (!ctx) return null;
const v = parseInt(t.replace(/,/g,''));
return (v > 0 && v < 100000) ? v : null;
});
}
// ─────────────────────────────────────────────────────────────────────────
// SESSION CLASSIFIER — tag each logged session by what kind of train it was.
// ─────────────────────────────────────────────────────────────────────────
function classifySession(energy, happy, propHappy) {
if (energy == null || happy == null) return "unknown";
const happyMult = propHappy > 0 ? happy / propHappy : 1;
if (happyMult > JUMP_HAPPY_RATIO) {
return energy >= 500 ? "jump-full" : "jump-partial";
}
if (energy >= 500) return "xanax-stack"; // multiple xanax stacked
if (energy >= 200) return "xanax-burst"; // one xanax + regen
if (energy >= 140) return "refill-or-cap"; // points refill or full cap
if (energy >= 50) return "regen"; // partial natural regen
return "small"; // tail-end train
}
// ─────────────────────────────────────────────────────────────────────────
// AUTO LOGGER — captures gym sessions from result text + click snapshot
// Each train result is logged independently. Click-time snapshots provide
// authoritative energy/happy values; result text provides the gain.
// ─────────────────────────────────────────────────────────────────────────
function logTrainEvent(gain, newStat, statKey, energyUsed) {
if (!gain || !statKey) return;
if (!window._ggAutoLogEnabled) return;
// Click-gate: only log if a real TRAIN button was clicked recently.
// This eliminates ALL false positives from page-load event log replays —
// no click = no log.
if (!lastTrainClickTs || Date.now() - lastTrainClickTs > LOG_CLICK_GATE_MS) return;
const stat = Object.keys(STAT_KEYS).find(k => STAT_KEYS[k] === statKey) || statKey;
const gym = getGymData();
const gymDots = gym?.[STAT_KEYS[stat]] || 0;
if (!gymDots) return;
// Energy used: prefer the click-time snapshot from the page (authoritative —
// we know exactly what was on screen when TRAIN was pressed). Fall back to
// parsed-from-text energyUsed only if the snapshot is missing.
// This fixes the "1000E jump logged as 10E" bug.
const finalEnergyUsed = (lastTrainedEnergy != null && lastTrainedEnergy > 0)
? lastTrainedEnergy
: energyUsed;
// If newStat wasn't in the result text (TornPDA short format),
// try reading it from the page DOM. If that fails too, derive from
// the pre-train stat field in our calculator.
let resolvedNewStat = newStat;
if (!resolvedNewStat) {
resolvedNewStat = Math.round(readStatFromPage(STAT_KEYS[stat]) || 0);
}
if (!resolvedNewStat) {
// Last resort: use calculator's current stat + gain as an approximation
resolvedNewStat = Math.round(gf(el.statTotal) + gain);
}
const preStat = Math.round(resolvedNewStat - gain);
if (preStat <= 0) return;
// Happy: use the snapshot taken at TRAIN click (most accurate), fall back
// to live read, then fall back to propHappy field value.
const happyAtTrain = lastTrainedHappy || readHappyFromPage() || gf(el.happy) || 5025;
const entry = {
ts: Date.now(),
scriptVer: "3.34",
playerId: _load('playerId', null),
playerName: _load('playerName', null),
stat,
gymId: gym?.id,
gymName: gym?.name,
gymDots,
ePerTrain: gym?.energy || 10,
subscriber: el.subscriber?.value || "?",
preStat,
newStat: Math.round(resolvedNewStat),
happy: happyAtTrain,
propHappy: gf(el.happy) || 5025,
energyUsed: finalEnergyUsed,
actualGain: Math.round(gain),
sessionType: lastSessionType || "unknown",
// Individual perks — broken out for formula research
perkFaction: parseFloat(el.factionPerk?.value) || 0,
perkProperty: parseFloat(el.propertyPerk?.value) || 0,
perkEduStat: parseFloat(el.eduStatPerk?.value) || 0,
perkEduGen: parseFloat(el.eduGenPerk?.value) || 0,
perkJob: parseFloat(el.jobPerk?.value) || 0,
perkBook: parseFloat(el.bookPerk?.value) || 0,
perkSteroids: parseFloat(el.steroids?.value) || 0,
perkSneakers: parseFloat(el.sportsSneakers?.value) || 0,
bonus: calcBonus(stat), // combined multiplier (derived from above)
auto: true,
};
const entries = logPrune(logLoad());
// Deduplicate: skip if a very similar entry exists within last 30s
const isDupe = entries.some(e =>
e.stat === entry.stat &&
Math.abs(e.ts - entry.ts) < LOG_DEDUPE_MS &&
Math.abs(e.actualGain - entry.actualGain) < 100
);
if (isDupe) return;
entries.push(entry);
logSave(entries);
renderLogger();
const banner = document.createElement('div');
banner.style.cssText = [
'position:fixed;top:60px;left:50%;transform:translateX(-50%)',
'background:#141a14;border:1px solid #2a4a2a;border-radius:5px',
'padding:8px 14px;font-size:12px;color:#7abf7a;z-index:99999',
'font-family:Arial,sans-serif;box-shadow:0 3px 12px rgba(0,0,0,.5)',
].join(';');
const sessionLabel = {
"jump-full": "🎉 Happy Jump",
"jump-partial": "⚡ Jump train",
"xanax-stack": "💊 Xanax stack",
"xanax-burst": "💊 Xanax burst",
"refill-or-cap": "🔋 Refill/cap",
"regen": "🌿 Regen",
"small": "↳ Tail train",
"unknown": "📓",
}[entry.sessionType] || "📓";
banner.textContent = `${sessionLabel} +${fmt(entry.actualGain)} ${STAT_LABELS[stat]} (${finalEnergyUsed}E)`;
document.body.appendChild(banner);
setTimeout(() => banner.remove(), 4000);
}
// Watch the DOM for train result text — Torn uses several formats:
// "You used 1000 energy and 5 happiness training your dexterity 999 times
// in Racing Fitness increasing it by 31,887.00 to 453,315.00"
// "You have gained 35,158.00 dexterity" (TornPDA short form)
// "You gained 35,158 strength"
// Torn sometimes UPDATES existing text nodes (characterData mutation) rather than
// inserting new ones — we observe both to catch the refill result reliably.
const resultObserver = new MutationObserver(mutations => {
for (const mut of mutations) {
// Only childList mutations — new DOM insertions only
const nodes = [...mut.addedNodes];
for (const node of nodes) {
// Skip anything inside our own widget
const el_ = node.nodeType === 3 ? node.parentElement : node;
if (!el_) continue;
if (wrap.contains(el_)) continue;
// Skip results inside Torn's event log, notifications, or history panels.
// These contain old train results that would create false entries.
const inTornLog = el_.closest(
'[class*="log-"], [class*="-log"], [id*="log"],' +
'[class*="event"], [id*="event"],' +
'[class*="notification"], [class*="activity"],' +
'[class*="timeline"], [class*="history"]'
);
if (inTornLog) continue;
const text = node.textContent || '';
if (!text || text.length < 20) continue;
let gain = null, newStat = null, statKey = null, energyUsed = null;
// Pattern 1 — Desktop Torn full result (most reliable, contains all data):
// "You used 150 energy and 76 happiness training your speed 15 times
// in Complete Cardio increasing it by 2,200.52 to 326,067.73"
const mFull = text.match(/you used\s+([\d,]+)\s+energy[^.]*?training (?:your\s+)?(strength|speed|defense|dexterity)[^.]*?increasing it by\s+([\d,]+(?:\.\d+)?)\s+to\s+([\d,]+(?:\.\d+)?)/i);
if (mFull) {
energyUsed = parseInt(mFull[1].replace(/,/g,''));
statKey = STAT_KEYS[mFull[2].toLowerCase()];
gain = parseFloat(mFull[3].replace(/,/g,''));
newStat = Math.round(parseFloat(mFull[4].replace(/,/g,'')));
}
// Pattern 2 — TornPDA short result:
// "You gained 2,247.56 speed" or "You have gained 2,247.56 speed"
if (!gain) {
const mPDA = text.match(/you (?:have )?gained\s+([\d,]+(?:\.\d+)?)\s+(strength|speed|defense|dexterity)/i);
if (mPDA) {
gain = parseFloat(mPDA[1].replace(/,/g,''));
statKey = STAT_KEYS[mPDA[2].toLowerCase()];
// newStat not available — derive after logTrainEvent from preStat+gain
newStat = 0; // will be computed inside logTrainEvent
// Energy: use current gym's ePerTrain × trains in result if available
const mTrains = text.match(/(\d+)\s+times/i);
const gymE = getGymData()?.energy || 10;
energyUsed = mTrains ? parseInt(mTrains[1]) * gymE : gymE;
}
}
if (!gain || !statKey || !energyUsed) continue;
if (!newStat) newStat = Math.round(readStatFromPage(statKey) || 0);
logTrainEvent(gain, newStat, statKey, energyUsed);
}
}
});
resultObserver.observe(document.body, { childList: true, subtree: true });
// Track which stat was last clicked so we can attribute results correctly
let lastTrainedStat = null;
let lastTrainedHappy = null; // happy snapshotted at TRAIN click, before regen
let lastTrainedEnergy = null; // energy snapshotted at TRAIN click — authoritative
let lastTrainClickTs = null; // timestamp of last TRAIN button click — gate for auto-log
let lastSessionType = null; // classified session type ("jump-full", "xanax-burst", etc.)
function handleTrainClick(e, btn) {
// Snapshot stat AND happy AND energy at the moment of click — before the train
// processes and before any regen ticks. This is the only reliable moment.
const statKey = getStatForTrainButton(btn);
if (statKey) lastTrainedStat = statKey;
lastTrainedHappy = readHappyFromPage();
lastTrainedEnergy = readEnergyFromPage();
lastTrainClickTs = Date.now(); // arm the log gate
// Classify the session for context — fixes "10E logged for a 1000E jump" bug
const propH = gf(el.happy) || 5025;
lastSessionType = classifySession(lastTrainedEnergy, lastTrainedHappy, propH);
if (!switcherEnabled) return;
if (!statKey) return;
const statLabel = Object.keys(STAT_KEYS).find(k => STAT_KEYS[k] === statKey);
const currentPos = STANDARD_UNLOCK_ORDER[parseInt(el.gym.value)] ?? 0;
const manualOwned = JSON.parse(_load('manualGymOwned','[]'));
const owned = GYMS
.filter(g => {
const dots = g[statKey] || 0;
if (!dots) return false;
if (SPECIALIST_IDS.has(g.id)) return manualOwned.includes(g.id);
return (STANDARD_UNLOCK_ORDER[g.id] ?? 99) <= currentPos;
})
.sort((a, b) => (b[statKey]||0) - (a[statKey]||0));
if (!owned.length) return;
const bestGym = owned[0];
const currentId = detectGymFromDOM();
// Can't determine current gym — don't switch, would cause wrong behaviour
if (!currentId) return;
// Already in the best gym — let train proceed
if (currentId === bestGym.id) return;
const switchBtn = findSwitchButtonForGym(bestGym.name);
if (!switchBtn) {
showSwitchBanner(GYM_BY_ID[currentId]?.name || 'current gym', bestGym.name + ' (switch manually)', STAT_LABELS[statLabel]);
return;
}
e.preventDefault();
e.stopImmediatePropagation();
switchBtn.click();
showSwitchBanner(GYM_BY_ID[currentId]?.name || 'current gym', bestGym.name, STAT_LABELS[statLabel]);
setTimeout(() => {
const newGymId = detectGymFromDOM();
if (newGymId) {
setGymSelect(el.gym, String(newGymId));
gymConfirmed = true;
el.gymBadge.textContent = "✓ page";
updateDotsDisplay();
updateBestGymPanel();
}
}, 1500);
}
const hookedButtons = new WeakSet();
function hookTrainButtons() {
// Primary selector from TornPDA DOM analysis:
// <button type="button" class="torn-btn" aria-label="Train speed" disabled="">TRAIN</button>
// Also covers desktop Torn which uses the same aria-label pattern.
// We use aria-label^="Train" as the definitive hook — much more precise
// than matching text content which can catch unrelated buttons.
const byAria = document.querySelectorAll('button[aria-label^="Train"]');
for (const btn of byAria) {
if (wrap.contains(btn)) continue;
if (hookedButtons.has(btn)) continue;
hookedButtons.add(btn);
btn.addEventListener('click', e => handleTrainClick(e, btn), true);
}
// Fallback: text-content match for any platform that doesn't set aria-label
const byText = document.querySelectorAll('button.torn-btn, button[class*="train"], a[class*="train"]');
for (const btn of byText) {
if (wrap.contains(btn)) continue;
if (hookedButtons.has(btn)) continue;
const t = btn.textContent.trim().toUpperCase();
if (!t.startsWith('TRAIN')) continue;
hookedButtons.add(btn);
btn.addEventListener('click', e => handleTrainClick(e, btn), true);
}
}
setTimeout(hookTrainButtons, 500);
const trainObserver = new MutationObserver(() => hookTrainButtons());
trainObserver.observe(document.body, { childList: true, subtree: true });
// ─────────────────────────────────────────────────────────────────────────
// HELPERS
// ─────────────────────────────────────────────────────────────────────────
const gf = e => parseFloat(e?.value)||0;
const gi = e => parseInt(e?.value) ||0;
const fmt = n => Math.round(n).toLocaleString();
const fmtD = (n,d=2) => n.toFixed(d);
const getPerk = e => (parseFloat(e?.value)||0)/100;
const dateIn = n => { const d=new Date(); d.setDate(d.getDate()+Math.round(n)); return d.toLocaleDateString("en-GB",{day:"numeric",month:"short",year:"numeric"}); };
const row = (l,v,c="") => `<div class="gg-row ${c}"><span class="gg-rl">${l}</span><span class="gg-rv">${v}</span></div>`;
const rsec = l => `<div class="gg-rsec">${l}</div>`;
// Current gym data object
function getGymData() {
const id = parseInt(el.gym.value);
return GYM_BY_ID[id] || null;
}
// ─────────────────────────────────────────────────────────────────────────
// VLADAR FORMULA
// dS = (S*ROUND(1+0.07*ROUND(LN(1+H/250),4),4) + 8*H^1.05 + (1-(H/99999)^2)*A + B)
// * (1/200000) * G * E * perks
// S is actual stat (no cap since Aug 2022 stat cap removal)
// G = gym dots (already stored as actual value e.g. 5.2, not ×10)
// ─────────────────────────────────────────────────────────────────────────
function calcGain(dots, stat, happy, bonus, energy, consts) {
const H = happy;
const lnPart = Math.round((1 + 0.07 * Math.round(Math.log(1+H/250)*10000)/10000)*10000)/10000;
// FORMULA_CORRECTION (×1.027) is an empirical scalar derived from logged
// sessions where the raw Vladar formula consistently underpredicted by
// +2.70% (mean across 20+ entries, ±0.44% std).
return (stat*lnPart + 8*Math.pow(H,1.05) + (1-Math.pow(H/99999,2))*consts.A + consts.B)
* (1/200000) * dots * energy * bonus * FORMULA_CORRECTION;
}
// Happy decay per train: Vladar formula dH = ROUND((1/10) * E * RANDBETWEEN(4,6))
// Midpoint = E * 0.5 (i.e. 5 per 10E train, 12.5 per 25E train, etc.)
function happyDecay(energy) { return energy * 0.5; }
function simulateBlock(dots, startStat, startHappy, bonus, energy, consts, numTrains, happyFloor=0) {
let stat=startStat, happy=startHappy, gain=0;
for (let i=0; i<numTrains; i++) {
const g = calcGain(dots,stat,happy,bonus,energy,consts);
gain += g; stat += g;
happy = Math.max(happyFloor, happy - happyDecay(energy));
}
return { totalGain:gain, finalStat:stat, finalHappy:happy };
}
function calcBonus(stat) {
let m = (1+getPerk(el.factionPerk))*(1+getPerk(el.propertyPerk))*
(1+getPerk(el.eduStatPerk))*(1+getPerk(el.eduGenPerk))*
(1+getPerk(el.jobPerk))*(1+getPerk(el.bookPerk))*(1+getPerk(el.steroids));
if (stat==="speed") m *= (1+getPerk(el.sportsSneakers));
return m;
}
// ─────────────────────────────────────────────────────────────────────────
// DAILY GRIND
// ─────────────────────────────────────────────────────────────────────────
function calculateDaily() {
const gym = getGymData();
if (!gym) throw new Error("Invalid gym selection.");
const stat = el.stat.value;
const dots = gym[STAT_KEYS[stat]];
if (!dots) throw new Error(`${gym.name} does not train ${STAT_LABELS[stat]}.`);
const consts = STAT_CONSTS[stat];
const propHappy = gf(el.happy);
const bonus = calcBonus(stat);
const statNow = gf(el.statTotal);
const statGoal = gf(el.statGoal) || statNow;
const initEnergy = gf(el.energy);
const ePerTrain = gym.energy;
// ── "Right Now" snapshot — spends whatever energy you have currently ──
// This is separate from the daily total; just shows what this session gives.
const numTrains = Math.floor(initEnergy / ePerTrain);
const initBlock = simulateBlock(dots, statNow, propHappy, bonus, ePerTrain, consts, numTrains);
const singleGain = calcGain(dots, statNow, propHappy, bonus, ePerTrain, consts);
const errSingle = consts.C * (1/200000) * dots * ePerTrain * bonus;
// ── Daily total — natural regen (720E/day sub, 480E/day non-sub) + optional refill ──
// initEnergy is NOT added here — the daily regen is the base, always.
const natEPerHr = NATURAL_E[el.subscriber.value] || NATURAL_E.no;
const natEPerDay = natEPerHr * 24; // 720E sub, 480E non-sub
const natTrains = Math.floor(natEPerDay / ePerTrain);
const wastedE = natEPerDay - natTrains * ePerTrain;
const dayBlock = simulateBlock(dots, statNow, propHappy, bonus, ePerTrain, consts, natTrains, propHappy);
// Daily refill
const useRefill = el.dailyRefill.value === "yes";
const refillCost = gf(el.dailyRefillCost) || 1725000;
const refillE = E_CAP[el.subscriber.value];
const refillTrains = Math.floor(refillE / ePerTrain);
const refillBlock = useRefill
? simulateBlock(dots, dayBlock.finalStat, propHappy, bonus, ePerTrain, consts, refillTrains, propHappy)
: { totalGain:0 };
const totalDailyGain = dayBlock.totalGain + refillBlock.totalGain;
const dailyCostPerDay = useRefill ? refillCost : 0;
// Goal projection
let cur = initBlock.finalStat, days = 0;
while (cur < statGoal && days < MAX_ITER) {
const d = simulateBlock(dots,cur,propHappy,bonus,ePerTrain,consts,natTrains,propHappy);
cur = d.finalStat;
if (useRefill) cur += simulateBlock(dots,cur,propHappy,bonus,ePerTrain,consts,refillTrains,propHappy).totalGain;
days++;
}
const goalUnreach = days >= MAX_ITER;
const dailyCostToGoal = dailyCostPerDay * days;
const gymName = gym.name;
const remaining = Math.max(0, statGoal - statNow);
const pctDone = statGoal > 0 ? ((statNow/statGoal)*100).toFixed(1) : "—";
let html = `<div class="gg-tldr">
<div class="gg-tldr-title">📊 Daily Grind — ${STAT_LABELS[stat]} at ${gymName}</div>
<div class="gg-tldr-grid">
<div class="gg-tldr-cell"><div class="gg-tldr-val">+${fmtD(singleGain,1)}</div><div class="gg-tldr-lbl">per train</div></div>
<div class="gg-tldr-cell"><div class="gg-tldr-val">+${fmt(totalDailyGain)}</div><div class="gg-tldr-lbl">per day${useRefill?" (regen+refill)":""}</div></div>
<div class="gg-tldr-cell"><div class="gg-tldr-val">${goalUnreach?"∞":`~${days}d`}</div><div class="gg-tldr-lbl">days to goal</div></div>
<div class="gg-tldr-cell"><div class="gg-tldr-val">${goalUnreach?"—":dateIn(days)}</div><div class="gg-tldr-lbl">done by</div></div>
</div>
</div>`;
// ── Snapshot ──────────────────────────────────────────────────────────
html += rsec("Your Stats Right Now");
html += row(`${STAT_LABELS[stat]}`, `${fmt(statNow)} (${pctDone}% of goal)`);
html += row("Goal", `${fmt(statGoal)} — need ${fmt(remaining)} more`);
html += row("Happy", `${fmt(propHappy)} — property max happy`);
html += row("Total bonus", `${((bonus-1)*100).toFixed(1)}% from all perks combined`);
// ── What you get per train ────────────────────────────────────────────
html += rsec("Per Train");
html += row("Gain", `~${fmtD(singleGain,2)} ${STAT_LABELS[stat]} per ${ePerTrain}E train`, "hi");
html += row("Range", `${fmtD(singleGain-errSingle,2)} – ${fmtD(singleGain+errSingle,2)} (±${fmtD(errSingle,2)} random variance)`);
// ── Current energy snapshot ───────────────────────────────────────────
if (numTrains > 0) {
html += rsec(`This Session — Spending Your ${initEnergy}E Now`);
html += row("Trains", `${numTrains} trains × ${ePerTrain}E`);
html += row("Gain", `+${fmtD(initBlock.totalGain,1)} ${STAT_LABELS[stat]}`, "hi");
html += row("New stat", `${fmt(initBlock.finalStat)} after this session`);
}
// ── Daily natural regen ───────────────────────────────────────────────
html += rsec(`Daily Natural Regen — ${natEPerDay}E/day (${natEPerHr}E/hr × 24h)`);
html += row("Trains/day", `${natTrains} trains × ${ePerTrain}E`);
if (wastedE > 0) html += row("Wasted energy", `${wastedE}E/day lost — regen doesn't divide evenly into ${ePerTrain}E trains`, "a");
html += row("Gain/day", `+${fmt(dayBlock.totalGain)} ${STAT_LABELS[stat]}/day from natural regen`, "hi");
// ── Daily refill ──────────────────────────────────────────────────────
if (useRefill) {
html += rsec("Points Refill (+150E once/day)");
html += row("Extra trains", `${refillTrains} trains × ${ePerTrain}E`);
html += row("Extra gain", `+${fmt(refillBlock.totalGain)} ${STAT_LABELS[stat]}`, "hi");
html += row("Cost", `$${fmt(refillCost)}/day`, "b");
}
// ── Total ─────────────────────────────────────────────────────────────
html += rsec("Daily Total");
html += row("Gain/day", `+${fmt(totalDailyGain)} ${STAT_LABELS[stat]}${useRefill?" (regen + refill)":""}`, "hi");
if (useRefill) html += row("Cost/day", `$${fmt(dailyCostPerDay)}`, "b");
// ── Goal projection ───────────────────────────────────────────────────
html += rsec("Reaching Your Goal");
if (goalUnreach) {
html += row("⚠ Unreachable", "Increase your happy or add a refill — gains are too small vs your goal", "a");
} else {
html += row("Days needed", `~${days} days`, "g");
html += row("Finish date", dateIn(days), "g");
if (useRefill) html += row("Total spend", `$${fmt(dailyCostToGoal)} in Points refills over ${days} days`, "b");
html += row("Tip", days > 30 ? "Consider Happy Jumps — they can cut this time significantly" : "You're close! Keep grinding 💪", "");
}
el.dailyResults.innerHTML = html;
el.dailyResults.style.display = "";
el.jumpResults.style.display = "none";
el.compareResults.style.display = "none";
el.copy.style.display = "inline-block";
el._lastResults = html;
}
// ─────────────────────────────────────────────────────────────────────────
// HAPPY JUMP
// ─────────────────────────────────────────────────────────────────────────
// ── Shared jump parameter extraction ───────────────────────────────────────
function readJumpParams() {
const gym = getGymData();
if (!gym) throw new Error("Invalid gym selection.");
const stat = el.stat.value;
const dots = gym[STAT_KEYS[stat]];
if (!dots) throw new Error(`${gym.name} does not train ${STAT_LABELS[stat]}.`);
const energyCap = E_CAP[el.subscriber.value];
const natEPerHr = NATURAL_E[el.subscriber.value];
const propHappy = gf(el.happy);
const statNow = gf(el.statTotal);
const statGoal = gf(el.statGoal) || statNow;
const bonus = calcBonus(stat);
const ePerTrain = gym.energy;
// Energy sources
const xanaxCount = gi(el.hjXanaxCount);
const fhcCount = gi(el.hjFHC);
const lsdCount = gi(el.hjLSD);
const canCount = gi(el.hjCans);
const canTypeIdx = gi(el.hjCanType);
const canFacPerk = gi(el.hjCanFactionPerk) / 100;
const canType = canTypeIdx > 0 ? CAN_TYPES[canTypeIdx - 1] : null;
const canEach = canType ? Math.round(canType.e * (1 + canFacPerk)) : 0;
const canCD = canType ? canType.cd : 2;
// Happy boosters
const edvdCount = gi(el.hjEDVDs);
const anJob = el.hjANJob.value === "yes";
const useEcstasy = el.hjEcstasy.value === "yes";
const useRefill = el.hjRefill.value === "yes";
const voracity = gi(el.hjVoracity);
const fhcCount_ = fhcCount; // alias for clarity
const fhcHappyBoost = fhcCount * FHC_HAPPY;
const candyCount = gi(el.hjCandies);
const candyTypeIdx = gi(el.hjCandyType);
const candyType = candyTypeIdx > 0 ? CANDY_TYPES[candyTypeIdx - 1] : null;
const candyVorPct = gi(el.hjCandyVoracity) / 100;
const candyAbsPct = el.hjCandyAbsorption?.value === "yes" ? 0.10 : 0;
const candyMult = 1 + candyVorPct + candyAbsPct;
const candyHappyEach= candyType ? Math.round(candyType.happy * candyMult) : 0;
const candyHappyTotal = candyCount * candyHappyEach;
// Happy calculation
const hjBase = gf(el.hjBaseHappy);
const baseHappy = hjBase > 0 ? hjBase : propHappy;
const edvdHappyPer = anJob ? EDVD_HAPPY * 2 : EDVD_HAPPY;
let jumpHappy = baseHappy + edvdCount * edvdHappyPer + fhcHappyBoost + candyHappyTotal;
if (useEcstasy) jumpHappy = Math.min(jumpHappy * 2, 99999);
// Energy calculation
const xanaxEnergy = xanaxCount === 4 ? 1000 : Math.min(1000, energyCap + xanaxCount * 250);
const xanStackE = Math.min(1000, xanaxEnergy + lsdCount * 50 + canCount * canEach);
const fhcEnergy = fhcCount * energyCap;
const stackTrains = Math.floor(xanStackE / ePerTrain);
const fhcTrains = Math.floor(fhcEnergy / ePerTrain);
// CD calculation
const maxCD = 24 + voracity;
const xanCD = XAN_CD_AVG * xanaxCount;
const edvdCDTotal = edvdCount * EDVD_CD;
const fhcCDTotal = fhcCount * FHC_CD;
const canCDTotal = canCount * canCD;
const candyCDTotal = candyCount * CANDY_CD_HRS;
const effectiveCD = Math.min(xanCD + edvdCDTotal + fhcCDTotal + canCDTotal + candyCDTotal, maxCD);
const cycleBest = Math.min(XAN_CD_MIN * xanaxCount + edvdCDTotal + fhcCDTotal + canCDTotal + candyCDTotal, maxCD) + (useRefill ? energyCap / natEPerHr : 0);
const cycleWorst = Math.min(XAN_CD_MAX * xanaxCount + edvdCDTotal + fhcCDTotal + canCDTotal + candyCDTotal, maxCD) + (useRefill ? energyCap / natEPerHr : 0);
const cycleAvg = effectiveCD + (useRefill ? energyCap / natEPerHr : 0);
// Costs
const xanaxCost = gf(el.hjXanaxCost);
const edvdCost = gf(el.hjEDVDCost);
const ecstasyCost = useEcstasy ? gf(el.hjEcstasyCost) : 0;
const fhcCost = gf(el.hjFHCCost);
const lsdCost = gf(el.hjLSDCost);
const canCost = canCount > 0 ? gf(el.hjCanCost) : 0;
const candyCost = candyCount > 0 ? gf(el.hjCandyCost) : 0;
const costTotal = xanaxCount * xanaxCost + edvdCount * edvdCost + ecstasyCost
+ fhcCount * fhcCost + lsdCount * lsdCost
+ canCount * canCost + candyCount * candyCost
+ (useRefill ? 1725000 : 0);
return {
gym, stat, dots, energyCap, natEPerHr, propHappy, statNow, statGoal,
bonus, ePerTrain, xanaxCount, fhcCount, lsdCount, canCount, canType,
canEach, canCD, edvdCount, anJob, useEcstasy, useRefill, voracity,
fhcHappyBoost, candyCount, candyType, candyHappyTotal, candyHappyEach,
baseHappy, jumpHappy, xanaxEnergy, xanStackE, fhcEnergy,
stackTrains, fhcTrains, maxCD, xanCD, edvdCDTotal, fhcCDTotal,
canCDTotal, candyCDTotal, effectiveCD, cycleBest, cycleWorst, cycleAvg,
xanaxCost, edvdCost, ecstasyCost, fhcCost, lsdCost, canCost, candyCost,
costTotal, edvdHappyPer, canFacPerk, candyMult,
};
}
function calculateHappyJump() {
const p = readJumpParams();
const { gym, stat, dots, energyCap, natEPerHr, propHappy, statNow, statGoal,
bonus, ePerTrain, xanaxCount, fhcCount, lsdCount, canCount, canType,
canEach, edvdCount, useEcstasy, useRefill, fhcHappyBoost,
candyCount, candyType, candyHappyTotal, baseHappy, jumpHappy,
xanStackE, fhcEnergy, stackTrains, fhcTrains, maxCD, xanCD,
edvdCDTotal, fhcCDTotal, canCDTotal, candyCDTotal, effectiveCD,
cycleBest, cycleWorst, cycleAvg, xanaxCost, edvdCost, ecstasyCost,
fhcCost, lsdCost, canCost, candyCost, costTotal, edvdHappyPer } = p;
const consts = STAT_CONSTS[stat];
// Simulate gains
const stackBlock = simulateBlock(dots, statNow, jumpHappy, bonus, ePerTrain, consts, stackTrains);
const fhcBlock = fhcCount > 0
? simulateBlock(dots, statNow + stackBlock.totalGain, stackBlock.finalHappy + fhcHappyBoost, bonus, ePerTrain, consts, fhcTrains)
: { totalGain: 0 };
const gainStack = stackBlock.totalGain + fhcBlock.totalGain;
const gainRefill = useRefill
? simulateBlock(dots, statNow + gainStack, propHappy, bonus, ePerTrain, consts, Math.floor(energyCap / ePerTrain)).totalGain
: 0;
const gainTotal = gainStack + gainRefill;
const jumpsPerWeek = (24 * 7) / cycleAvg;
const weeklyGain = gainTotal * jumpsPerWeek;
const costPerKStat = gainTotal > 0 ? (costTotal / gainTotal) * 1000 : 0;
// Goal projection
let jJumps = 0, cur = statNow;
while (cur < statGoal && jJumps < MAX_ITER) {
const sb = simulateBlock(dots, cur, jumpHappy, bonus, ePerTrain, consts, stackTrains);
const fb = fhcCount > 0 ? simulateBlock(dots, cur + sb.totalGain, sb.finalHappy + fhcHappyBoost, bonus, ePerTrain, consts, fhcTrains) : { totalGain: 0 };
cur += sb.totalGain + fb.totalGain;
if (useRefill) cur += simulateBlock(dots, cur, propHappy, bonus, ePerTrain, consts, Math.floor(energyCap / ePerTrain)).totalGain;
jJumps++;
}
const jumpGoalUnreach = jJumps >= MAX_ITER;
const daysToGoal = jJumps * (cycleAvg / 24);
const totalCost = costTotal * jJumps;
// OD risk
const xODBase = parseFloat(el.hjXanaxOD.value) / 100 || 0.03;
const eODBase = parseFloat(el.hjEcstasyOD.value) / 100 || 0.05;
const tolPct = parseFloat(el.hjToleration.value) / 100 || 0;
const odMult = (1 - tolPct) * (el.hjNightclub.value === "yes" ? 0.5 : 1);
const xODEff = xODBase * odMult;
const eODEff = eODBase * odMult;
const pClean = Math.pow(1 - xODEff, xanaxCount) * (useEcstasy ? 1 - eODEff : 1);
const odExtraCost = pClean > 0 ? (1/pClean - 1) * costTotal : 0;
// Energy/happy summary
const gymName = gym.name;
const statLabel = STAT_LABELS[stat];
const energyParts = [];
if (xanaxCount > 0) energyParts.push(`${xanaxCount}× Xanax (${p.xanaxEnergy}E)`);
if (lsdCount > 0) energyParts.push(`${lsdCount}× LSD (+${lsdCount*50}E)`);
if (canCount > 0) energyParts.push(`${canCount}× ${canType?.label||'cans'} (+${canCount*canEach}E)`);
if (fhcCount > 0) energyParts.push(`${fhcCount}× FHC (${fhcEnergy}E + +${fhcHappyBoost} happy)`);
if (useRefill) energyParts.push(`Points refill (+${energyCap}E)`);
if (candyCount > 0) energyParts.push(`${candyCount}× ${candyType?.label||'candy'} (+${candyHappyTotal} happy)`);
const happyParts = [`${Math.round(baseHappy)} base`];
if (edvdCount > 0) happyParts.push(`+${edvdCount}×eDVD (+${edvdCount*edvdHappyPer})`);
if (fhcCount > 0) happyParts.push(`+${fhcCount}×FHC (+${fhcHappyBoost})`);
if (candyCount > 0) happyParts.push(`+${candyCount}×${candyType?.label||'candy'} (+${candyHappyTotal})`);
if (useEcstasy) happyParts.push(`×2 ecstasy`);
let html = `<div class="gg-tldr">
<div class="gg-tldr-title">⚡ Happy Jump — ${statLabel} at ${gymName}</div>
<div class="gg-tldr-grid">
<div class="gg-tldr-cell"><div class="gg-tldr-val">+${fmtD(gainTotal,1)}</div><div class="gg-tldr-lbl">per jump</div></div>
<div class="gg-tldr-cell"><div class="gg-tldr-val">+${fmt(weeklyGain)}</div><div class="gg-tldr-lbl">per week</div></div>
<div class="gg-tldr-cell"><div class="gg-tldr-val">${jumpGoalUnreach?"∞":`~${fmtD(daysToGoal,1)}d`}</div><div class="gg-tldr-lbl">days to goal</div></div>
<div class="gg-tldr-cell"><div class="gg-tldr-val">$${fmt(costTotal)}</div><div class="gg-tldr-lbl">per jump</div></div>
</div>
</div>`;
html += rsec("Energy Setup");
html += row("Sources", energyParts.join(', ') || 'Natural regen only');
html += row("Total energy", `${fmt(p.xanStackE + fhcEnergy)}E (${Math.floor((p.xanStackE + fhcEnergy)/ePerTrain)} trains)`);
html += rsec("Happy");
html += row("Breakdown", happyParts.join(' '));
html += row("Jump happy", `${fmt(jumpHappy)}`, "hi");
html += rsec("Gains Per Jump");
if (stackTrains > 0) html += row("Main stack", `+${fmtD(stackBlock.totalGain,1)} (${stackTrains} trains at ${fmt(jumpHappy)} happy)`, "hi");
if (fhcCount > 0) html += row("FHC trains", `+${fmtD(fhcBlock.totalGain,1)} (${fhcTrains} trains)`, "hi");
if (useRefill) html += row("Refill", `+${fmtD(gainRefill,1)} (${Math.floor(energyCap/ePerTrain)} trains at ${fmt(propHappy)})`, "hi");
html += row("Total / jump", `+${fmtD(gainTotal,1)} ${statLabel}`, "hi");
html += row("Stat after", fmt(statNow + gainTotal));
html += rsec("Cycle Timing");
const cdParts = [xanaxCount>0?`Xanax:${xanCD}h`:null, edvdCount>0?`eDVD:${edvdCDTotal}h`:null,
fhcCount>0?`FHC:${fhcCDTotal}h`:null, canCount>0?`Cans:${canCDTotal}h`:null,
candyCount>0?`Candy:${candyCDTotal}h`:null].filter(Boolean);
html += row("CD breakdown", cdParts.join(' + ') || '—');
html += row("Effective CD", `${fmtD(effectiveCD,1)}h (cap ${maxCD}h)`);
if (useRefill) html += row("Refill wait", `+${fmtD(energyCap/natEPerHr,1)}h`);
html += row("Cycle avg", `~${fmtD(cycleAvg,1)}h (best:${fmtD(cycleBest,1)} worst:${fmtD(cycleWorst,1)})`);
html += row("Jumps/week", `~${fmtD(jumpsPerWeek,2)}`);
html += row("Gain/week", `+${fmt(weeklyGain)} ${statLabel}`, "hi");
html += rsec("Cost Per Jump");
if (xanaxCount > 0) html += row("Xanax", `${xanaxCount}× $${fmt(xanaxCost)} = $${fmt(xanaxCount*xanaxCost)}`);
if (edvdCount > 0) html += row("eDVD", `${edvdCount}× $${fmt(edvdCost)} = $${fmt(edvdCount*edvdCost)}`);
if (useEcstasy) html += row("Ecstasy", `$${fmt(ecstasyCost)}`);
if (fhcCount > 0) html += row("FHC", `${fhcCount}× $${fmt(fhcCost)} = $${fmt(fhcCount*fhcCost)}`);
if (lsdCount > 0) html += row("LSD", `${lsdCount}× $${fmt(lsdCost)} = $${fmt(lsdCount*lsdCost)}`);
if (canCount > 0) html += row("Cans", `${canCount}× $${fmt(canCost)} = $${fmt(canCount*canCost)}`);
if (candyCount > 0) html += row("Candy", `${candyCount}× ${candyType?.label||''} $${fmt(candyCost)} = $${fmt(candyCount*candyCost)}`);
if (useRefill) html += row("Refill", "$1,725,000");
html += row("Total", `$${fmt(costTotal)}`, "b");
html += row("Per 1k stat", `$${fmt(costPerKStat)}`);
html += rsec("Goal: " + fmt(statGoal));
if (jumpGoalUnreach) {
html += row("⚠ Unreachable", "Add more energy/happy sources.", "a");
} else {
html += row("Jumps needed", `${jJumps}`, "g");
html += row("Time", `~${fmtD(daysToGoal,1)} days`, "g");
html += row("Finish date", dateIn(daysToGoal), "g");
html += row("Total spend", `$${fmt(totalCost)}`, "b");
}
if (xanaxCount > 0) {
html += rsec("OD Risk ⚠ Community Estimates");
html += row("Per-Xanax OD%", `${(xODEff*100).toFixed(2)}%`);
if (useEcstasy) html += row("Ecstasy OD%", `${(eODEff*100).toFixed(2)}%`);
html += row("Clean jump odds", `${(pClean*100).toFixed(1)}%`);
html += row("OD cost impact", `~$${fmt(odExtraCost)} extra/jump avg`, "a");
html += row("Effective gain/wk", `~${fmt(weeklyGain*pClean)} after OD probability`, "hi");
}
el.jumpResults.innerHTML = html;
el.jumpResults.style.display = "";
el.dailyResults.style.display = "none";
el.compareResults.style.display = "none";
el.copy.style.display = "inline-block";
el._lastResults = html;
}
function calculateCompare() {
const p = readJumpParams();
const { gym, stat, dots, energyCap, natEPerHr, propHappy, statNow, statGoal,
bonus, ePerTrain, xanaxCount, fhcCount, canCount,
canEach, edvdCount, anJob, useEcstasy, useRefill, fhcHappyBoost,
candyCount, candyType, candyHappyTotal, jumpHappy,
xanStackE, fhcEnergy, stackTrains, fhcTrains,
edvdCDTotal, fhcCDTotal, canCDTotal, candyCDTotal, cycleAvg,
costTotal } = p;
const consts = STAT_CONSTS[stat];
const statLabel = STAT_LABELS[stat];
// ── Daily Grind ───────────────────────────────────────────────────────
// Daily Grind = pure natural regen across 24h + optional Points Refill.
// The "Current energy" field is irrelevant here (that's a one-time snapshot,
// not a long-term training pattern). Compare must reflect long-term steady
// state, otherwise typing 99999 in Current Energy would project absurd gains.
const dailyUseRefill = el.dailyRefill.value === "yes";
const dailyRefillCost = dailyUseRefill ? gf(el.dailyRefillCost) : 0;
const dailyRefillTrains = Math.floor(energyCap / ePerTrain);
const dailyNatTrains = Math.floor(natEPerHr * 24 / ePerTrain);
// Simulate one full Daily Grind day: full natural regen + optional refill
const _dailyNat = simulateBlock(dots, statNow, propHappy, bonus, ePerTrain, consts, dailyNatTrains, propHappy);
const _dailyRefill = dailyUseRefill
? simulateBlock(dots, _dailyNat.finalStat, propHappy, bonus, ePerTrain, consts, dailyRefillTrains, propHappy)
: { totalGain: 0 };
const gainDaily = _dailyNat.totalGain + _dailyRefill.totalGain;
const weeklyDaily = gainDaily * 7;
const cycleDaily = 24; // one cycle = one day
// ── Happy Jump ────────────────────────────────────────────────────────
const _stackRes = simulateBlock(dots, statNow, jumpHappy, bonus, ePerTrain, consts, stackTrains);
const gainStack = _stackRes.totalGain;
const gainFHC = fhcCount > 0 ? simulateBlock(dots, statNow+gainStack, _stackRes.finalHappy+fhcHappyBoost, bonus, ePerTrain, consts, fhcTrains).totalGain : 0;
const gainRefill = useRefill ? simulateBlock(dots, statNow+gainStack+gainFHC, propHappy, bonus, ePerTrain, consts, Math.floor(energyCap/ePerTrain)).totalGain : 0;
const gainJump = gainStack + gainFHC + gainRefill;
const weeklyJump = gainJump * (24 * 7 / cycleAvg);
// ── Xanax Grind ───────────────────────────────────────────────────────
// Same model as calculateXanaxGrind: each CD cycle has two trains:
// 1. mid-CD nat (150E fills in 5h, CD is 7h avg)
// 2. Xanax burst (2h × 30E/hr = 60E + 250E = 310E)
const xgCount = gi(el.xgXanaxCount);
const xgCost = gf(el.xgXanaxCost);
const xgUseRefill = el.xgRefill?.value === "yes";
const xgRefillCost = gf(el.xgRefillCost) || 1725000;
const xgRefillTrains = Math.floor(energyCap / ePerTrain);
const xgCdHrs = xgCount >= 4 ? XAN_CD_MIN : XAN_CD_AVG;
const xgFillHrs = energyCap / natEPerHr;
const xgMidCdTrains = Math.floor(energyCap / ePerTrain);
const xgAtXanE = Math.min(energyCap, (xgCdHrs - xgFillHrs) * natEPerHr);
const xgCycleE = xgAtXanE + 250;
const xgCycleTrains = Math.floor(xgCycleE / ePerTrain);
const xgHrsUsed = xgCount * xgCdHrs;
const xgRemainHrs = Math.max(0, 24 - xgHrsUsed);
const xgNatLeftoverE = Math.min(xgRemainHrs * natEPerHr, energyCap);
const xgLeftoverTrains = Math.floor(xgNatLeftoverE / ePerTrain);
let xgGainXan = 0, xgGainMid = 0, xgCur = statNow;
for (let i = 0; i < xgCount; i++) {
const mb = simulateBlock(dots, xgCur, propHappy, bonus, ePerTrain, consts, xgMidCdTrains, propHappy);
xgGainMid += mb.totalGain; xgCur = mb.finalStat;
const xb = simulateBlock(dots, xgCur, propHappy, bonus, ePerTrain, consts, xgCycleTrains, propHappy);
xgGainXan += xb.totalGain; xgCur = xb.finalStat;
}
const xgGainRefill = xgUseRefill ? simulateBlock(dots, xgCur, propHappy, bonus, ePerTrain, consts, xgRefillTrains, propHappy).totalGain : 0;
if (xgUseRefill) xgCur += xgGainRefill;
const xgGainNat = simulateBlock(dots, xgCur, propHappy, bonus, ePerTrain, consts, xgLeftoverTrains, propHappy).totalGain;
const gainXG = xgGainXan + xgGainMid + xgGainRefill + xgGainNat;
const weeklyXG = gainXG * 7;
const costXG = xgCount * xgCost + (xgUseRefill ? xgRefillCost : 0);
// ── Goal projections ──────────────────────────────────────────────────
let dJumps=0, dCur=statNow;
while (dCur<statGoal && dJumps<MAX_ITER) {
const _n = simulateBlock(dots,dCur,propHappy,bonus,ePerTrain,consts,dailyNatTrains,propHappy);
dCur = _n.finalStat;
if (dailyUseRefill) dCur += simulateBlock(dots,dCur,propHappy,bonus,ePerTrain,consts,dailyRefillTrains,propHappy).totalGain;
dJumps++;
}
let jJumps=0, jCur=statNow;
while (jCur<statGoal && jJumps<MAX_ITER) {
const sb = simulateBlock(dots,jCur,jumpHappy,bonus,ePerTrain,consts,stackTrains);
const fb = fhcCount>0 ? simulateBlock(dots,jCur+sb.totalGain,sb.finalHappy+fhcHappyBoost,bonus,ePerTrain,consts,fhcTrains) : {totalGain:0};
jCur += sb.totalGain + fb.totalGain;
if (useRefill) jCur += simulateBlock(dots,jCur,propHappy,bonus,ePerTrain,consts,Math.floor(energyCap/ePerTrain)).totalGain;
jJumps++;
}
let xgDays=0, xgDayCur=statNow;
while (xgDayCur<statGoal && xgDays<MAX_ITER) {
let s=xgDayCur;
for (let i=0; i<xgCount; i++) {
s+=simulateBlock(dots,s,propHappy,bonus,ePerTrain,consts,xgMidCdTrains,propHappy).totalGain;
s+=simulateBlock(dots,s,propHappy,bonus,ePerTrain,consts,xgCycleTrains,propHappy).totalGain;
}
if (xgUseRefill) s+=simulateBlock(dots,s,propHappy,bonus,ePerTrain,consts,xgRefillTrains,propHappy).totalGain;
s+=simulateBlock(dots,s,propHappy,bonus,ePerTrain,consts,xgLeftoverTrains,propHappy).totalGain;
xgDayCur=s; xgDays++;
}
const daysDaily = dJumps * (cycleDaily / 24);
const daysJump = jJumps * (cycleAvg / 24);
// xgDays already in days
// ── Build 3-column table ──────────────────────────────────────────────
const best3 = (d, j, x, lowerBetter=false) => {
const vals = [d, j, x];
const best = lowerBetter ? Math.min(...vals.filter(v=>isFinite(v))) : Math.max(...vals.filter(v=>isFinite(v)));
return vals.map(v => v === best && isFinite(v));
};
const cell = (text, win) => `<div class="gg-cmp-val${win?" win":" lose"}">${text}</div>`;
const xgLabel = xgCount > 0 ? `💊 Xanax Grind (${xgCount}/day)` : "💊 Xanax Grind";
let html = `<div class="gg-cmp">
<div class="gg-cmp-title">⚖ Training Method Comparison — ${statLabel} at ${gym.name}</div>
<div class="gg-cmp-grid">
<div class="gg-cmp-hdr"></div>
<div class="gg-cmp-hdr daily">📅 Daily<br>Grind</div>
<div class="gg-cmp-hdr jump">⚡ Happy<br>Jump</div>
<div class="gg-cmp-hdr" style="color:#bf9f7a">💊 Xanax<br>Grind</div>`;
const rows_ = [
{ label:"Gain / session", vals:[gainDaily, gainJump, gainXG], fmt_:(v)=>`+${fmtD(v,1)}`, lower:false },
{ label:"Gain / week", vals:[weeklyDaily, weeklyJump, weeklyXG], fmt_:(v)=>`+${fmt(v)}`, lower:false },
{ label:"Cost / session", vals:[dailyRefillCost, costTotal, costXG], fmt_:(v)=>v===0?"free":`$${fmt(v)}`, lower:true },
{ label:"Sessions to goal", vals:[dJumps, jJumps, xgDays], fmt_:(v)=>v>=MAX_ITER?"∞":String(v), lower:true },
{ label:"Days to goal", vals:[daysDaily, daysJump, xgDays], fmt_:(v)=>v>=MAX_ITER?"∞":`${fmtD(v,1)}d`, lower:true },
];
// Total cost to goal
const totalCostDaily = dailyRefillCost * dJumps;
const totalCostJump = costTotal * jJumps;
const totalCostXG = costXG * xgDays;
rows_.push({ label:"Total cost to goal", vals:[totalCostDaily, totalCostJump, totalCostXG], fmt_:(v)=>v===0?"free":`$${fmt(v)}`, lower:true });
for (const {label, vals, fmt_, lower} of rows_) {
const wins = best3(vals[0], vals[1], vals[2], lower);
html += `<div class="gg-cmp-label">${label}</div>
${cell(fmt_(vals[0]), wins[0])}${cell(fmt_(vals[1]), wins[1])}${cell(fmt_(vals[2]), wins[2])}`;
}
html += `</div>`;
// ── Verdict — factor in both speed AND cost ──────────────────────────
const weekly = [weeklyDaily, weeklyJump, weeklyXG];
const totalCosts = [totalCostDaily, totalCostJump, totalCostXG];
const days = [daysDaily, daysJump, xgDays];
const names = ["Daily Grind", "Happy Jump", "Xanax Grind"];
const speedWinIdx = days.indexOf(Math.min(...days.filter(d => d < MAX_ITER)));
const costWinIdx = totalCosts.indexOf(Math.min(...totalCosts.filter((_,i) => days[i] < MAX_ITER)));
const hasCosts = totalCosts.some(c => c > 0);
// Cost-efficiency: stat gained per $1M spent
const efficiency = weekly.map((w, i) => {
const dailyCostForMethod = totalCosts[i] / Math.max(days[i], 1); // cost per day
return dailyCostForMethod > 0 ? (w / (dailyCostForMethod / 1e6)) : Infinity; // stat/week per $1M/day
});
const effWinIdx = efficiency.indexOf(Math.max(...efficiency));
let verdictLines = names.map((n, i) => {
const dStr = days[i] >= MAX_ITER ? "∞" : `${fmtD(days[i],1)}d`;
const cStr = totalCosts[i] > 0 ? ` · $${fmt(Math.round(totalCosts[i]))} total` : "";
const wStr = `${fmt(Math.round(weekly[i]))} ${statLabel}/week`;
const badges = [];
if (i === speedWinIdx) badges.push("⚡ fastest");
if (i === costWinIdx && hasCosts) badges.push("💰 cheapest");
if (i === effWinIdx && hasCosts) badges.push("📈 best value");
const badgeStr = badges.length ? ` <span style="font-size:10px;opacity:.8">[${badges.join(", ")}]</span>` : "";
return `<div style="padding:2px 0"><strong style="color:${i===speedWinIdx?"#7abf7a":i===costWinIdx&&hasCosts?"#bf9f7a":"#8899aa"}">${n}${badgeStr}</strong> — ${wStr} · ${dStr}${cStr}</div>`;
}).join("");
// Summary recommendation
let recommendation = "";
if (speedWinIdx === costWinIdx || !hasCosts) {
recommendation = `🏆 <strong>${names[speedWinIdx]}</strong> wins on all fronts.`;
} else {
const speedDays = days[speedWinIdx];
const cheapDays = days[costWinIdx];
const daysDiff = (cheapDays - speedDays).toFixed(1);
const costSaving = totalCosts[speedWinIdx] - totalCosts[costWinIdx];
recommendation = `⚡ <strong>${names[speedWinIdx]}</strong> reaches goal ${daysDiff}d faster, but costs $${fmt(Math.round(costSaving))} more total. 💰 <strong>${names[costWinIdx]}</strong> is cheaper. Best value: <strong>${names[effWinIdx]}</strong>.`;
}
html += `<div class="gg-cmp-verdict">${verdictLines}<div style="margin-top:8px;padding-top:8px;border-top:1px solid #2a3a20;font-size:11px;color:#8abf7a">${recommendation}</div></div></div>`;
el.compareResults.innerHTML = html;
el.compareResults.style.display = "";
el.dailyResults.style.display = "none";
el.jumpResults.style.display = "none";
el.copy.style.display = "inline-block";
el._lastResults = html;
}
// ─────────────────────────────────────────────────────────────────────────
// XANAX GRIND
// Natural regen + N xanax/day each used at cap energy.
// No happy boosters — just property max happy throughout.
// ─────────────────────────────────────────────────────────────────────────
function calculateXanaxGrind() {
const gym = getGymData();
if (!gym) throw new Error("Invalid gym selection.");
const stat = el.stat.value;
const dots = gym[STAT_KEYS[stat]];
if (!dots) throw new Error(`${gym.name} does not train ${STAT_LABELS[stat]}.`);
const consts = STAT_CONSTS[stat];
const propHappy = gf(el.happy);
const bonus = calcBonus(stat);
const statNow = gf(el.statTotal);
const statGoal = gf(el.statGoal) || statNow;
const ePerTrain = gym.energy;
const sub = el.subscriber.value;
const energyCap = E_CAP[sub];
const natEPerHr = NATURAL_E[sub];
const natEPerDay= natEPerHr * 24;
// Xanax config from xg- inputs
const xanCount = gi(el.xgXanaxCount); // xanax per day
const xanCost = gf(el.xgXanaxCost); // $ per xanax
const useRefill = el.xgRefill?.value === "yes";
const refillCost = gf(el.xgRefillCost) || 1725000;
const refillTrains = Math.floor(energyCap / ePerTrain);
// ── Time-simulation model ─────────────────────────────────────────────
// Subscriber fills 150E in exactly 5h (30E/hr). Xanax CD averages 7h.
// Each Xanax cycle actually contains TWO training opportunities:
// 1. At t=0: train whatever E has regened since last train + 250E Xanax
// 2. At t=5h: energy cap full again — train 150E (nat train mid-CD)
// 3. At t=7h: CD over → take next Xanax, repeat
//
// So 3 Xanax/day = 3 Xanax bursts + 3 mid-CD nat trains
// Then remaining hours after 3×7=21h → 3h left, 3×30=90E, not enough for a train
//
// For N Xanax/day, mid-CD nat trains = N (one per cycle) provided
// energyFillHours (5h) < cdHours (7h) — which is always true for subscribers.
// Non-subscribers fill in 100/20 = 5h too, same situation.
// CD averages 7h (6-8h range). With 4 xanax/day the CD MUST be 6h flat
// (4×6=24), so use min CD for that case. Otherwise use average.
const xanCdHrs = xanCount >= 4 ? XAN_CD_MIN : XAN_CD_AVG;
const fillHrs = energyCap / natEPerHr; // 5h to fill cap (150/30 or 100/20)
const midCdNatE = energyCap; // always fills fully between xanax
const midCdTrains = Math.floor(midCdNatE / ePerTrain);
// At each Xanax moment: energy has regened for (cdHours - fillHours) = 2h after
// the mid-CD train, so at Xanax time there's (cdHrs - fillHrs) × natEPerHr E built up
const atXanE = Math.min(energyCap, (xanCdHrs - fillHrs) * natEPerHr); // 2h × 30 = 60E
const xanBurstE = atXanE + 250; // 60 + 250 = 310E per Xanax cycle
const xanTrains = Math.floor(xanBurstE / ePerTrain);
// Hours used by N complete Xanax cycles
const xanHrsUsed = xanCount * xanCdHrs; // 3 × 7 = 21h, or 4 × 6 = 24h
const remainHrs = Math.max(0, 24 - xanHrsUsed); // 3h or 0h
// Remaining nat regen after all Xanax cycles (likely partial, < cap)
const leftoverE = Math.min(energyCap, remainHrs * natEPerHr); // 90E or 0E
const leftoverTrains= Math.floor(leftoverE / ePerTrain);
// Total daily energy breakdown for display
const totalXanE = xanCount * xanBurstE;
const totalMidCdE = xanCount * midCdNatE;
const totalDailyE = totalXanE + totalMidCdE + leftoverE + (useRefill ? energyCap : 0);
// ── Simulate one full day ─────────────────────────────────────────────
// For each Xanax: first train mid-CD nat (150E), then train Xanax burst
let cur = statNow, xanGainTotal = 0, midCdGainTotal = 0;
for (let i = 0; i < xanCount; i++) {
// Mid-CD nat train (energy filled while waiting for CD)
const midB = simulateBlock(dots, cur, propHappy, bonus, ePerTrain, consts, midCdTrains, propHappy);
midCdGainTotal += midB.totalGain;
cur = midB.finalStat;
// Xanax burst (partial regen + 250E drug)
const xanB = simulateBlock(dots, cur, propHappy, bonus, ePerTrain, consts, xanTrains, propHappy);
xanGainTotal += xanB.totalGain;
cur = xanB.finalStat;
}
// Points refill (once/day)
const refillBlock = useRefill
? simulateBlock(dots, cur, propHappy, bonus, ePerTrain, consts, refillTrains, propHappy)
: { totalGain: 0, finalStat: cur };
if (useRefill) cur = refillBlock.finalStat;
// Leftover nat regen after all Xanax cycles
const natBlock = simulateBlock(dots, cur, propHappy, bonus, ePerTrain, consts, leftoverTrains, propHappy);
const dayGain = xanGainTotal + midCdGainTotal + refillBlock.totalGain + natBlock.totalGain;
// Cost per day
const dailyCost = xanCount * xanCost + (useRefill ? refillCost : 0);
// ── Goal projection ───────────────────────────────────────────────────
let gcur = statNow, gdays = 0;
while (gcur < statGoal && gdays < MAX_ITER) {
let s = gcur;
for (let i = 0; i < xanCount; i++) {
s += simulateBlock(dots, s, propHappy, bonus, ePerTrain, consts, midCdTrains, propHappy).totalGain;
s += simulateBlock(dots, s, propHappy, bonus, ePerTrain, consts, xanTrains, propHappy).totalGain;
}
if (useRefill) s += simulateBlock(dots, s, propHappy, bonus, ePerTrain, consts, refillTrains, propHappy).totalGain;
s += simulateBlock(dots, s, propHappy, bonus, ePerTrain, consts, leftoverTrains, propHappy).totalGain;
gcur = s;
gdays++;
}
const goalUnreach = gdays >= MAX_ITER;
const totalCost = dailyCost * gdays;
// ── Xanax CD check ────────────────────────────────────────────────────
// Xanax CD is 6–8h random. Max possible = 4/day at exactly 6h CD each (4×6=24h)
const cdWarning = xanCount === 4;
// ── Per-train baseline (natural happy, natural energy) ─────────────────
const singleGain = calcGain(dots, statNow, propHappy, bonus, ePerTrain, consts);
const gymName = gym.name;
const statLabel = STAT_LABELS[stat];
let html = `<div class="gg-tldr">
<div class="gg-tldr-title">💊 Xanax Grind — ${statLabel} at ${gymName}</div>
<div class="gg-tldr-grid">
<div class="gg-tldr-cell"><div class="gg-tldr-val">+${fmt(dayGain)}</div><div class="gg-tldr-lbl">gain / day</div></div>
<div class="gg-tldr-cell"><div class="gg-tldr-val">${xanCount}× xanax</div><div class="gg-tldr-lbl">+ natural regen</div></div>
<div class="gg-tldr-cell"><div class="gg-tldr-val">${goalUnreach?"∞":`~${gdays}d`}</div><div class="gg-tldr-lbl">days to goal</div></div>
<div class="gg-tldr-cell"><div class="gg-tldr-val">${goalUnreach?"—":dateIn(gdays)}</div><div class="gg-tldr-lbl">done by</div></div>
</div>
</div>`;
if (cdWarning) {
html += `<div style="padding:8px 10px;background:#201a08;border:1px solid #4a3a10;border-radius:4px;font-size:11px;color:#bf9f5a;margin-bottom:8px">
⚠ 4 xanax/day requires back-to-back 6h CDs with no slack — only achievable on a perfect day. Most players reliably fit 3/day.
</div>`;
}
html += rsec("Your Setup");
html += row("Gym / Stat", `${gymName} — ${statLabel} (${ePerTrain}E/train)`);
html += row("Happy", `${fmt(propHappy)} — property max, used throughout`);
html += row("Total bonus", `${((bonus-1)*100).toFixed(1)}% from all perks combined`);
html += row("Per train", `~${fmtD(singleGain,2)} ${statLabel} at this happy`, "hi");
html += rsec(`Each Xanax Cycle — ×${xanCount}/day`);
html += row("How it works", `Energy fills to ${energyCap}E in ${fillHrs.toFixed(0)}h — train that, then wait ${(xanCdHrs-fillHrs).toFixed(0)}h more for CD`);
html += row("Mid-CD nat train", `${midCdNatE}E ÷ ${ePerTrain}E = ${midCdTrains} trains (energy full while waiting for CD)`);
html += row("Xanax burst", `${atXanE.toFixed(0)}E regen (${(xanCdHrs-fillHrs).toFixed(0)}h × ${natEPerHr}E/hr) + 250E = ${xanBurstE.toFixed(0)}E = ${xanTrains} trains`);
html += row("Gain per cycle", `+${fmtD((xanGainTotal + midCdGainTotal) / Math.max(1,xanCount), 0)} ${statLabel} avg (nat + xanax)`, "hi");
html += row("Total from xanax cycles", `+${fmt(xanGainTotal + midCdGainTotal)} ${statLabel}/day`, "hi");
if (xanCost > 0) html += row("Xanax cost", `$${fmt(xanCost)} × ${xanCount} = $${fmt(xanCount*xanCost)}/day`, "b");
if (useRefill) {
html += rsec("Points Refill");
html += row("Refill trains", `${energyCap}E ÷ ${ePerTrain}E = ${refillTrains} trains at ${fmt(propHappy)} happy`);
html += row("Gain", `+${fmt(refillBlock.totalGain)} ${statLabel}`, "hi");
html += row("Cost", `$${fmt(refillCost)}/day`, "b");
}
html += rsec("Leftover Regen");
html += row("After ${xanCount} cycles", `24h − ${xanHrsUsed}h = ${remainHrs}h → ${leftoverE.toFixed(0)}E → ${leftoverTrains} trains`);
html += row("Gain", `+${fmt(natBlock.totalGain)} ${statLabel}`, "hi");
html += rsec("Daily Total");
html += row("Energy breakdown", `${xanCount}×(${midCdNatE}E nat + ${xanBurstE.toFixed(0)}E xanax) + ${leftoverE.toFixed(0)}E leftover${useRefill?` + ${energyCap}E refill`:""} = ${totalDailyE.toFixed(0)}E`);
html += row("Total gain/day", `+${fmt(dayGain)} ${statLabel}`, "hi");
if (dailyCost > 0) {
const costBreakdown = [
xanCount > 0 && xanCost > 0 ? `${xanCount}× xanax $${fmt(xanCount*xanCost)}` : null,
useRefill ? `refill $${fmt(refillCost)}` : null,
].filter(Boolean).join(' + ');
html += row("Cost/day", `$${fmt(dailyCost)}${costBreakdown ? ` (${costBreakdown})` : ""}`, "b");
}
html += rsec("Reaching Your Goal");
if (goalUnreach) {
html += row("⚠ Unreachable", "Gains are too small vs your goal at this happy level", "a");
} else {
html += row("Days needed", `~${gdays} days`, "g");
html += row("Finish date", dateIn(gdays), "g");
if (totalCost > 0) html += row("Total xanax spend", `$${fmt(totalCost)} over ${gdays} days`, "b");
}
el.dailyResults.innerHTML = html;
el.dailyResults.style.display = "";
el.jumpResults.style.display = "none";
el.compareResults.style.display = "none";
el.copy.style.display = "inline-block";
el._lastResults = html;
}
function calculateSession() {
// "This Session" — what do I get RIGHT NOW with what I have?
try {
el.compareResults.style.display = "none";
const stat = el.stat.value;
const statNow = gi(el.statTotal);
const statGoal = gi(el.statGoal);
const propH = gf(el.happy) || 5025;
const gym = GYMS.find(g => String(g.id) === String(el.gym.value));
const statKey = STAT_KEYS[stat];
const dots = (statKey && gym?.[statKey]) || 5.0;
const ePerTrain= gym?.energy || 10;
const bonus = calcBonus(stat);
const consts = STAT_CONSTS[stat] || STAT_CONSTS.speed;
const sub = el.subscriber.value;
if (!statNow) { showStatus("warn", "Enter your current stat first."); return; }
if (!gym) { showStatus("warn", "Select your gym first."); return; }
// Session energy = current E + drug + refill
const baseE = gi(el.energy) || 0;
const drug = el.sessionDrug?.value || "none";
const drugE = drug === "xanax" ? 250 : drug === "lsd" ? 50 : 0;
const hasRefill= el.sessionRefill?.value === "yes";
const refillE = hasRefill ? (sub === "yes" ? 150 : 100) : 0;
const totalE = baseE + drugE + refillE;
if (totalE <= 0) { showStatus("warn", "Enter your current energy (or pick a drug/refill)."); return; }
// Happy — use field if filled, else property max
const rawH = gf(el.sessionHappy);
const startH = rawH > 0 ? rawH : propH;
const numTrains= Math.floor(totalE / ePerTrain);
const block = simulateBlock(dots, statNow, startH, bonus, ePerTrain, consts, numTrains, propH);
// Goal progress
const remaining = statGoal > statNow ? statGoal - statNow : 0;
const pctDone = statGoal > 0 ? Math.min(100, (statNow / statGoal * 100)).toFixed(1) : "?";
const pctAfter = statGoal > 0 ? Math.min(100, (block.finalStat / statGoal * 100)).toFixed(1) : "?";
const pctOfGoal = remaining > 0 ? (block.totalGain / remaining * 100).toFixed(1) : "100";
// Estimate sessions to goal from here (using this session type)
let sessionsLeft = 0;
if (statGoal > statNow && totalE > 0) {
let cur = statNow;
const MAX = 5000;
while (cur < statGoal && sessionsLeft < MAX) {
cur += simulateBlock(dots, cur, startH, bonus, ePerTrain, consts, numTrains, propH).totalGain;
sessionsLeft++;
}
if (sessionsLeft >= MAX) sessionsLeft = null; // unreachable
}
const gymName = gym?.name || "?";
const statLabel = STAT_LABELS[stat];
const fmtE = e => e > 0 ? `+${e}E` : "";
// Energy breakdown line
const parts = [];
if (baseE > 0) parts.push(`${baseE}E current`);
if (drugE > 0) parts.push(`${fmtE(drugE)} ${drug}`);
if (refillE > 0) parts.push(`${fmtE(refillE)} refill`);
const eLine = parts.join(" + ");
const html = `
<div class="gg-tldr">
<div class="gg-tldr-title">⚡ This Session — ${statLabel} at ${gymName}</div>
<div class="gg-tldr-grid">
<div class="gg-tldr-cell">
<div class="gg-tldr-val">+${fmt(Math.round(block.totalGain))}</div>
<div class="gg-tldr-lbl">${statLabel} gained</div>
</div>
<div class="gg-tldr-cell">
<div class="gg-tldr-val">${numTrains} trains</div>
<div class="gg-tldr-lbl">${totalE}E total</div>
</div>
<div class="gg-tldr-cell">
<div class="gg-tldr-val">${pctAfter}%</div>
<div class="gg-tldr-lbl">of goal after</div>
</div>
<div class="gg-tldr-cell">
<div class="gg-tldr-val">${sessionsLeft == null ? "∞" : sessionsLeft > 999 ? "999+" : sessionsLeft}</div>
<div class="gg-tldr-lbl">sessions like this left</div>
</div>
</div>
</div>
${rsec("Energy breakdown")}
${row("Using", eLine || `${totalE}E`)}
${row("Per train", `~${fmtD(calcGain(dots,statNow,startH,bonus,ePerTrain,consts),1)} ${statLabel}`)}
${row("Starting happy", fmt(startH) + (rawH > 0 ? "" : " (property max)"))}
${rsec("Your goal")}
${row("Current", `${fmt(statNow)} — ${pctDone}% of goal`)}
${row("After session", `${fmt(Math.round(block.finalStat))} — ${pctAfter}% of goal`, "g")}
${row("This session = ", `${pctOfGoal}% of remaining ${fmt(remaining)}`, "hi")}
${statGoal > 0 && sessionsLeft != null ? row("Sessions left", `~${sessionsLeft} more sessions like this`, "") : ""}
${rsec("Quick tip")}
${row("💡", drugE === 0 && baseE <= 150 ? "No drug CD? Xanax adds +250E and roughly doubles this session's gain." : drug === "xanax" ? "Good — Xanax gives the best gain per session. Stack with a Points Refill for even more." : "Use 📋 Plan ahead to project your full training schedule.")}
`;
el.dailyResults.innerHTML = html;
el.dailyResults.style.display = "";
el.jumpResults.style.display = "none";
} catch(e) {
showStatus("warn", "✗ " + e.message);
}
}
function copyResults() {
const text = (el._lastResults||"")
.replace(/<div class="gg-rsec">([^<]+)<\/div>/g, "\n── $1 ──")
.replace(/<div class="gg-row[^"]*"><span class="gg-rl">([^<]+)<\/span><span class="gg-rv">([^<]+)<\/span><\/div>/g, "$1: $2")
.replace(/<[^>]+>/g,"").trim();
navigator.clipboard.writeText(text)
.then(()=>showStatus("ok","✓ Copied."))
.catch(()=>showStatus("warn","⚠ Copy failed."));
}
// ─────────────────────────────────────────────────────────────────────────
// ─────────────────────────────────────────────────────────────────────────
// GAINS LOGGER v2
// Clean schema per entry:
// { ts, stat, gymName, gymId, gymDots, ePerTrain,
// preStat, happy, propHappy, energyUsed, actualGain, bonus, auto }
// TTL: 30 days. No compression, no legacy compat cruft.
// ─────────────────────────────────────────────────────────────────────────
const LOG_KEY = "gainsLog_v2";
const LOG_TTL = 30 * 24 * 60 * 60 * 1000;
function logLoad() { try { return JSON.parse(_load(LOG_KEY,"[]")); } catch(_){ return []; } }
function logSave(e) { _save(LOG_KEY, JSON.stringify(e)); }
function logPrune(entries) { const cut=Date.now()-LOG_TTL; return entries.filter(e=>e.ts>cut); }
// Predict gain for one entry at a given dots value
function predictEntry(e, dots) {
const consts = STAT_CONSTS[e.stat] || STAT_CONSTS.speed;
const trains = Math.round((e.energyUsed||e.ePerTrain||10) / (e.ePerTrain||10));
return simulateBlock(dots, e.preStat, e.happy, e.bonus||1, e.ePerTrain||10, consts, trains, e.propHappy||0).totalGain;
}
// Golden-section search for best-fit dots value
function calibrateDots(entries) {
if (entries.length < 2) return null;
const loss = d => entries.reduce((s,e) => { const diff=predictEntry(e,d)-e.actualGain; return s+diff*diff; }, 0);
const phi = (Math.sqrt(5)-1)/2;
let a=1.0, b=9.5, c=b-phi*(b-a), dd=a+phi*(b-a);
for (let i=0; i<100; i++) {
if (loss(c)<loss(dd)) b=dd; else a=c;
c=b-phi*(b-a); dd=a+phi*(b-a);
if (Math.abs(b-a)<0.001) break;
}
const bestDots = (a+b)/2;
const meanA = entries.reduce((s,e)=>s+e.actualGain,0)/entries.length;
const ssTot = entries.reduce((s,e)=>s+Math.pow(e.actualGain-meanA,2),0);
const ssRes = entries.reduce((s,e)=>s+Math.pow(predictEntry(e,bestDots)-e.actualGain,2),0);
const r2 = ssTot>0 ? 1-ssRes/ssTot : 1;
const rmse = Math.sqrt(ssRes/entries.length);
return { dots: Math.round(bestDots*100)/100, r2, rmse };
}
function renderLogger() {
const all = logPrune(logLoad());
logSave(all);
const badge = q("#gg-logger-badge");
if (badge) badge.textContent = all.length ? `${all.length} entries` : "";
const entEl = q("#gg-log-entries");
const calEl = q("#gg-log-calibration");
if (!entEl) return;
if (!all.length) {
entEl.innerHTML = `<div style="font-size:11px;color:#445;padding:6px 0">No entries yet — auto-log is watching for train results.</div>`;
if (calEl) calEl.innerHTML = "";
return;
}
// ── Table ────────────────────────────────────────────────────────────
// Two-line layout per entry — fits mobile without horizontal scroll:
// Line 1: age/source · stat · energyE · happy
// Line 2: Actual: X vs Pred: Y → diff (+Z%)
let tableRows = "";
for (let i = all.length-1; i >= 0; i--) {
const e = all[i];
const pred = predictEntry(e, e.gymDots);
const diff = e.actualGain - pred;
const errPct = pred > 0 ? (diff / pred * 100) : 0;
const isOutlier = Math.abs(errPct) > OUTLIER_THRESHOLD * 100;
const errCol = isOutlier ? "#bf5a3a" : Math.abs(errPct) > 10 ? "#bf7a3a" : Math.abs(errPct) > 5 ? "#9a9a4a" : "#5a9f5a";
const diffStr = `${diff >= 0 ? "+" : ""}${fmt(Math.round(diff))} (${errPct >= 0 ? "+" : ""}${errPct.toFixed(1)}%)`;
const age = Math.round((Date.now()-e.ts)/3600000);
const ageS = age < 1 ? "now" : age < 24 ? `${age}h` : `${Math.round(age/24)}d`;
const srcDot = e.auto
? `<span style="color:#3a6a3a;font-size:10px" title="auto-logged">●</span>`
: `<span style="color:#556;font-size:10px" title="manual">M</span>`;
const statLabel = (e.stat||"").slice(0,3).toUpperCase();
const rowBg = i % 2 === 0 ? "#191919" : "#161616";
// Outlier warning — likely a partial jump log (energy understated)
const outlierBanner = isOutlier ? `
<div style="margin:3px 0;padding:3px 6px;background:#2a1a10;border:1px solid #5a3020;border-radius:3px;font-size:10px;color:#bf7a4a">
⚠ Likely bad log — gain vs energy mismatch (+${errPct.toFixed(0)}% off). Probably a jump session where only the last train was captured. Delete this entry.
</div>` : "";
const sessLabel = {
"jump-full":"🎉jump", "jump-partial":"⚡jump",
"xanax-stack":"💊stack", "xanax-burst":"💊xan",
"refill-or-cap":"🔋full", "regen":"🌿regen", "small":"↳tail"
}[e.sessionType] || "";
const sessTag = sessLabel
? `<span style="color:#5a6a8a;font-size:9px;background:#161a22;padding:1px 4px;border-radius:2px">${sessLabel}</span>`
: "";
tableRows += `
<div style="padding:6px 8px;background:${rowBg};border-bottom:1px solid ${isOutlier?"#3a2010":"#1e1e2a"}">
<!-- Row 1: meta -->
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:3px">
<div style="display:flex;gap:6px;align-items:center;font-size:10px;flex-wrap:wrap">
${srcDot}
<span style="color:#445">${ageS}</span>
<span style="color:#6a8a6a;font-weight:bold">${statLabel}</span>
<span style="color:#445">${e.energyUsed||"?"}E</span>
<span style="color:#445">😊${fmt(e.happy||0)}</span>
${sessTag}
</div>
<button data-idx="${i}" class="gg-log-del" style="background:none;border:none;color:#553;cursor:pointer;font-size:14px;padding:0 4px;line-height:1">✕</button>
</div>
${outlierBanner}
<!-- Row 2: actual vs predicted -->
<div style="display:flex;align-items:baseline;gap:0;font-family:monospace;font-size:12px;flex-wrap:wrap;gap:4px">
<span style="color:#888;font-size:10px">Actual</span>
<span style="color:#c8e6c8;font-weight:bold">+${fmt(e.actualGain)}</span>
<span style="color:#334;font-size:11px">vs</span>
<span style="color:#888;font-size:10px">Pred</span>
<span style="color:#7a8a9a">+${fmt(Math.round(pred))}</span>
<span style="color:#334;font-size:11px">→</span>
<span style="color:${errCol};font-weight:bold">${diffStr}</span>
</div>
</div>`;
}
entEl.innerHTML = `
<div style="margin-bottom:4px;font-size:9px;color:#334">● auto M manual diff = actual − predicted</div>
<div style="border:1px solid #252535;border-radius:4px;overflow:hidden;margin-bottom:8px">${tableRows}</div>`;
entEl.querySelectorAll(".gg-log-del").forEach(btn => {
btn.addEventListener("click", () => {
const entries = logLoad();
entries.splice(parseInt(btn.dataset.idx), 1);
logSave(entries);
renderLogger();
});
});
// ── Calibration ───────────────────────────────────────────────────────
if (!calEl) return;
if (all.length < 2) {
calEl.innerHTML = `<div style="font-size:11px;color:#445;padding:4px 0">Log 2+ entries to calibrate gym dots.</div>`;
return;
}
// Exclude outlier entries (>OUTLIER_THRESHOLD error) from calibration —
// these are bad logs (e.g. partial jump captures with wrong energy)
const validEntries = all.filter(e => {
const p = predictEntry(e, e.gymDots);
return p > 0 && Math.abs((e.actualGain - p) / p) < OUTLIER_THRESHOLD;
});
const outlierCount = all.length - validEntries.length;
if (validEntries.length < 2) {
calEl.innerHTML = `<div style="font-size:11px;color:#7a5a3a;padding:4px 0">⚠ Need 2+ valid entries to calibrate. ${outlierCount} entr${outlierCount===1?"y":"ies"} excluded as outlier${outlierCount===1?"":"s"} (>50% error). Delete bad logs from the list above.</div>`;
return;
}
const cal = calibrateDots(validEntries);
if (!cal) return;
const currentDots = getGymData()?.[STAT_KEYS[el.stat?.value]] ?? "?";
const dotsMatch = Math.abs(cal.dots - parseFloat(currentDots)) < 0.15;
const conf = validEntries.length >= 5 ? "High" : validEntries.length >= 3 ? "Medium" : "Low";
const confCol = validEntries.length >= 5 ? "#4a9a4a" : validEntries.length >= 3 ? "#9a9a4a" : "#9a7a3a";
calEl.innerHTML = `
<div style="padding:10px;background:#141a14;border:1px solid #253025;border-radius:4px;margin-top:4px">
<div style="font-size:10px;color:#4a7a4a;text-transform:uppercase;letter-spacing:.06em;margin-bottom:8px">Calibrated Gym Dots</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<div>
<span style="font-size:22px;font-weight:bold;color:#7abf7a">${cal.dots}</span>
<span style="font-size:11px;color:#556;margin-left:8px">vs API: ${currentDots} ${dotsMatch?"✓":"⚠ differs"}</span>
</div>
<div style="text-align:right;font-size:11px;color:#556;line-height:1.6">
<div>R² ${(cal.r2*100).toFixed(1)}%</div>
<div>RMSE ±${fmt(Math.round(cal.rmse))}</div>
<div style="color:${confCol}">${conf} (${validEntries.length} valid entries)</div>
</div>
</div>
${outlierCount > 0 ? `<div style="font-size:10px;color:#7a5a3a;margin-bottom:6px">⚠ ${outlierCount} outlier${outlierCount>1?"s":""} excluded from calibration (>50% error — likely bad jump logs). Delete them from the list above.</div>` : ""}
${!dotsMatch && validEntries.length>=3 ? `<div style="font-size:11px;color:#9a9a4a;margin-bottom:8px">⚠ Calibrated dots differ from API — your real gym dots may differ from the fallback table. Run Auto-fill to refresh API data.</div>` : ""}
${validEntries.length>=5 ? `<button id="gg-log-apply" class="gg-btn" style="width:100%;background:#182018;border-color:#2a4a2a;color:#7abf7a;font-size:12px">Apply ${cal.dots} dots to calculator</button>` : `<div style="font-size:10px;color:#445">Log ${5-validEntries.length} more valid entries to unlock Apply.</div>`}
</div>`;
q("#gg-log-apply")?.addEventListener("click", () => {
const gymId = parseInt(el.gym.value);
const stat = el.stat.value;
const sk = STAT_KEYS[stat];
const idx = GYMS.findIndex(g=>g.id===gymId);
if (idx>=0 && sk) {
GYMS[idx] = {...GYMS[idx], [sk]: cal.dots};
buildGymMaps(); updateDotsDisplay(); updateBestGymPanel();
showStatus("ok", `✓ Applied ${cal.dots} dots to ${GYMS[idx].name} ${STAT_LABELS[stat]}.`);
}
});
}
// Build plain-text log for sharing (tab-separated, easy to paste to Claude)
function buildLogText(entries) {
if (!entries.length) return "No entries logged.";
const header = [
"#","date","scriptVer","playerId","playerName",
"stat","gym","gymId","dots","E/train","subscriber","sessionType",
"preStat","newStat","happy","propHappy","energyUsed","actualGain",
"perkFaction","perkProperty","perkEduStat","perkEduGen","perkJob","perkBook","perkSteroids","perkSneakers",
"bonusCombined","predicted","err%","source"
].join("\t");
const rows = entries.map((e, i) => {
const pred = predictEntry(e, e.gymDots);
const errPct = pred > 0 ? ((e.actualGain - pred) / pred * 100).toFixed(2) : "?";
const date = new Date(e.ts).toISOString().slice(0,16).replace("T"," ");
return [
i+1,
date,
e.scriptVer || "?",
e.playerId || "?",
e.playerName || "?",
e.stat,
e.gymName || "?",
e.gymId || "?",
e.gymDots,
e.ePerTrain || 10,
e.subscriber || "?",
e.sessionType || "?",
e.preStat,
e.newStat || (e.preStat + e.actualGain),
e.happy,
e.propHappy || "?",
e.energyUsed || "?",
e.actualGain,
e.perkFaction ?? "?",
e.perkProperty ?? "?",
e.perkEduStat ?? "?",
e.perkEduGen ?? "?",
e.perkJob ?? "?",
e.perkBook ?? "?",
e.perkSteroids ?? "?",
e.perkSneakers ?? "?",
(e.bonus || 1).toFixed(4),
pred.toFixed(1),
errPct,
e.auto ? "auto" : "manual",
].join("\t");
});
return [header, ...rows].join("\n");
}
// Wire up logger UI
(function initLogger() {
// ── Auto-log toggle ──────────────────────────────────────────────────
let autoLogEnabled = _load('autoLogEnabled', true);
const updateAutoLogUI = () => {
const dot = q("#gg-autolog-dot");
const lbl = q("#gg-autolog-lbl");
if (!dot) return;
dot.style.background = autoLogEnabled ? '#5a9f5a' : '#554444';
lbl.style.color = autoLogEnabled ? '#7abf7a' : '#aa6a6a';
lbl.textContent = autoLogEnabled ? 'Auto-log: ON' : 'Auto-log: OFF';
};
q("#gg-autolog-row")?.addEventListener("click", () => {
autoLogEnabled = !autoLogEnabled;
_save('autoLogEnabled', autoLogEnabled);
window._ggAutoLogEnabled = autoLogEnabled;
updateAutoLogUI();
});
window._ggAutoLogEnabled = autoLogEnabled;
updateAutoLogUI();
// ── Fill from page ───────────────────────────────────────────────────
// Scans the current DOM for the most recent train result text and
// pre-fills all fields it can find. User only needs to confirm/correct.
q("#gg-log-autofill")?.addEventListener("click", () => {
let filled = [];
// Try to find the latest result text anywhere on the page
let gain = null, energyUsed = null, statKey = null, newStat = null;
// Scan all text nodes for known result patterns
const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, {
acceptNode: n => wrap.contains(n.parentElement) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT
});
let node, bestMatch = null, bestLen = 0;
while ((node = walker.nextNode())) {
const t = node.textContent || '';
// Prefer longer matches (more data)
if (t.length > bestLen && (
/you used\s+[\d,]+\s+energy.*increasing it by/i.test(t) ||
/you (?:have )?gained\s+[\d,]+(?:\.\d+)?\s+(?:strength|speed|defense|dexterity)/i.test(t)
)) {
bestMatch = t;
bestLen = t.length;
}
}
if (bestMatch) {
// Pattern 1: full desktop format
const m1 = bestMatch.match(/you used\s+([\d,]+)\s+energy[^.]*?training (?:your\s+)?(strength|speed|defense|dexterity)[^.]*?increasing it by\s+([\d,]+(?:\.\d+)?)\s+to\s+([\d,]+(?:\.\d+)?)/i);
if (m1) {
energyUsed = parseInt(m1[1].replace(/,/g,''));
statKey = m1[2].toLowerCase();
gain = parseFloat(m1[3].replace(/,/g,''));
newStat = Math.round(parseFloat(m1[4].replace(/,/g,'')));
}
// Pattern 2: TornPDA short format
if (!gain) {
const m2 = bestMatch.match(/you (?:have )?gained\s+([\d,]+(?:\.\d+)?)\s+(strength|speed|defense|dexterity)/i);
if (m2) {
gain = parseFloat(m2[1].replace(/,/g,''));
statKey = m2[2].toLowerCase();
const mTrains = bestMatch.match(/(\d+)\s+times/i);
const gymE = getGymData()?.energy || 10;
energyUsed = mTrains ? parseInt(mTrains[1]) * gymE : gymE;
}
}
}
// Fill Actual Gain
if (gain) {
q("#gg-log-gain").value = Math.round(gain);
filled.push("gain");
}
// Fill Energy Used
if (energyUsed) {
q("#gg-log-energy").value = energyUsed;
filled.push("energy");
}
// Fill Pre-train Stat: use newStat - gain if available, else calculator field
if (gain && newStat) {
q("#gg-log-stat").value = Math.round(newStat - gain);
filled.push("stat");
} else {
const calcStat = gf(el.statTotal);
if (calcStat > 0) {
q("#gg-log-stat").value = calcStat;
filled.push("stat");
}
}
// Fill Happy: use lastTrainedHappy snapshot, else live read, else propHappy
const happy = lastTrainedHappy || readHappyFromPage() || gf(el.happy);
if (happy) {
q("#gg-log-happy").value = happy;
filled.push("happy");
}
if (filled.length === 0) {
const statusEl = q("#gg-log-copy-status");
if (statusEl) {
statusEl.textContent = "⚠ No train result found on page — fill manually.";
statusEl.style.color = "#bf9f5a";
statusEl.style.display = "block";
setTimeout(() => statusEl.style.display = "none", 3000);
}
} else {
const statusEl = q("#gg-log-copy-status");
const missing = ["gain","energy","stat","happy"].filter(f => !filled.includes(f));
const msg = `⚡ Filled: ${filled.join(", ")}${missing.length ? ` — enter ${missing.join(", ")} manually` : " — ready to log!"}`;
if (statusEl) {
statusEl.textContent = msg;
statusEl.style.color = "#7abf7a";
statusEl.style.display = "block";
setTimeout(() => statusEl.style.display = "none", 4000);
}
// Focus the first empty field so user can type straight away
const emptyFields = [
{id:"#gg-log-gain", filled: !!gain},
{id:"#gg-log-energy", filled: !!energyUsed},
{id:"#gg-log-stat", filled: filled.includes("stat")},
{id:"#gg-log-happy", filled: !!happy},
].find(f => !f.filled);
if (emptyFields) q(emptyFields.id)?.focus();
}
});
q("#gg-log-add")?.addEventListener("click", () => {
const gain = parseFloat(q("#gg-log-gain")?.value) || 0;
const energyUsed= parseFloat(q("#gg-log-energy")?.value) || 0;
const preStat = parseFloat(q("#gg-log-stat")?.value) || 0;
const happy = parseFloat(q("#gg-log-happy")?.value) || 0;
if (!gain || !preStat || !happy || !energyUsed) {
showStatus("warn", "⚠ Fill in all four fields to log an entry.");
return;
}
const gym = getGymData();
const stat = el.stat.value;
const sk = STAT_KEYS[stat];
const dots = gym?.[sk] || 0;
if (!dots) { showStatus("warn", "⚠ Gym doesn't train this stat."); return; }
const entry = {
ts: Date.now(),
scriptVer: "3.34",
playerId: _load('playerId', null),
playerName: _load('playerName', null),
stat,
gymId: gym?.id,
gymName: gym?.name,
gymDots: dots,
ePerTrain: gym?.energy || 10,
subscriber: el.subscriber?.value || "?",
preStat,
newStat: Math.round(preStat + gain),
happy,
propHappy: gf(el.happy) || 5025,
energyUsed,
actualGain: Math.round(gain),
perkFaction: parseFloat(el.factionPerk?.value) || 0,
perkProperty: parseFloat(el.propertyPerk?.value) || 0,
perkEduStat: parseFloat(el.eduStatPerk?.value) || 0,
perkEduGen: parseFloat(el.eduGenPerk?.value) || 0,
perkJob: parseFloat(el.jobPerk?.value) || 0,
perkBook: parseFloat(el.bookPerk?.value) || 0,
perkSteroids: parseFloat(el.steroids?.value) || 0,
perkSneakers: parseFloat(el.sportsSneakers?.value) || 0,
bonus: calcBonus(stat),
auto: false,
};
const entries = logPrune(logLoad());
entries.push(entry);
logSave(entries);
q("#gg-log-gain").value = "";
q("#gg-log-energy").value = "";
q("#gg-log-stat").value = "";
q("#gg-log-happy").value = "";
renderLogger();
showStatus("ok", `✓ Entry logged (${entries.length} total).`);
});
// ── Copy log ─────────────────────────────────────────────────────────
q("#gg-log-copy")?.addEventListener("click", () => {
const entries = logPrune(logLoad());
if (!entries.length) { showStatus("warn", "⚠ Nothing to copy."); return; }
const text = buildLogText(entries);
const statusEl = q("#gg-log-copy-status");
const showSt = (msg) => { if (statusEl) { statusEl.textContent=msg; statusEl.style.display="block"; setTimeout(()=>statusEl.style.display="none", 3000); } };
// Try GM_setClipboard first (always works in Tampermonkey/TornPDA)
if (typeof GM_setClipboard !== "undefined") {
GM_setClipboard(text);
showSt(`✓ ${entries.length} entries copied — paste anywhere (Notes, Discord, chat with Claude).`);
return;
}
// Then try navigator.clipboard (works in modern browsers)
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text)
.then(() => showSt(`✓ ${entries.length} entries copied to clipboard.`))
.catch(() => showFallback(text));
return;
}
showFallback(text);
});
function showFallback(text) {
// Full-screen selectable textarea fallback — always works in TornPDA
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:999999;display:flex;flex-direction:column;padding:16px;gap:10px';
overlay.innerHTML = `
<div style="background:#1a1a1a;border:1px solid #2a4a2a;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:10px;flex:1;overflow:hidden">
<div style="color:#7abf7a;font-size:14px;font-weight:bold">📋 Gains Log — Select All & Copy</div>
<div style="font-size:11px;color:#556;line-height:1.5">Long-press → Select All → Copy. Then paste to Claude, Discord, or Notes.</div>
<textarea id="gg-log-ta" readonly style="flex:1;min-height:0;background:#0e0e0e;color:#7a9a7a;border:1px solid #2a4a2a;border-radius:4px;padding:10px;font-size:11px;font-family:monospace;resize:none;line-height:1.4">${text.replace(/</g,'<').replace(/>/g,'>')}</textarea>
<button id="gg-fb-close" style="background:#181818;border:1px solid #333;color:#888;padding:10px;border-radius:4px;font-size:14px;font-weight:bold">Close</button>
</div>`;
document.body.appendChild(overlay);
const ta = overlay.querySelector('#gg-log-ta');
// Set value directly (not innerHTML) so clipboard picks it up
ta.value = text;
setTimeout(() => { ta.focus(); ta.select(); }, 80);
overlay.querySelector('#gg-fb-close').addEventListener('click', () => overlay.remove());
}
// ── Clear ────────────────────────────────────────────────────────────
q("#gg-log-clear")?.addEventListener("click", () => {
if (!confirm("Clear all logged entries? This cannot be undone.")) return;
logSave([]);
renderLogger();
showStatus("ok", "✓ Log cleared.");
});
// ── Pre-fill stat from calculator on panel open ───────────────────────
q("#gg-logger-panel")?.querySelector(".gg-collapsible-header")?.addEventListener("click", () => {
const cur = gf(el.statTotal);
if (cur > 0 && !q("#gg-log-stat")?.value) {
const statInput = q("#gg-log-stat");
if (statInput) statInput.value = cur;
}
});
renderLogger();
})();
// Debug buttons
const debugLog = [];
const _ow = console.warn.bind(console);
console.warn = (...a) => { _ow(...a); if (String(a[0]).includes("[AIO")) debugLog.push("W:"+a.join(" ")); };
q("#gg-copy-logs")?.addEventListener("click", () => {
const log = [
"Gym Planner v3.0 " + new Date().toISOString(),
"GYMS:" + GYMS.length + " maxSPD:" + Math.max(...GYMS.map(g=>g.spd||0)),
"Jail:" + JSON.stringify(GYMS.find(g=>g.id===32)),
"CanPrices:" + JSON.stringify(canPrices),
"CAN_IDs:" + CAN_TYPES.map(c=>c.label+":"+c.id).join(","),
"Stats:" + JSON.stringify(allStats), "ActivePanel:" + (typeof activePanel !== 'undefined' ? activePanel : 'n/a'),
"---", ...debugLog.slice(-20),
].join("\n");
GM_setClipboard ? GM_setClipboard(log) : navigator.clipboard?.writeText(log);
const ds = q("#gg-debug-status");
if (ds) { ds.style.display = "block"; ds.textContent = "✓ Copied"; setTimeout(() => ds.style.display = "none", 2000); }
});
q("#gg-clear-cache")?.addEventListener("click", () => {
["gymDataCache_v2","gymDataCacheTs_v2","gymDataCache","gymDataCacheTs",
PRICE_CACHE_KEY,"canItemIds","canItemIdsTs","lastAutofillTs","manualGymOwned"]
.forEach(k => { try { GM_deleteValue(k); } catch(_){} });
CAN_TYPES.forEach(c => c.id = null); CANDY_TYPES.forEach(c => c.id = null);
GYMS = GYMS_FALLBACK.slice(); buildGymMaps(); rebuildGymDropdown();
const ds = q("#gg-debug-status");
if (ds) { ds.style.display = "block"; ds.style.color = "#bf7a7a"; ds.textContent = "✓ Cleared"; setTimeout(() => ds.style.display = "none", 3000); }
showStatus("ok", "✓ Cache cleared. Reload or Auto-fill.");
});
})();